diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index 23463f50..e378880f 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -294,7 +294,6 @@ function TouchSkin.parse(text) page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0), w = num(vp[3], 1), h = num(vp[4], 1) } page.viewportFill = toBool(kv[p .. "_viewport_fill"]) - page.viewportExpand = toBool(kv[p .. "_viewport_expand"]) end page.pixelCoords = not page.normalized @@ -447,7 +446,6 @@ function TouchSkin.parseNative(text) page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0), w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) } page.viewportFill = raw.viewport.fill == true - page.viewportExpand = raw.viewport.expand == true end for _, c in ipairs(raw.controls or {}) do local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul") @@ -526,7 +524,6 @@ function TouchSkin.toNative(skin) x = page.viewport.x, y = page.viewport.y, w = page.viewport.w, h = page.viewport.h, fill = page.viewportFill or nil, - expand = page.viewportExpand or nil, } end for _, ctl in ipairs(page.controls or {}) do @@ -922,7 +919,6 @@ function TouchSkin.toRetroArchConfig(skin) if page.viewport then out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport) if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end - if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end end local controls = {} for _, ctl in ipairs(page.controls or {}) do @@ -1434,6 +1430,16 @@ local function remainderBox(ox, oy, w, h, bx, by, bw, bh) return best[1], best[2], best[3], best[4] end +-- The deck box pinned to the lower edge (see pageBox) leaves room above it +-- that belongs to the game, so a screen rect flush with the top of the deck +-- grows into it instead of showing a black band. +local function deckHeadroom(y, vh, by, bh, oy, h) + if by <= oy + 0.5 then return y, vh end + if by + bh < oy + h - 0.5 then return y, vh end + if y - by > math.max(2, bh * 0.01) then return y, vh end + return oy, vh + (y - oy) +end + function TouchSkin.pageViewport(page, w, h, ox, oy) if not page then return nil end ox, oy = ox or 0, oy or 0 @@ -1443,12 +1449,13 @@ function TouchSkin.pageViewport(page, w, h, ox, oy) local x, y = bx + v.x * bw, by + v.y * bh local vw, vh = v.w * bw, v.h * bh if vw <= 0 or vh <= 0 then return nil end - return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true + y, vh = deckHeadroom(y, vh, by, bh, oy, h) + return x, y, vw, vh, page.viewportFill == true end if page.screenFit == "remainder" then local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh) if not x then return nil end - return x, y, vw, vh, false, false + return x, y, vw, vh, false end return nil end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 860b5e8b..13c3eedd 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1067,9 +1067,22 @@ local GAME_TABS = { local HEADER_TABS = { { id = "mods", key = "tab-mods" }, { id = "find", key = "tab-find" }, - { id = "skins", key = "tab-skins", glyph = true }, + { id = "skins", key = "tab-skins", glyph = true, beta = true }, { id = "bug", key = "tab-bug" }, } + +local BETA_TAG_OPTS = { fill = true, bold = true, ink = PAL.inverse } + +local function drawBetaTag(x, y, w, h) + Kit.tag(x, y, w, h, "BETA", PAL.yellow, BETA_TAG_OPTS) +end + +local function overlayBeta(tx, ty, w, tabH, m) + local bh = math.floor(11 * m.s) + local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s)) + drawBetaTag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh) +end + for _, t in ipairs(HEADER_TABS) do t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter } if t.glyph then @@ -1273,6 +1286,7 @@ local function buildHeader(imp, m) o.image = t.icon o.action = chrome.tab[t.id] btn(imp, tx, ty, w, tabH, t.key, "", o) + if t.beta then overlayBeta(tx, ty, w, tabH, m) end tx = tx + w + tabGap end -- The bug-report chip sits LAST, past the sync chip. @@ -1290,10 +1304,7 @@ local function buildHeader(imp, m) local o = chrome.sync o.active = imp._syncModal ~= nil btn(imp, tx, ty, w, tabH, "tab-sync", "", o) - local bh = math.floor(11 * m.s) - local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s)) - Kit.tag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh, - "BETA", o.active and PAL.inverse or PAL.yellow) + overlayBeta(tx, ty, w, tabH, m) local eng = imp._sync if eng and eng.busy and eng:busy() then Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s), @@ -4490,8 +4501,8 @@ local function syncTitle(imp, m, px, py, pw, pad) Kit.text("button", label, px + pad, py, PAL.heading) local bh = math.floor(15 * m.s) local bw = Kit.textWidth("micro", "BETA") + math.floor(14 * m.s) - Kit.tag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s), - py + (Kit.textHeight("button") - bh) / 2, bw, bh, "BETA", PAL.yellow) + drawBetaTag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s), + py + (Kit.textHeight("button") - bh) / 2, bw, bh) return py + Kit.textHeight("button") + math.floor(12 * m.s) end diff --git a/src/render/Playfield.lua b/src/render/Playfield.lua index e150dfef..c9eb7897 100644 --- a/src/render/Playfield.lua +++ b/src/render/Playfield.lua @@ -3,8 +3,6 @@ local TouchSkin = require("src.core.TouchSkin") local Playfield = {} -Playfield.WIDTH, Playfield.HEIGHT = 160, 144 - Playfield.entered = false Playfield.box = nil @@ -35,14 +33,9 @@ function Playfield.cutout(sw, sh) end function Playfield.rect(sw, sh) - local x, y, w, h, _, expand = Playfield.cutout(sw, sh) + local x, y, w, h = Playfield.cutout(sw, sh) if not x then return 0, 0, sw or 0, sh or 0, false end - if expand then return x, y, w, h, true end - local s = math.max(1, math.floor(math.min(w / Playfield.WIDTH, - h / Playfield.HEIGHT))) - local pw = math.min(w, Playfield.WIDTH * s) - local ph = math.min(h, Playfield.HEIGHT * s) - return x + math.floor((w - pw) / 2), y + math.floor((h - ph) / 2), pw, ph, true + return x, y, w, h, true end function Playfield.enter(x, y, w, h) diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index bfb29b0c..412f793c 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -87,12 +87,12 @@ local function displayMetrics() if dpiX < 1e-6 then dpiX = 1 end if dpiY < 1e-6 then dpiY = 1 end local vx, vy = 0, 0 - local cut, grow = false, false - local sx, sy, sw, sh, _, expand = Playfield.cutout(pw, ph) + local cut = false + local sx, sy, sw, sh = Playfield.cutout(pw, ph) if sx then - vx, vy, pw, ph, cut, grow = sx, sy, sw, sh, true, expand + vx, vy, pw, ph, cut = sx, sy, sw, sh, true end - return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow + return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut end local function positionLift(ph, contentPx, dpiY, cut) @@ -275,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, _, dpiY, _, _, cut, grow = displayMetrics() + local _, _, pw, ph, _, dpiY, _, _, cut = 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 @@ -287,7 +287,6 @@ function Renderer:worldViewSize() -- 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 not cap and cut and not grow then cap = self:fitScale() end if cap then local uiw, uih = self:uiSize() pw = cut and math.min(pw, uiw * cap) or uiw * cap diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index dcdfb17b..b27aa26e 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -456,9 +456,10 @@ function Studio.setCanvas(index, fromSync) page.viewportFill = false markDirty() end - -- A cfg-authored aspect_ratio is the overlay's design aspect; keep it so - -- the preview letterboxes like RetroArch instead of stretching (#1503). - if page and not page.aspectFromCfg then + -- A cfg-authored aspect_ratio is the overlay's design aspect, and bezel art + -- is its own design canvas (TouchSkin.applyImageAspect); keep either so the + -- preview lays the page out exactly the way the game does (#1503). + if page and not (page.aspectFromCfg or page.aspectFromImage) then page.aspect = canvas.w / canvas.h end end diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index 18718dd2..56adc505 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -749,12 +749,23 @@ function Kit.chip(x, y, w, h, label, on, color, id) end -- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE" --- markers on mod rows. -function Kit.tag(x, y, w, h, label, color) +-- markers on mod rows. Pass opts.fill for a solid chip (BETA badges); ink +-- then defaults to PAL.inverse so the label stays readable on the fill. +function Kit.tag(x, y, w, h, label, color, opts) if not G then return end - Theme.strokeRounded(x, y, w, h, color or PAL.line, 0.7, 1) - Kit.textCenter("micro", label, x, y + (h - Kit.textHeight("micro")) / 2, w, - color or PAL.muted) + opts = opts or {} + if opts.fill then + Theme.fillRounded(x, y, w, h, color or PAL.yellow, 1, h / 2) + else + Theme.strokeRounded(x, y, w, h, color or PAL.line, 0.7, 1) + end + local ink = opts.ink or (opts.fill and PAL.inverse) or color or PAL.muted + local ty = y + (h - Kit.textHeight("micro")) / 2 + if opts.bold then + Kit.textCenterBold("micro", label, x, ty, w, ink) + else + Kit.textCenter("micro", label, x, ty, w, ink) + end end -- Checkbox row. Returns (newChecked, changed). diff --git a/tests/drivers/skin_screen_shot.lua b/tests/drivers/skin_screen_shot.lua new file mode 100644 index 00000000..aa796d8e --- /dev/null +++ b/tests/drivers/skin_screen_shot.lua @@ -0,0 +1,57 @@ +-- Screen-rect check for a touch skin, both generations. +-- +-- POKEPORT_DRIVER=tests/drivers/skin_screen_shot.lua love . +-- POKEPORT_GAME=gold POKEPORT_SKIN=my_skin POKEPORT_DRIVER=... love . +-- +-- Gold's cache lives under the DEFAULT save identity, so a POKEPORT_IDENTITY +-- sandbox has to be seeded with it first or the boot hangs in the importer: +-- cp -R ~/Library/Application\ Support/LOVE/pokemon-love2d/{gold,skins} \ +-- ~/Library/Application\ Support/LOVE// +local U = require("tests.drivers.util") +local GameVersion = require("src.core.GameVersion") + +local function env(name, fallback) + local v = os.getenv(name) + if v == nil or v == "" then return fallback end + return v +end + +local function tap(game, button) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false +end + +return function(game) + local TouchControls = require("src.core.TouchControls") + local out = env("POKEPORT_SHOT_DIR", ".") + local gen2 = GameVersion.generation() == 2 + + love.window.setMode(tonumber(env("POKEPORT_SHOT_W", 460)), + tonumber(env("POKEPORT_SHOT_H", 950))) + local skin = env("POKEPORT_SKIN", "gb_anim") + local ok, err = TouchControls:selectSkin(skin) + assert(ok, "skin " .. skin .. ": " .. tostring(err)) + + U.wait(60) + if gen2 then + local A = require("tests.drivers.gold.adapter") + assert(game.world and game.world.map, "gold world did not boot") + A.teleport(game, env("POKEPORT_SHOT_MAP", "NEW_BARK_TOWN"), 6, 6) + U.wait(120) + else + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + U.teleport(game, env("POKEPORT_SHOT_MAP", "PALLET_TOWN"), 10, 8, "down") + U.wait(40) + end + U.shot(game, out .. "/skin_world.png") + + tap(game, "start") + U.wait(60) + U.shot(game, out .. "/skin_menu.png") + + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/engine/launcher_sync_modal.lua b/tests/engine/launcher_sync_modal.lua index 10ad290e..df6818b5 100644 --- a/tests/engine/launcher_sync_modal.lua +++ b/tests/engine/launcher_sync_modal.lua @@ -316,6 +316,8 @@ check(view:find('"tab-sync"', 1, true) ~= nil, local header = view:match("local HEADER_TABS = %{(.-)%}\n") check(header and header:find('id = "skins"', 1, true) ~= nil, "and it sits beside the skins tab") +check(header and header:find("beta = true", 1, true) ~= nil, + "the skins tab carries a BETA badge too") check(view:find('"BETA"', 1, true) ~= nil, "the button and the modal are labelled BETA") check(view:find("buildSyncModal", 1, true) ~= nil, diff --git a/tests/engine/skin_studio_test.lua b/tests/engine/skin_studio_test.lua index a95391ee..957c5a14 100644 --- a/tests/engine/skin_studio_test.lua +++ b/tests/engine/skin_studio_test.lua @@ -111,6 +111,25 @@ check(Studio.page().viewport ~= nil, "the SGB cutout cannot be toggled off") Studio.setCanvas(#Studio.CANVASES + 1) eq(Studio.canvasIndex, 1, "canvas selection wraps") +-- the preview must lay the page out the way the game will, so a preset never +-- overwrites the aspect the bezel art (or a cfg) already fixed +session() +local art = Studio.page() +art.aspect, art.aspectFromImage = 0.5625, true +Studio.setCanvas(1) +near(art.aspect, 0.5625, 1e-9, "bezel art keeps its own aspect on a preset switch") +local deckW, deckH = 900, 1700 +local _, dby, _, dbh = TouchSkin.pageBox(art, deckW, deckH) +check(dby > 0 and math.abs(dby + dbh - deckH) < 1e-6, + "so a preset taller than the art pins the deck low, as gameplay does") + +session() +local plain = Studio.page() +plain.aspect, plain.aspectFromImage, plain.aspectFromCfg = nil, nil, nil +Studio.setCanvas(1) +near(plain.aspect, Studio.canvas().w / Studio.canvas().h, 1e-9, + "a page with no art of its own still takes the preset's aspect") + Studio.setCanvas(1) Studio.viewZoom = 1 local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600) diff --git a/tests/engine/skin_viewport_containment.lua b/tests/engine/skin_viewport_containment.lua index 9ad35d4f..be3921ea 100644 --- a/tests/engine/skin_viewport_containment.lua +++ b/tests/engine/skin_viewport_containment.lua @@ -136,11 +136,16 @@ for off = lo, hi do end Zoom.offset = 0 -local capped = select(1, Renderer:worldViewSize()) -useSkin("0.25,0.1,0.5,0.6", "overlay0_viewport_expand = true") -local expanded = select(1, Renderer:worldViewSize()) -check(expanded > capped, - "viewport_expand lets the survey world fill the cutout instead of the GB box") +useSkin("0.25,0.1,0.3,0.8") +local tallR = Renderer:frameRects() +local tallVw, tallVh = Renderer:worldViewSize() +local tallSp = Zoom.scale(tallR.Sp) +check(tallVw * tallSp >= tallR.vuw and tallVh * tallSp >= tallR.vuh, + "the world pass covers the whole cutout, not just the GB box inside it") +check(tallVh * tallSp > tallR.uvph, + "so a cutout taller than 160x144 shows map where the UI letterbox ends") +check(tallR.uvph < tallR.vuh and tallR.uvpw <= tallR.vuw, + "while the UI keeps its whole-pixel letterbox inside that cutout") useSkin("0.25,0.1,0.5,0.6") setWindow(480, 800) @@ -159,22 +164,14 @@ setWindow(1280, 720) useSkin("0.25,0.1,0.5,0.6") local px, py, pw, ph, active = Playfield.rect(1280, 720) eq(active, true, "Gold sees the cutout too") -eq(pw, 480, "the playfield is a whole multiple of 160") -eq(ph, 432, "and of 144") -check(inside(px, py, pw, ph, 320, 72, 640, 432), - "centred inside the cutout") -eq(Chrome.fitScale(1280, 720), 3, "Chrome fits the playfield") +eq(px, 320, "the playfield is the cutout, at its origin") +eq(py, 72, "on both axes") +eq(pw, 640, "with its full width") +eq(ph, 432, "and its full height") +eq(Chrome.fitScale(1280, 720), 3, "Chrome fits whole GB pixels in it") local cox, coy = Chrome.fitOrigin(1280, 720) -eq(cox, px, "and centres the panel on it") -eq(coy, py, "on both axes") - -useSkin("0.25,0.1,0.5,0.6", "overlay0_viewport_expand = true") -local ex, ey, ew, eh = Playfield.rect(1280, 720) -eq(ew, 640, "expand hands the picture the full cutout width") -eq(eh, 432, "and its full height") -eq(ex, 320, "at the cutout origin") -eq(ey, 72, "on both axes") -useSkin("0.25,0.1,0.5,0.6") +eq(cox, 320 + 80, "and centres the panel on the cutout") +eq(coy, 72, "on both axes") local ew2, eh2, ex2, ey2, act2 = Playfield.push(1280, 720) eq(act2, true, "push reports the frame is contained") @@ -184,12 +181,29 @@ eq(ew2, pw, "and hands the scene the playfield size") eq(Playfield.cutout(ew2, eh2), nil, "inside the frame there is no cutout left") eq(select(3, Playfield.rect(ew2, eh2)), pw, "so the playfield is the surface") eq(Chrome.fitScale(ew2, eh2), 3, "and Chrome fits it without re-applying") -eq(select(1, Chrome.fitOrigin(ew2, eh2)), 0, "at a local origin") +eq(select(1, Chrome.fitOrigin(ew2, eh2)), 80, "centred on that surface alone") eq(select(1, Playfield.dimensions()), pw, "screens read the playfield as the display") Playfield.pop() eq(Playfield.entered, false, "pop leaves the frame") eq(select(1, Playfield.cutout(1280, 720)), 320, "and the cutout is visible again") +setWindow(960, 1901) +useSkin("0,0,1,0.5", "overlay0_aspect_ratio = 0.5625") +local deck = TouchSkin.page() +local _, dy, _, dh = TouchSkin.pageBox(deck, 960, 1901) +check(dy > 0 and math.abs(dy + dh - 1901) < 1, + "a portrait deck taller than the display pins to the lower edge") +local hx, hy, hw, hh = Playfield.cutout(960, 1901) +eq(hy, 0, "and the screen rect it left flush takes the room above it") +eq(hx, 0, "without moving sideways") +eq(hw, 960, "or changing width") +eq(hh, math.floor(dy + dh * 0.5), "growing by exactly the headroom") + +useSkin("0,0.1,1,0.5", "overlay0_aspect_ratio = 0.5625") +check(select(2, Playfield.cutout(960, 1901)) > dy, + "a screen rect the author inset from the top keeps that bezel margin") + +setWindow(1280, 720) useSkin("0.4,0.4,0.1,0.1") local sx, sy, sw, sh = Playfield.rect(1280, 720) check(inside(sx, sy, sw, sh, 512, 288, 128, 72),