diff --git a/src/core/SafeArea.lua b/src/core/SafeArea.lua new file mode 100644 index 00000000..86c404fb --- /dev/null +++ b/src/core/SafeArea.lua @@ -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 diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 50178e7c..8678dce8 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -23,6 +23,7 @@ -- and a player rebind can never detach the overlay. local Input = require("src.core.Input") +local SafeArea = require("src.core.SafeArea") local TouchControls = {} @@ -92,20 +93,23 @@ function TouchControls.normalizeConfig(tc) return out end --- Pure default layout in LOVE units for a given window size. Shared by --- layout() and the editor's Reset path so defaults stay in one place. -function TouchControls.defaultLayout(ww, wh) +-- Pure default layout in LOVE units for a usable rect of size ww x wh at +-- origin (ox, oy). Shared by layout() and the editor's Reset path so +-- 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 dpadW = math.min(180, short * 0.34) local abW = dpadW * 0.46 local ssW = dpadW * 0.30 local margin = dpadW * 0.12 return { - dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW }, - a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW }, - b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW }, - start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW }, - select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW }, + dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW }, + a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW }, + b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW }, + start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + 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 @@ -155,6 +159,7 @@ function TouchControls:applyOptions(opts) self.enabled = cfg.enabled self.positions = cfg.positions self.layoutW, self.layoutH = nil, nil + self.layoutOx, self.layoutOy = nil, nil if not self.enabled then self.controllerHidden = false self:reset() @@ -185,30 +190,37 @@ function TouchControls:visible() and not self.controllerHidden 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 - zone.cx = math.max(half, math.min(ww - half, zone.cx)) - zone.cy = math.max(half, math.min(wh - half, zone.cy)) + zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx)) + zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy)) end -- Layout in LOVE units (density-independent on mobile), recomputed when --- the window size changes (rotation, resize). Default: d-pad bottom-left, --- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT --- flanking the bottom center. Custom positions (normalized 0..1) override --- centers while sizes stay derived from the short edge. +-- the window or safe area changes (rotation, resize, notch insets). +-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy +-- diagonal), START/SELECT flanking the bottom center -- all inside the +-- 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() - local ww, wh = love.graphics.getDimensions() - if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end - self.layoutW, self.layoutH = ww, wh - self.L = TouchControls.defaultLayout(ww, wh) + local ox, oy, sw, sh = SafeArea.rect() + if self.layoutW == sw and self.layoutH == sh + and self.layoutOx == ox and self.layoutOy == oy and self.L then + 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 for _, name in ipairs(CONTROLS) do local p = self.positions[name] local zone = self.L[name] if p and zone then - zone.cx = p.x * ww - zone.cy = p.y * wh - clampZone(zone, ww, wh) + zone.cx = ox + p.x * sw + zone.cy = oy + p.y * sh + clampZone(zone, ox, oy, ox + sw, oy + sh) end end end @@ -222,21 +234,25 @@ function TouchControls:layout() end -- 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) - local ww, wh = love.graphics.getDimensions() + local ox, oy, sw, sh = SafeArea.rect() local L = self:layout() local zone = L[name] if not zone then return end 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[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 function TouchControls:clearPositions() self.positions = nil self.layoutW, self.layoutH = nil, nil + self.layoutOx, self.layoutOy = nil, nil end local function inCircle(zone, x, y, slop) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 65f37419..51d83c71 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1,6 +1,7 @@ local GameVersion = require("src.core.GameVersion") local Strings = require("src.core.Strings") local HostShell = require("src.core.HostShell") +local SafeArea = require("src.core.SafeArea") local RomImporter = {} RomImporter.__index = RomImporter @@ -1291,10 +1292,10 @@ local PAD_DPAD_SPEED = 420 function RomImporter:_activatePadCursor() if self._padCursorActive then return end - local w, h = love.graphics.getDimensions() + local ox, oy, w, h = SafeArea.rect() if not self._padInited then - self._padCursor.x = w * 0.5 - self._padCursor.y = h * 0.45 + self._padCursor.x = ox + w * 0.5 + self._padCursor.y = oy + h * 0.45 self._padInited = true end self._padCursorActive = true @@ -1340,11 +1341,11 @@ function RomImporter:_updatePadCursor(dt) if mag > 1 then dx, dy = dx / mag, dy / mag end local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD) 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 ny = self._padCursor.y + dy * speed * dt - self._padCursor.x = math.max(0, math.min(w, nx)) - self._padCursor.y = math.max(0, math.min(h, ny)) + self._padCursor.x = math.max(ox, math.min(ox + w, nx)) + self._padCursor.y = math.max(oy, math.min(oy + h, ny)) end -- Right stick scrolls the active list (save slots or mods), or the whole page @@ -1722,7 +1723,10 @@ function RomImporter:_resetFrameRects() end 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 pulse = self.pulse self._s = s @@ -1741,8 +1745,9 @@ function RomImporter:draw() self._anyHover = false self:_resetFrameRects() - -- Fonts + size-dependent scenery, rebuilt only when the window size changes. - local fontKey = ("%dx%d"):format(width, height) + -- Fonts + size-dependent scenery, rebuilt only when the window / safe + -- area changes (rotation, resize, inset changes). + local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy) if self.fontKey ~= fontKey then self.fontKey = fontKey local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end @@ -1766,10 +1771,10 @@ function RomImporter:draw() -- Background: a radial gradient (bright navy at top-centre -> near black). -- 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 - -- match seamlessly. + -- match seamlessly. Sized to the full window so unsafe edges stay filled. do - local cx, cy = width / 2, 0 - local rx, ry = width * 1.3, height * 1.08 + local cx, cy = fullW / 2, 0 + local rx, ry = fullW * 1.3, fullH * 1.08 local n = 72 local verts = { { cx, cy, 0, 0, PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } } @@ -1783,8 +1788,8 @@ function RomImporter:draw() -- CRT vignette: a gentle edge darkening, centred slightly above the middle. do - local cx, cy = width / 2, height * 0.45 - local rx, ry = width * 0.78, height * 0.78 + local cx, cy = fullW / 2, fullH * 0.45 + local rx, ry = fullW * 0.78, fullH * 0.78 local n = 72 local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } } for i = 0, n do @@ -1806,7 +1811,7 @@ function RomImporter:draw() self.scanlineImage:setWrap("repeat", "repeat") self.scanlineImage:setFilter("nearest", "nearest") end - self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3) + self.scanlineQuad = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3) end -- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it @@ -1832,21 +1837,23 @@ function RomImporter:draw() } ]]) - -- background + -- background (full window — unsafe edges stay painted) 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.draw(self.bgMesh) -- Centered content container (max ~1440 scaled units on very wide windows) -- 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 appX = (width - appW) / 2 + local appX = ox + (width - appW) / 2 local padH = clamp(appW * 0.03, 12 * s, 26 * s) local third = appW / 3 -- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom local stripH = math.max(4, 6 * s) + local stripY = oy local segs = { { PAL.red, appX, third }, { PAL.blue, appX + third, third }, @@ -1854,11 +1861,11 @@ function RomImporter:draw() } love.graphics.setBlendMode("add") 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 love.graphics.setBlendMode("alpha") 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 -- Footer (Boi's Club Games logo + trust warning), measured first so the @@ -1880,7 +1887,7 @@ function RomImporter:draw() math.min(330 * s, appW - 32 * s)) local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH) 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), -- with "N of 3 ready" right-aligned. @@ -1910,7 +1917,7 @@ function RomImporter:draw() local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s local cX = appX + padH local cW = appW - 2 * padH - local contentBottom = height - footerH - bannerBand + local contentBottom = oy + height - footerH - bannerBand local cH = math.max(0, contentBottom - contentTop) -- Page scroll. Everything under the tab bar -- panel, updater banner and @@ -1921,14 +1928,14 @@ function RomImporter:draw() -- 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 -- 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 {} local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH local paged, pageScroll, maxPage = RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll) self.pageScroll, self._pageMax = pageScroll, maxPage -- 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, -- and it sits above the scrolling viewport. @@ -2099,10 +2106,10 @@ function RomImporter:draw() -- logo, over the split, with a gentle bob + gold glow + sweeping shine 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.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) love.graphics.setBlendMode("alpha") local shineW = 0.16 @@ -2135,11 +2142,11 @@ function RomImporter:draw() -- save-slot rename modal (#205), drawn over everything if self._rename then 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 dh = 128 * s local dx = appX + (appW - dw) / 2 - local dy = (height - dh) / 2 + local dy = oy + (height - dh) / 2 local rr = 12 * s neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85) @@ -2181,11 +2188,11 @@ function RomImporter:draw() -- it is typed. if self._indexPrompt then 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 dh = 168 * s local dx = appX + (appW - dw) / 2 - local dy = (height - dh) / 2 + local dy = oy + (height - dh) / 2 local rr = 12 * s neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4) fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9) @@ -2237,7 +2244,7 @@ function RomImporter:draw() if self._modConfirm or self._modVersions or self._modReleaseNotes or self._findDetails then col(PAL.bgBot, 0.72) - love.graphics.rectangle("fill", 0, 0, width, height) + love.graphics.rectangle("fill", 0, 0, fullW, fullH) end if self._modConfirm then local c = self._modConfirm @@ -2245,7 +2252,7 @@ function RomImporter:draw() local lineH = self.hintFont:getHeight() + 4 * s local dh = 36 * s + (#c.lines) * lineH + 56 * s local dx = appX + (appW - dw) / 2 - local dy = (height - dh) / 2 + local dy = oy + (height - dh) / 2 local rr = 12 * s fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92) love.graphics.setLineWidth(math.max(1, 1.2 * s)) @@ -2287,7 +2294,7 @@ function RomImporter:draw() local dw = math.min(appW - 32 * s, 480 * s) local dh = math.min(height - 48 * s, 360 * s) local dx = appX + (appW - dw) / 2 - local dy = (height - dh) / 2 + local dy = oy + (height - dh) / 2 local rr = 12 * s fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92) love.graphics.setLineWidth(math.max(1, 1.2 * s)) @@ -2332,7 +2339,7 @@ function RomImporter:draw() local dw = math.min(appW - 32 * s, 520 * s) local dh = math.min(height - 48 * s, 420 * s) local dx = appX + (appW - dw) / 2 - local dy = (height - dh) / 2 + local dy = oy + (height - dh) / 2 local rr = 12 * s fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94) love.graphics.setLineWidth(math.max(1, 1.2 * s)) @@ -2389,7 +2396,7 @@ function RomImporter:draw() listH = listN * rowH dh = headerH + listH + footerH local dx = appX + (appW - dw) / 2 - local dy = (height - dh) / 2 + local dy = oy + (height - dh) / 2 local rr = 12 * s fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96) love.graphics.setLineWidth(math.max(1, 1.2 * s)) diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index 2a69ea02..73f54bed 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -109,34 +109,36 @@ function Editor.update(_dt) end 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)) Editor.rects = {} -- radial-ish navy field (two stacked fills; matches launcher atmosphere) col(PAL.bgBot) - love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.rectangle("fill", 0, 0, fullW, fullH) 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 barH = 56 * s local btnH = 40 * 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) - love.graphics.rectangle("fill", 0, 0, ww, barH + pad) + love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad) col(PAL.stroke, 0.35) 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) 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 - 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 } local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH } Editor.rects.done, Editor.rects.reset = done, reset @@ -156,9 +158,9 @@ function Editor.draw() chromeBtn(done, "Done", PAL.green) -- enable toggle card - local cardY = barH + pad + 14 * s + local cardY = oy + barH + pad + 14 * 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) roundRect("fill", cardX, cardY, cardW, cardH, 12 * s) col(PAL.stroke, 0.4) @@ -190,7 +192,7 @@ function Editor.draw() local hint = on 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." - 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) TouchControls:draw() diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 00dc65e0..8973c8a0 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -203,4 +203,12 @@ stub.mouse = { 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 diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 59e826c3..5443e196 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1696,6 +1696,7 @@ end -- ---------------------------------------------------------------- touch controls layout (#327) do local TC = require("src.core.TouchControls") + local SafeArea = require("src.core.SafeArea") local cfg = TC.normalizeConfig(nil) eq(cfg.enabled, true, "touchControls default enabled") 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.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) TC.enabled = true TC.active = true @@ -1743,16 +1750,37 @@ do -- custom position applied through layout() local g = love.graphics local oldDim, oldFont = g.getDimensions, g.newFont + local oldSafe = love.window and love.window.getSafeArea g.getDimensions = function() return 400, 800 end g.newFont = function() return { getWidth = function() return 10 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() eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww") 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() check(TC.positions == nil, "clearPositions wipes overrides") g.getDimensions, g.newFont = oldDim, oldFont + if oldSafe then love.window.getSafeArea = oldSafe + else love.window.getSafeArea = nil end end -- ---------------------------------------------------------------- crit thresholds (CriticalHitTest)