Respect iOS/Android safe areas in launcher and touch chrome.

Layout interactive UI against love.window.getSafeArea so notch, Dynamic Island, and home-indicator insets no longer clip controls, while keeping the game framebuffer edge-to-edge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-07-31 23:38:03 -03:00
parent f2a3e5f05b
commit fccb122c59
6 changed files with 174 additions and 73 deletions
+40
View File
@@ -0,0 +1,40 @@
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
-- the engine provides it; otherwise the full graphics window.
--
-- Desktop and headless stubs return the full window, so callers can always
-- layout against this rect without platform branches. Interactive chrome
-- (touch overlay, launcher) should prefer this over getDimensions; the game
-- canvas may still letterbox into the full framebuffer for immersion.
local SafeArea = {}
function SafeArea.rect()
local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then
ww, wh = love.graphics.getDimensions()
end
if ww <= 0 then ww = 1 end
if wh <= 0 then wh = 1 end
if not (love and love.window and love.window.getSafeArea) then
return 0, 0, ww, wh
end
local x, y, w, h = love.window.getSafeArea()
if type(x) ~= "number" or type(y) ~= "number"
or type(w) ~= "number" or type(h) ~= "number"
or w <= 0 or h <= 0 then
return 0, 0, ww, wh
end
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
-- push layout outside the surface.
x = math.max(0, math.min(x, ww))
y = math.max(0, math.min(y, wh))
w = math.max(1, math.min(w, ww - x))
h = math.max(1, math.min(h, wh - y))
return x, y, w, h
end
return SafeArea
+42 -26
View File
@@ -23,6 +23,7 @@
-- and a player rebind can never detach the overlay. -- and a player rebind can never detach the overlay.
local Input = require("src.core.Input") local Input = require("src.core.Input")
local SafeArea = require("src.core.SafeArea")
local TouchControls = {} local TouchControls = {}
@@ -92,20 +93,23 @@ function TouchControls.normalizeConfig(tc)
return out return out
end end
-- Pure default layout in LOVE units for a given window size. Shared by -- Pure default layout in LOVE units for a usable rect of size ww x wh at
-- layout() and the editor's Reset path so defaults stay in one place. -- origin (ox, oy). Shared by layout() and the editor's Reset path so
function TouchControls.defaultLayout(ww, wh) -- defaults stay in one place. ox/oy default to 0 for the headless tests
-- and for callers that already pass a full-window size.
function TouchControls.defaultLayout(ww, wh, ox, oy)
ox, oy = ox or 0, oy or 0
local short = math.min(ww, wh) local short = math.min(ww, wh)
local dpadW = math.min(180, short * 0.34) local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46 local abW = dpadW * 0.46
local ssW = dpadW * 0.30 local ssW = dpadW * 0.30
local margin = dpadW * 0.12 local margin = dpadW * 0.12
return { return {
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW }, dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW }, a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW }, b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW }, start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW }, select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
} }
end end
@@ -155,6 +159,7 @@ function TouchControls:applyOptions(opts)
self.enabled = cfg.enabled self.enabled = cfg.enabled
self.positions = cfg.positions self.positions = cfg.positions
self.layoutW, self.layoutH = nil, nil self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
if not self.enabled then if not self.enabled then
self.controllerHidden = false self.controllerHidden = false
self:reset() self:reset()
@@ -185,30 +190,37 @@ function TouchControls:visible()
and not self.controllerHidden and not self.controllerHidden
end end
local function clampZone(zone, ww, wh) -- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
local function clampZone(zone, x0, y0, x1, y1)
local half = zone.w * 0.5 local half = zone.w * 0.5
zone.cx = math.max(half, math.min(ww - half, zone.cx)) zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
zone.cy = math.max(half, math.min(wh - half, zone.cy)) zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
end end
-- Layout in LOVE units (density-independent on mobile), recomputed when -- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window size changes (rotation, resize). Default: d-pad bottom-left, -- the window or safe area changes (rotation, resize, notch insets).
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT -- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
-- flanking the bottom center. Custom positions (normalized 0..1) override -- diagonal), START/SELECT flanking the bottom center -- all inside the
-- centers while sizes stay derived from the short edge. -- device safe area so thumbs clear the home indicator / cutouts.
-- Custom positions (normalized 0..1 within the safe rect) override centers
-- while sizes stay derived from the short edge.
function TouchControls:layout() function TouchControls:layout()
local ww, wh = love.graphics.getDimensions() local ox, oy, sw, sh = SafeArea.rect()
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end if self.layoutW == sw and self.layoutH == sh
self.layoutW, self.layoutH = ww, wh and self.layoutOx == ox and self.layoutOy == oy and self.L then
self.L = TouchControls.defaultLayout(ww, wh) return self.L
end
self.layoutW, self.layoutH = sw, sh
self.layoutOx, self.layoutOy = ox, oy
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
if self.positions then if self.positions then
for _, name in ipairs(CONTROLS) do for _, name in ipairs(CONTROLS) do
local p = self.positions[name] local p = self.positions[name]
local zone = self.L[name] local zone = self.L[name]
if p and zone then if p and zone then
zone.cx = p.x * ww zone.cx = ox + p.x * sw
zone.cy = p.y * wh zone.cy = oy + p.y * sh
clampZone(zone, ww, wh) clampZone(zone, ox, oy, ox + sw, oy + sh)
end end
end end
end end
@@ -222,21 +234,25 @@ function TouchControls:layout()
end end
-- Move one control to a screen-space point and persist its normalized -- Move one control to a screen-space point and persist its normalized
-- position. Used by the layout editor while dragging. -- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy) function TouchControls:setControlCenter(name, cx, cy)
local ww, wh = love.graphics.getDimensions() local ox, oy, sw, sh = SafeArea.rect()
local L = self:layout() local L = self:layout()
local zone = L[name] local zone = L[name]
if not zone then return end if not zone then return end
zone.cx, zone.cy = cx, cy zone.cx, zone.cy = cx, cy
clampZone(zone, ww, wh) clampZone(zone, ox, oy, ox + sw, oy + sh)
self.positions = self.positions or {} self.positions = self.positions or {}
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh } self.positions[name] = {
x = sw > 0 and (zone.cx - ox) / sw or 0,
y = sh > 0 and (zone.cy - oy) / sh or 0,
}
end end
function TouchControls:clearPositions() function TouchControls:clearPositions()
self.positions = nil self.positions = nil
self.layoutW, self.layoutH = nil, nil self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
end end
local function inCircle(zone, x, y, slop) local function inCircle(zone, x, y, slop)
+42 -35
View File
@@ -1,6 +1,7 @@
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local HostShell = require("src.core.HostShell") local HostShell = require("src.core.HostShell")
local SafeArea = require("src.core.SafeArea")
local RomImporter = {} local RomImporter = {}
RomImporter.__index = RomImporter RomImporter.__index = RomImporter
@@ -1295,10 +1296,10 @@ local PAD_DPAD_SPEED = 420
function RomImporter:_activatePadCursor() function RomImporter:_activatePadCursor()
if self._padCursorActive then return end if self._padCursorActive then return end
local w, h = love.graphics.getDimensions() local ox, oy, w, h = SafeArea.rect()
if not self._padInited then if not self._padInited then
self._padCursor.x = w * 0.5 self._padCursor.x = ox + w * 0.5
self._padCursor.y = h * 0.45 self._padCursor.y = oy + h * 0.45
self._padInited = true self._padInited = true
end end
self._padCursorActive = true self._padCursorActive = true
@@ -1344,11 +1345,11 @@ function RomImporter:_updatePadCursor(dt)
if mag > 1 then dx, dy = dx / mag, dy / mag end if mag > 1 then dx, dy = dx / mag, dy / mag end
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD) local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
and PAD_SPEED or PAD_DPAD_SPEED and PAD_SPEED or PAD_DPAD_SPEED
local w, h = love.graphics.getDimensions() local ox, oy, w, h = SafeArea.rect()
local nx = self._padCursor.x + dx * speed * dt local nx = self._padCursor.x + dx * speed * dt
local ny = self._padCursor.y + dy * speed * dt local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(0, math.min(w, nx)) self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(0, math.min(h, ny)) self._padCursor.y = math.max(oy, math.min(oy + h, ny))
end end
-- Right stick scrolls the active list (save slots or mods), or the whole page -- Right stick scrolls the active list (save slots or mods), or the whole page
@@ -1726,7 +1727,10 @@ function RomImporter:_resetFrameRects()
end end
function RomImporter:draw() function RomImporter:draw()
local width, height = love.graphics.getDimensions() -- Full window for immersive backdrop; safe rect for interactive chrome so
-- notch / Dynamic Island / home indicator / Android cutouts are respected.
local fullW, fullH = love.graphics.getDimensions()
local ox, oy, width, height = SafeArea.rect()
local s = clamp(height / 768, 0.7, 1.6) local s = clamp(height / 768, 0.7, 1.6)
local pulse = self.pulse local pulse = self.pulse
self._s = s self._s = s
@@ -1745,8 +1749,9 @@ function RomImporter:draw()
self._anyHover = false self._anyHover = false
self:_resetFrameRects() self:_resetFrameRects()
-- Fonts + size-dependent scenery, rebuilt only when the window size changes. -- Fonts + size-dependent scenery, rebuilt only when the window / safe
local fontKey = ("%dx%d"):format(width, height) -- area changes (rotation, resize, inset changes).
local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy)
if self.fontKey ~= fontKey then if self.fontKey ~= fontKey then
self.fontKey = fontKey self.fontKey = fontKey
local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end
@@ -1770,10 +1775,10 @@ function RomImporter:draw()
-- Background: a radial gradient (bright navy at top-centre -> near black). -- Background: a radial gradient (bright navy at top-centre -> near black).
-- A triangle fan from the top-centre gives the radial falloff; the screen -- A triangle fan from the top-centre gives the radial falloff; the screen
-- is cleared to the outer colour first so the corners it does not reach -- is cleared to the outer colour first so the corners it does not reach
-- match seamlessly. -- match seamlessly. Sized to the full window so unsafe edges stay filled.
do do
local cx, cy = width / 2, 0 local cx, cy = fullW / 2, 0
local rx, ry = width * 1.3, height * 1.08 local rx, ry = fullW * 1.3, fullH * 1.08
local n = 72 local n = 72
local verts = { { cx, cy, 0, 0, local verts = { { cx, cy, 0, 0,
PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } } PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } }
@@ -1787,8 +1792,8 @@ function RomImporter:draw()
-- CRT vignette: a gentle edge darkening, centred slightly above the middle. -- CRT vignette: a gentle edge darkening, centred slightly above the middle.
do do
local cx, cy = width / 2, height * 0.45 local cx, cy = fullW / 2, fullH * 0.45
local rx, ry = width * 0.78, height * 0.78 local rx, ry = fullW * 0.78, fullH * 0.78
local n = 72 local n = 72
local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } } local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } }
for i = 0, n do for i = 0, n do
@@ -1810,7 +1815,7 @@ function RomImporter:draw()
self.scanlineImage:setWrap("repeat", "repeat") self.scanlineImage:setWrap("repeat", "repeat")
self.scanlineImage:setFilter("nearest", "nearest") self.scanlineImage:setFilter("nearest", "nearest")
end end
self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3) self.scanlineQuad = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3)
end end
-- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it -- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it
@@ -1836,21 +1841,23 @@ function RomImporter:draw()
} }
]]) ]])
-- background -- background (full window — unsafe edges stay painted)
col(PAL.bgBot) col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, fullW, fullH)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.bgMesh) love.graphics.draw(self.bgMesh)
-- Centered content container (max ~1440 scaled units on very wide windows) -- Centered content container (max ~1440 scaled units on very wide windows)
-- with a responsive side gutter; every column below derives from these. -- with a responsive side gutter; every column below derives from these.
-- Origin is the safe-area top-left so chrome clears device insets.
local appW = math.min(width, 1440 * s) local appW = math.min(width, 1440 * s)
local appX = (width - appW) / 2 local appX = ox + (width - appW) / 2
local padH = clamp(appW * 0.03, 12 * s, 26 * s) local padH = clamp(appW * 0.03, 12 * s, 26 * s)
local third = appW / 3 local third = appW / 3
-- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom -- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom
local stripH = math.max(4, 6 * s) local stripH = math.max(4, 6 * s)
local stripY = oy
local segs = { local segs = {
{ PAL.red, appX, third }, { PAL.red, appX, third },
{ PAL.blue, appX + third, third }, { PAL.blue, appX + third, third },
@@ -1858,11 +1865,11 @@ function RomImporter:draw()
} }
love.graphics.setBlendMode("add") love.graphics.setBlendMode("add")
for _, seg in ipairs(segs) do for _, seg in ipairs(segs) do
fillGrad(seg[2], stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0) fillGrad(seg[2], stripY + stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
end end
love.graphics.setBlendMode("alpha") love.graphics.setBlendMode("alpha")
for _, seg in ipairs(segs) do for _, seg in ipairs(segs) do
col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], stripH) col(seg[1]); love.graphics.rectangle("fill", seg[2], stripY, seg[3], stripH)
end end
-- Footer (Boi's Club Games logo + trust warning), measured first so the -- Footer (Boi's Club Games logo + trust warning), measured first so the
@@ -1884,7 +1891,7 @@ function RomImporter:draw()
math.min(330 * s, appW - 32 * s)) math.min(330 * s, appW - 32 * s))
local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH) local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH)
local logoDW, logoDH = logoW * logoScale, logoH * logoScale local logoDW, logoDH = logoW * logoScale, logoH * logoScale
local logoY = stripH + 14 * s local logoY = stripY + stripH + 14 * s
-- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one), -- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one),
-- with "N of 3 ready" right-aligned. -- with "N of 3 ready" right-aligned.
@@ -1914,7 +1921,7 @@ function RomImporter:draw()
local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s
local cX = appX + padH local cX = appX + padH
local cW = appW - 2 * padH local cW = appW - 2 * padH
local contentBottom = height - footerH - bannerBand local contentBottom = oy + height - footerH - bannerBand
local cH = math.max(0, contentBottom - contentTop) local cH = math.max(0, contentBottom - contentTop)
-- Page scroll. Everything under the tab bar -- panel, updater banner and -- Page scroll. Everything under the tab bar -- panel, updater banner and
@@ -1925,14 +1932,14 @@ function RomImporter:draw()
-- the previous frame's measurement, the same one-frame settle the slot and -- the previous frame's measurement, the same one-frame settle the slot and
-- mod lists already rely on. While the page fits, `paged` is false and every -- mod lists already rely on. While the page fits, `paged` is false and every
-- measurement below is what it always was. -- measurement below is what it always was.
local viewportH = math.max(0, height - contentTop) local viewportH = math.max(0, oy + height - contentTop)
self._panelNaturalH = self._panelNaturalH or {} self._panelNaturalH = self._panelNaturalH or {}
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
local paged, pageScroll, maxPage = local paged, pageScroll, maxPage =
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll) RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
self.pageScroll, self._pageMax = pageScroll, maxPage self.pageScroll, self._pageMax = pageScroll, maxPage
-- read by the hit tests; a scrolled control is live only inside the viewport -- read by the hit tests; a scrolled control is live only inside the viewport
pageBand = paged and { contentTop, height } or nil pageBand = paged and { contentTop, oy + height } or nil
-- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation, -- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation,
-- and it sits above the scrolling viewport. -- and it sits above the scrolling viewport.
@@ -2103,10 +2110,10 @@ function RomImporter:draw()
-- logo, over the split, with a gentle bob + gold glow + sweeping shine -- logo, over the split, with a gentle bob + gold glow + sweeping shine
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
local lx, ly = (width - logoDW) / 2, logoY + bob local lx, ly = ox + (width - logoDW) / 2, logoY + bob
love.graphics.setBlendMode("add") love.graphics.setBlendMode("add")
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6))) love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0, love.graphics.draw(self.logo, ox + (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
logoScale * 1.05, logoScale * 1.05) logoScale * 1.05, logoScale * 1.05)
love.graphics.setBlendMode("alpha") love.graphics.setBlendMode("alpha")
local shineW = 0.16 local shineW = 0.16
@@ -2139,11 +2146,11 @@ function RomImporter:draw()
-- save-slot rename modal (#205), drawn over everything -- save-slot rename modal (#205), drawn over everything
if self._rename then if self._rename then
col(PAL.bgBot, 0.72) col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 420 * s) local dw = math.min(appW - 32 * s, 420 * s)
local dh = 128 * s local dh = 128 * s
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4) neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
@@ -2185,11 +2192,11 @@ function RomImporter:draw()
-- it is typed. -- it is typed.
if self._indexPrompt then if self._indexPrompt then
col(PAL.bgBot, 0.72) col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 520 * s) local dw = math.min(appW - 32 * s, 520 * s)
local dh = 168 * s local dh = 168 * s
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4) neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9)
@@ -2241,7 +2248,7 @@ function RomImporter:draw()
if self._modConfirm or self._modVersions or self._modReleaseNotes if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then or self._findDetails then
col(PAL.bgBot, 0.72) col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, fullW, fullH)
end end
if self._modConfirm then if self._modConfirm then
local c = self._modConfirm local c = self._modConfirm
@@ -2249,7 +2256,7 @@ function RomImporter:draw()
local lineH = self.hintFont:getHeight() + 4 * s local lineH = self.hintFont:getHeight() + 4 * s
local dh = 36 * s + (#c.lines) * lineH + 56 * s local dh = 36 * s + (#c.lines) * lineH + 56 * s
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s)) love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2291,7 +2298,7 @@ function RomImporter:draw()
local dw = math.min(appW - 32 * s, 480 * s) local dw = math.min(appW - 32 * s, 480 * s)
local dh = math.min(height - 48 * s, 360 * s) local dh = math.min(height - 48 * s, 360 * s)
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s)) love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2336,7 +2343,7 @@ function RomImporter:draw()
local dw = math.min(appW - 32 * s, 520 * s) local dw = math.min(appW - 32 * s, 520 * s)
local dh = math.min(height - 48 * s, 420 * s) local dh = math.min(height - 48 * s, 420 * s)
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94)
love.graphics.setLineWidth(math.max(1, 1.2 * s)) love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2393,7 +2400,7 @@ function RomImporter:draw()
listH = listN * rowH listH = listN * rowH
dh = headerH + listH + footerH dh = headerH + listH + footerH
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96)
love.graphics.setLineWidth(math.max(1, 1.2 * s)) love.graphics.setLineWidth(math.max(1, 1.2 * s))
+13 -11
View File
@@ -109,34 +109,36 @@ function Editor.update(_dt)
end end
function Editor.draw() function Editor.draw()
local ww, wh = love.graphics.getDimensions() local SafeArea = require("src.core.SafeArea")
local fullW, fullH = love.graphics.getDimensions()
local ox, oy, ww, wh = SafeArea.rect()
local s = math.max(0.75, math.min(1.4, wh / 768)) local s = math.max(0.75, math.min(1.4, wh / 768))
Editor.rects = {} Editor.rects = {}
-- radial-ish navy field (two stacked fills; matches launcher atmosphere) -- radial-ish navy field (two stacked fills; matches launcher atmosphere)
col(PAL.bgBot) col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, fullW, fullH)
col(PAL.bgTop, 0.85) col(PAL.bgTop, 0.85)
love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55) love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
local pad = 18 * s local pad = 18 * s
local barH = 56 * s local barH = 56 * s
local btnH = 40 * s local btnH = 40 * s
local btnW = 100 * s local btnW = 100 * s
-- top bar -- top bar (inside the safe area so it clears the notch / status bar)
col(PAL.card, 0.92) col(PAL.card, 0.92)
love.graphics.rectangle("fill", 0, 0, ww, barH + pad) love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
col(PAL.stroke, 0.35) col(PAL.stroke, 0.35)
love.graphics.setLineWidth(1) love.graphics.setLineWidth(1)
love.graphics.line(0, barH + pad, ww, barH + pad) love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
love.graphics.setFont(Editor.fonts.title) love.graphics.setFont(Editor.fonts.title)
col(PAL.white) col(PAL.white)
love.graphics.print("Touch Controls", pad, pad + 4 * s) love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
-- Done / Reset -- Done / Reset
local done = { x = ww - pad - btnW, y = pad + (barH - btnH) / 2, local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
w = btnW, h = btnH } w = btnW, h = btnH }
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH } local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
Editor.rects.done, Editor.rects.reset = done, reset Editor.rects.done, Editor.rects.reset = done, reset
@@ -156,9 +158,9 @@ function Editor.draw()
chromeBtn(done, "Done", PAL.green) chromeBtn(done, "Done", PAL.green)
-- enable toggle card -- enable toggle card
local cardY = barH + pad + 14 * s local cardY = oy + barH + pad + 14 * s
local cardH = 64 * s local cardH = 64 * s
local cardX, cardW = pad, ww - 2 * pad local cardX, cardW = ox + pad, ww - 2 * pad
col(PAL.card, 0.88) col(PAL.card, 0.88)
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s) roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4) col(PAL.stroke, 0.4)
@@ -190,7 +192,7 @@ function Editor.draw()
local hint = on local hint = on
and "Drag each button to reposition. Layout is saved when you tap Done." and "Drag each button to reposition. Layout is saved when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout." or "Controls are hidden in-game. Enable them to show and edit the layout."
love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left") love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled) -- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw() TouchControls:draw()
+8
View File
@@ -203,4 +203,12 @@ stub.mouse = {
stub.timer = { getTime = function() return 0 end } stub.timer = { getTime = function() return 0 end }
-- Desktop / headless: full-window safe area (matches LÖVE's fallback).
stub.window = {
getSafeArea = function()
local ww, wh = stub.graphics.getDimensions()
return 0, 0, ww, wh
end,
}
return stub return stub
+29 -1
View File
@@ -1696,6 +1696,7 @@ end
-- ---------------------------------------------------------------- touch controls layout (#327) -- ---------------------------------------------------------------- touch controls layout (#327)
do do
local TC = require("src.core.TouchControls") local TC = require("src.core.TouchControls")
local SafeArea = require("src.core.SafeArea")
local cfg = TC.normalizeConfig(nil) local cfg = TC.normalizeConfig(nil)
eq(cfg.enabled, true, "touchControls default enabled") eq(cfg.enabled, true, "touchControls default enabled")
check(cfg.positions == nil, "touchControls default positions nil") check(cfg.positions == nil, "touchControls default positions nil")
@@ -1721,6 +1722,12 @@ do
check(L.a.cx > 200, "default A on right half") check(L.a.cx > 200, "default A on right half")
check(L.dpad.cy > 400, "default d-pad in bottom half") check(L.dpad.cy > 400, "default d-pad in bottom half")
-- safe-area origin shifts defaults without changing relative layout
local Ls = TC.defaultLayout(400, 800, 20, 30)
eq(Ls.dpad.cx, L.dpad.cx + 20, "defaultLayout ox shifts controls")
eq(Ls.dpad.cy, L.dpad.cy + 30, "defaultLayout oy shifts controls")
eq(Ls.a.cx, L.a.cx + 20, "defaultLayout ox shifts A")
-- applyOptions + visible gate (no real images needed for the gate) -- applyOptions + visible gate (no real images needed for the gate)
TC.enabled = true TC.enabled = true
TC.active = true TC.active = true
@@ -1743,16 +1750,37 @@ do
-- custom position applied through layout() -- custom position applied through layout()
local g = love.graphics local g = love.graphics
local oldDim, oldFont = g.getDimensions, g.newFont local oldDim, oldFont = g.getDimensions, g.newFont
local oldSafe = love.window and love.window.getSafeArea
g.getDimensions = function() return 400, 800 end g.getDimensions = function() return 400, 800 end
g.newFont = function() return { getWidth = function() return 10 end, g.newFont = function() return { getWidth = function() return 10 end,
getHeight = function() return 10 end } end getHeight = function() return 10 end } end
TC.layoutW, TC.layoutH, TC.L = nil, nil, nil love.window = love.window or {}
love.window.getSafeArea = function() return 0, 0, 400, 800 end
TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
local lay = TC:layout() local lay = TC:layout()
eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww") eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww")
eq(lay.dpad.cy, 600, "custom dpad cy = ny * wh") eq(lay.dpad.cy, 600, "custom dpad cy = ny * wh")
-- inset safe area: custom positions stay inside the usable rect
love.window.getSafeArea = function() return 10, 40, 380, 720 end
TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
lay = TC:layout()
eq(lay.dpad.cx, 10 + 0.25 * 380, "safe-area custom dpad cx")
eq(lay.dpad.cy, 40 + 0.75 * 720, "safe-area custom dpad cy")
check(lay.dpad.cy <= 40 + 720 - lay.dpad.w * 0.5 + 1e-6,
"safe-area dpad clears bottom inset")
local x, y, w, h = SafeArea.rect()
eq(x, 10, "SafeArea.rect x")
eq(y, 40, "SafeArea.rect y")
eq(w, 380, "SafeArea.rect w")
eq(h, 720, "SafeArea.rect h")
TC:clearPositions() TC:clearPositions()
check(TC.positions == nil, "clearPositions wipes overrides") check(TC.positions == nil, "clearPositions wipes overrides")
g.getDimensions, g.newFont = oldDim, oldFont g.getDimensions, g.newFont = oldDim, oldFont
if oldSafe then love.window.getSafeArea = oldSafe
else love.window.getSafeArea = nil end
end end
-- ---------------------------------------------------------------- crit thresholds (CriticalHitTest) -- ---------------------------------------------------------------- crit thresholds (CriticalHitTest)