From b831076839d357937f40b6f229af7964bb0ae2d8 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 3 Aug 2026 16:38:07 -0400 Subject: [PATCH] CLOSES #592 --- conf.lua | 4 + docs/new-features.md | 11 +++ main.lua | 7 ++ src/core/Game.lua | 2 + src/core/Orientation.lua | 113 ++++++++++++++++++++++++++++ src/import/LauncherSettings.lua | 19 +++++ src/ui/OptionsMenu.lua | 22 ++++++ tests/engine/orientation_option.lua | 66 ++++++++++++++++ 8 files changed, 244 insertions(+) create mode 100644 src/core/Orientation.lua create mode 100644 tests/engine/orientation_option.lua diff --git a/conf.lua b/conf.lua index 08bd3583..c46bc00b 100644 --- a/conf.lua +++ b/conf.lua @@ -70,6 +70,10 @@ function love.conf(t) -- just work. FULL_SENSOR ignores the device's rotation lock, so -- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has -- run: same orientations allowed, but auto-rotate being off now wins. + -- A persisted ORIENTATION lock (#592) overrides all of this after boot: + -- src/core/Orientation.lua sets SDL_HINT_ORIENTATIONS over the FFI and + -- re-triggers the request, from main.lua for the launcher and from + -- Game:applyOptions in game. -- iOS follows the Info.plist orientations -- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape). t.window.resizable = true diff --git a/docs/new-features.md b/docs/new-features.md index 532cbf3a..c3e6d30f 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -332,6 +332,17 @@ one used sideways. An `options.lua` from before this split keeps its single layout in both orientations until one of them is edited. In-game, Options → **TOUCH PAD** toggles the same on/off flag without leaving a play session. +## Screen orientation lock (Android) + +Options → **ORIENTATION** (also in the launcher's gear menu) locks the +screen to **PORTRAIT**, **LANDSCAPE** (either landscape, following the +device), or **REVERSE LANDSCAPE**, or leaves it on **AUTO** (#592). AUTO +allows every orientation but defers to the system: with auto-rotate turned +off in Android's quick settings, the game stays put instead of following +the sensor (#716). Changes apply immediately -- the screen rotates as the +row is stepped -- and persist in `options.lua`. Android only: iOS follows +the app's fixed orientation list, and desktop windows rotate nothing. + ## Translation support Every string the player can read is now reachable from a mod, so a diff --git a/main.lua b/main.lua index d5874db4..1229dde5 100644 --- a/main.lua +++ b/main.lua @@ -209,6 +209,13 @@ function love.load(args) end love.graphics.setDefaultFilter("nearest", "nearest") + -- Apply the persisted Android orientation lock (#592) before the launcher + -- shows: SDL created the window with no orientation hint, so without this + -- the launcher would rotate freely until Game:applyOptions runs at boot. + -- No-op on desktop / iOS / when options.lua does not exist yet. + require("src.core.Orientation").applyOptions( + require("src.core.SaveData").loadOptions()) + -- Standalone editor. A bare `--editor` run has no launcher behind it, so -- Close quits; --save points it at a specific file, otherwise it opens the -- default save path for POKEPORT_VERSION (Red unless overridden), whose diff --git a/src/core/Game.lua b/src/core/Game.lua index 1ee03703..2abc5682 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -839,6 +839,8 @@ function Game:applyOptions(opts) -- returns true when a persisted GBC FX level was cleared on mobile local gbcCleared = require("src.render.GBCFX").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts) + -- Android orientation lock (#592); no-op everywhere else + require("src.core.Orientation").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) diff --git a/src/core/Orientation.lua b/src/core/Orientation.lua new file mode 100644 index 00000000..e166a7ca --- /dev/null +++ b/src/core/Orientation.lua @@ -0,0 +1,113 @@ +-- Screen orientation lock, Android only (#592, #716). +-- +-- Persisted as options.orientation: "auto" | "portrait" | "landscape" | +-- "reverseLandscape". The lock travels through SDL_HINT_ORIENTATIONS: +-- SDLActivity.setOrientationBis parses the hint's space-separated names +-- into a setRequestedOrientation call, and GameActivity's override then +-- remaps any *_SENSOR result onto the matching *_USER constant, so a device +-- with auto-rotate off stays put (#716). AUTO leaves the hint empty, which +-- with a resizable window means "any orientation, deferring to the system +-- rotation lock"; LANDSCAPE allows both landscapes (SENSOR_LANDSCAPE -> +-- USER_LANDSCAPE); REVERSE LANDSCAPE is SDL's LandscapeRight alone. +-- +-- SDL only re-reads the hint when the window is created or its resizable +-- flag changes (SDL_androidwindow.c: Android_CreateWindow / +-- Android_SetWindowResizable both call Android_JNI_SetOrientation). LOVE +-- 11.5 exposes neither hints nor a resizable setter, so apply() goes through +-- the FFI to SDL's C API: set the hint, then pulse the window's resizable +-- flag off and back on -- each edge makes the Android backend recompute the +-- requested orientation, so a change from the launcher or the OPTION menu +-- takes hold immediately, and the flag ends where it started (conf.lua sets +-- resizable on mobile). Everything is pcall-guarded: desktop, iOS (the +-- Info.plist governs there) and headless stubs make this a no-op. + +local Orientation = {} + +Orientation.MODES = { "auto", "portrait", "landscape", "reverseLandscape" } +Orientation.DEFAULT = "auto" + +local LABELS = { + auto = "AUTO", + portrait = "PORTRAIT", + landscape = "LANDSCAPE", + reverseLandscape = "REVERSE LANDSCAPE", +} + +-- SDL_HINT_ORIENTATIONS values, exactly the names SDLActivity parses +-- (SDLActivity.java setOrientationBis): "Portrait", "PortraitUpsideDown", +-- "LandscapeLeft", "LandscapeRight". Both landscapes together promote to +-- SENSOR_LANDSCAPE; LandscapeRight alone maps to REVERSE_LANDSCAPE. +local HINTS = { + auto = "", + portrait = "Portrait", + landscape = "LandscapeLeft LandscapeRight", + reverseLandscape = "LandscapeRight", +} + +function Orientation.normalize(mode) + if HINTS[mode] then return mode end + return Orientation.DEFAULT +end + +function Orientation.modeLabel(mode) + return LABELS[Orientation.normalize(mode)] +end + +function Orientation.isAndroid() + if not love or not love.system or not love.system.getOS then return false end + return love.system.getOS() == "Android" +end + +function Orientation.cycle(mode, dir) + local cur, idx = Orientation.normalize(mode), 1 + for i, m in ipairs(Orientation.MODES) do + if m == cur then idx = i break end + end + local n = #Orientation.MODES + return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1] +end + +-- The SDL2 C API this module needs. cdef errors on redefinition, so run it +-- once and remember whether it took; ffi itself may be absent (plain Lua +-- test interpreters), hence the pcall'd require. +local cdefOk = nil +local function sdlFfi() + local okFfi, ffi = pcall(require, "ffi") + if not okFfi then return nil end + if cdefOk == nil then + cdefOk = pcall(ffi.cdef, [[ + typedef struct SDL_Window SDL_Window; + int SDL_SetHint(const char *name, const char *value); + SDL_Window *SDL_GL_GetCurrentWindow(void); + void SDL_SetWindowResizable(SDL_Window *window, int resizable); + ]]) + end + if not cdefOk then return nil end + return ffi +end + +-- Push the mode into the live activity. Returns true when the hint reached +-- SDL (the symbols resolved), false on any non-Android / stubbed platform. +function Orientation.apply(mode) + if not Orientation.isAndroid() then return false end + local ffi = sdlFfi() + if not ffi then return false end + mode = Orientation.normalize(mode) + local ok = pcall(function() + -- "SDL_IOS_ORIENTATIONS" is SDL_HINT_ORIENTATIONS's name (SDL_hints.h); + -- despite the IOS in the string, the Android backend reads it too. + ffi.C.SDL_SetHint("SDL_IOS_ORIENTATIONS", HINTS[mode]) + local win = ffi.C.SDL_GL_GetCurrentWindow() + if win ~= nil then + ffi.C.SDL_SetWindowResizable(win, 0) + ffi.C.SDL_SetWindowResizable(win, 1) + end + end) + return ok +end + +function Orientation.applyOptions(opts) + return Orientation.apply(opts and opts.orientation) +end + +return Orientation diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 665b5504..19e74312 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -173,6 +173,25 @@ local function coreRows(opts) end) end + -- ORIENTATION (#592): Android only -- the lock rides SDL's orientation + -- hint, which iOS reads only at startup (the Info.plist governs there) and + -- desktop ignores. Unlike the other launcher rows this one live-applies: + -- the window exists here too, and rotating under the player's finger is + -- the only feedback that reads. + do + local osName = love.system and love.system.getOS and love.system.getOS() + local okOr, Orientation = pcall(require, "src.core.Orientation") + if okOr and osName == "Android" then + add(Strings("ORIENTATION"), + function() return Strings(Orientation.modeLabel(opts.orientation)) end, + function(dir) + opts.orientation = Orientation.cycle(opts.orientation, dir) + Orientation.apply(opts.orientation) + return true + end) + end + end + local okFr, FaithfulRes = pcall(require, "src.core.FaithfulRes") if okFr then add(Strings("FAITHFUL RATIO"), diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 0cdc7f36..56379f7f 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -19,6 +19,7 @@ local TileRenderer = require("src.render.TileRenderer") local GameSpeed = require("src.core.GameSpeed") 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 FrameCap = require("src.core.FrameCap") local Performance = require("src.core.Performance") @@ -350,6 +351,19 @@ local function buildRows(game) VideoMode.apply(o.videoMode) return true end }, + -- Android orientation lock (#592): AUTO / PORTRAIT / LANDSCAPE / + -- REVERSE LANDSCAPE, live-applied through SDL's orientation hint. + -- Filtered out below on everything that is not Android. + { id = "orientation", label = Strings("ORIENTATION"), + value = function(g) + return Strings(Orientation.modeLabel(g.save.options.orientation)) + end, + step = function(g, dir) + local o = g.save.options + o.orientation = Orientation.cycle(o.orientation, dir) + Orientation.apply(o.orientation) + return true + end }, -- Lock the window to an exact 160x144 multiple, so the surface IS the -- Game Boy screen with no letterbox at all. Sits next to VIDEO MODE -- because it overrides it: holding an exact size means dropping @@ -432,6 +446,14 @@ local function buildRows(game) end rows = filtered end + -- ORIENTATION only on Android, the one platform Orientation.apply reaches. + if not Orientation.isAndroid() then + local filtered = {} + for _, row in ipairs(rows) do + if row.id ~= "orientation" then filtered[#filtered + 1] = row end + end + rows = filtered + end -- TOUCH PAD only where the overlay can appear (mobile, or desktop with -- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere. do diff --git a/tests/engine/orientation_option.lua b/tests/engine/orientation_option.lua new file mode 100644 index 00000000..cb000198 --- /dev/null +++ b/tests/engine/orientation_option.lua @@ -0,0 +1,66 @@ +-- ORIENTATION lock (#592, #716): the option model. +-- +-- The Android side (SDL hint parsing, GameActivity's *_SENSOR -> *_USER +-- remap) can only be exercised on a device; what this tier pins down is the +-- Lua contract every UI row leans on: the mode set, normalization of stale +-- or garbage saves, the cycle order in both directions, and that apply() is +-- a safe no-op anywhere that is not Android -- including here, where love +-- is a headless stub and no SDL library is loaded. +-- luajit tests/engine/orientation_option.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Orientation = require("src.core.Orientation") + +local realOS = love.system and love.system.getOS +love.system = love.system or {} + +-- ------------------------------------------------------------- normalize + +T.eq(Orientation.DEFAULT, "auto", "AUTO is the default") +T.eq(Orientation.normalize(nil), "auto", "missing option reads as AUTO") +T.eq(Orientation.normalize("sideways"), "auto", "garbage reads as AUTO") +T.eq(Orientation.normalize("portrait"), "portrait", "valid modes pass through") +T.eq(Orientation.normalize("reverseLandscape"), "reverseLandscape", + "reverse landscape is a real mode") + +-- ------------------------------------------------------------------ cycle + +T.eq(Orientation.cycle("auto", 1), "portrait", "cycle forward from AUTO") +T.eq(Orientation.cycle("reverseLandscape", 1), "auto", "cycle wraps forward") +T.eq(Orientation.cycle("auto", -1), "reverseLandscape", "cycle wraps back") +T.eq(Orientation.cycle(nil, 1), "portrait", "cycling a fresh save starts at AUTO") + +-- one full lap forward touches every mode exactly once +local seen, mode = {}, "auto" +for _ = 1, #Orientation.MODES do + seen[mode] = true + mode = Orientation.cycle(mode, 1) +end +T.eq(mode, "auto", "a full lap returns to the start") +for _, m in ipairs(Orientation.MODES) do + T.eq(seen[m], true, "lap visits " .. m) +end + +-- ----------------------------------------------------------------- labels + +for _, m in ipairs(Orientation.MODES) do + T.eq(type(Orientation.modeLabel(m)), "string", m .. " has a label") + T.eq(#Orientation.modeLabel(m) <= 17, true, + m .. "'s label fits the OPTION box value line (17 cells at x=24)") +end + +-- -------------------------------------------------- apply() stays harmless + +love.system.getOS = function() return "OS X" end +T.eq(Orientation.apply("portrait"), false, "desktop apply is a refused no-op") +love.system.getOS = function() return "iOS" end +T.eq(Orientation.apply("portrait"), false, "iOS defers to the Info.plist") +-- Android posed but no SDL loaded in this process: the FFI path must fail +-- closed inside its pcall, never throw. +love.system.getOS = function() return "Android" end +local ok, err = pcall(Orientation.applyOptions, { orientation = "landscape" }) +T.eq(ok, true, "posed-Android apply never raises (" .. tostring(err) .. ")") + +love.system.getOS = realOS