mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 20:50:21 +02:00
CLOSES #1603
This commit is contained in:
@@ -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**
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user