diff --git a/main.lua b/main.lua index 2c352977..1ffe5a89 100644 --- a/main.lua +++ b/main.lua @@ -877,6 +877,8 @@ love.handlers = love.handlers or {} function love.handlers.audiosuspend() local ChipAudio = package.loaded["src.core.ChipAudio"] if ChipAudio then pcall(ChipAudio.setSuspended, true) end + local Sound = package.loaded["src.core.Sound"] + if Sound then pcall(Sound.onDeviceReset) end end function love.handlers.audioreset() diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index a12254e6..2c7d8798 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -54,7 +54,7 @@ -- 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. +-- Android only re-reads the hint at window creation or on a resizable-flag +-- change (SDL_androidwindow.c), and SDL_SetWindowResizable early-returns on +-- a fullscreen window (SDL_video.c:2237) -- which LOVE's Android window +-- always is -- so the hint never reached a running activity (#1638). +-- apply() sets the hint for a later window, then goes over JNI for the live +-- one. iOS needs only the hint. Desktop and headless stubs no-op. local Orientation = {} @@ -58,6 +54,11 @@ function Orientation.isAndroid() return love.system.getOS() == "Android" end +function Orientation.isIOS() + if not love or not love.system or not love.system.getOS then return false end + return love.system.getOS() == "iOS" +end + function Orientation.cycle(mode, dir) local cur, idx = Orientation.normalize(mode), 1 for i, m in ipairs(Orientation.MODES) do @@ -67,6 +68,15 @@ function Orientation.cycle(mode, dir) return Orientation.MODES[(idx - 1 + (dir or 1)) % n + 1] end +-- ActivityInfo constants, what setOrientationBis lands on per hint after +-- GameActivity's *_SENSOR -> *_USER remap (#716). +local REQUESTED = { + auto = 13, + portrait = 1, + landscape = 11, + reverseLandscape = 8, +} + -- 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. @@ -77,33 +87,79 @@ local function sdlFfi() if cdefOk == nil then cdefOk = pcall(ffi.cdef, [[ typedef struct SDL_Window SDL_Window; + typedef union { int32_t i; int64_t pad; } love_jvalue; int SDL_SetHint(const char *name, const char *value); SDL_Window *SDL_GL_GetCurrentWindow(void); void SDL_SetWindowResizable(SDL_Window *window, int resizable); + void *SDL_AndroidGetJNIEnv(void); + void *SDL_AndroidGetActivity(void); ]]) 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. +-- Slot numbers in JNINativeInterface (jni.h). +local JNI_EXCEPTION_CLEAR = 17 +local JNI_DELETE_LOCAL_REF = 23 +local JNI_GET_OBJECT_CLASS = 31 +local JNI_GET_METHOD_ID = 33 +local JNI_CALL_VOID_METHOD_A = 63 + +-- What Android_JNI_SetOrientation reaches, called directly: the hint path +-- cannot re-run on a live fullscreen window (SDL_video.c:2237). +local function setRequestedOrientation(ffi, requested) + local env = ffi.C.SDL_AndroidGetJNIEnv() + if env == nil then return false end + local activity = ffi.C.SDL_AndroidGetActivity() + if activity == nil then return false end + local fns = ffi.cast("void***", env)[0] + local getObjectClass = ffi.cast("void *(*)(void *, void *)", fns[JNI_GET_OBJECT_CLASS]) + local getMethodID = ffi.cast( + "void *(*)(void *, void *, const char *, const char *)", fns[JNI_GET_METHOD_ID]) + local callVoidMethodA = ffi.cast( + "void (*)(void *, void *, void *, love_jvalue *)", fns[JNI_CALL_VOID_METHOD_A]) + local deleteLocalRef = ffi.cast("void (*)(void *, void *)", fns[JNI_DELETE_LOCAL_REF]) + local exceptionClear = ffi.cast("void (*)(void *)", fns[JNI_EXCEPTION_CLEAR]) + + local ok = false + local cls = getObjectClass(env, activity) + if cls ~= nil then + local mid = getMethodID(env, cls, "setRequestedOrientation", "(I)V") + if mid ~= nil then + local args = ffi.new("love_jvalue[1]") + args[0].pad = 0 + args[0].i = requested + callVoidMethodA(env, activity, mid, args) + ok = true + end + exceptionClear(env) + deleteLocalRef(env, cls) + end + deleteLocalRef(env, activity) + return ok +end + +-- Returns true only when the request actually landed, never unconditionally +-- as it once did (#1638). function Orientation.apply(mode) - if not Orientation.isAndroid() then return false end + local android = Orientation.isAndroid() + if not (android or Orientation.isIOS()) 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. + local ok, reached = pcall(function() + -- SDL_HINT_ORIENTATIONS is "SDL_IOS_ORIENTATIONS" in the SDL2 Android + -- ships and "SDL_ORIENTATIONS" in the SDL3 the iOS app links; each + -- engine ignores the other's key. 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 + ffi.C.SDL_SetHint("SDL_ORIENTATIONS", HINTS[mode]) + -- On iOS the hint is the lock: UIKit re-asks on every rotation + -- (SDL_uikitviewcontroller.m supportedInterfaceOrientations). + if not android then return true end + return setRequestedOrientation(ffi, REQUESTED[mode]) end) - return ok + return ok and reached == true end function Orientation.applyOptions(opts) diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 9aea0c7d..f2982353 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -154,8 +154,14 @@ local function newSfxSource(data, key, def, pitch, tempo, plain) return newFileSource(def) end +local function deviceSuspended() + local ChipAudio = package.loaded["src.core.ChipAudio"] + return ChipAudio ~= nil and ChipAudio.isSuspended() +end + local function playPath(data, key, def, pitch, tempo, plain) if not love.audio or not def then return nil end + if deviceSuspended() then return nil end local src = cache[key] if src == false then return nil end -- known bad, already logged if not src then @@ -602,6 +608,7 @@ end -- cache carries no clips (Red/Blue) or headless. function Sound.playPikaCry(data, n) if not love.audio then return nil end + if deviceSuspended() then return nil end local count = data.audio and data.audio.pikaCries if not count then return nil end n = math.max(1, math.min(count, n or 1)) @@ -633,6 +640,7 @@ end -- like the original's PlayCry -> WaitForSoundToFinish can poll it function Sound.playCry(data, species, pikaClip) if not love.audio then return nil end + if deviceSuspended() then return nil end -- Yellow voices every Pikachu cry with the PCM clips (the chip cry is -- never used for the species there). Which clip is a property of the -- call site in the original -- every caller of PlayPikachuSoundClip sets diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index b832121e..ab820348 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -303,15 +303,12 @@ local function coreRows(opts, hooks) 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. + -- ORIENTATION (#592, #1638): mobile only. 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 + if okOr and (Orientation.isAndroid() or Orientation.isIOS()) then add(Strings("ORIENTATION"), function() return Strings(Orientation.modeLabel(opts.orientation)) end, function(dir) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index a5d6f204..11a36a41 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -3017,9 +3017,13 @@ local function buildTextModal(imp, m, key, title, body, closeFn) local h = math.floor(math.min(m.H - 2 * m.pad, 460 * m.s)) local px, py, pw, ph = modalPanel(m, w, h) local cy = py + pad - Kit.text("button", Kit.ellipsize("button", title, pw - 2 * pad), + local xW = math.max(Kit.tapMin(), math.floor(30 * m.s)) + Kit.text("button", Kit.ellipsize("button", title, + pw - 2 * pad - xW - math.floor(8 * m.s)), px + pad, cy, PAL.heading) - cy = cy + Kit.textHeight("button") + math.floor(10 * m.s) + btn(imp, px + pw - pad - xW, cy, xW, xW, key .. "-x", "X", + { font = "small", action = closeFn }) + cy = cy + math.max(Kit.textHeight("button"), xW) + math.floor(10 * m.s) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local bodyH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s) @@ -5071,6 +5075,7 @@ function LauncherView.draw(imp) -- whole stage draws shielded (no clicks, no hover, no focus ring) while -- one is up; buildModals lowers the shield for the modal's own controls. imp._modalUpNow = modalUp(imp) + if imp._modalUpNow then imp:_blurPanelFields() end Kit.blockClicks = imp._modalUpNow local step = Kit.scrollStep(m.s) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 5b7fe222..5845f810 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2854,6 +2854,7 @@ function RomImporter:_updatePadCursor(dt) local overY = 0 if ny > oy + h then overY = ny - (oy + h) elseif ny < oy then overY = ny - oy end + if math.abs(ay) > PAD_DEAD and not self._padStickCentered then overY = 0 end if overY ~= 0 and self._flex then require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) end @@ -2913,7 +2914,11 @@ end function RomImporter:gamepadaxis(_, axis, value) if axis == "leftx" or axis == "lefty" or axis == "righty" then self._padAxis[axis] = value - if math.abs(value) > PAD_DEAD then self:_activatePadCursor() end + if math.abs(value) > PAD_DEAD then + self:_activatePadCursor() + elseif axis == "lefty" then + self._padStickCentered = true + end end end @@ -2922,18 +2927,21 @@ end -- fire the virtual cursor's click twice off one A press (#620). function RomImporter:joystickpressed(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end local padButton = GamepadMap.mapRawToGamepadButton(button) if padButton then self:gamepadpressed(joystick, padButton) end end function RomImporter:joystickreleased(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end local padButton = GamepadMap.mapRawToGamepadButton(button) if padButton then self:gamepadreleased(joystick, padButton) end end function RomImporter:joystickaxis(joystick, axis, value) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end if axis == 1 then self:gamepadaxis(joystick, "leftx", value) elseif axis == 2 then @@ -2943,6 +2951,7 @@ end function RomImporter:joystickhat(joystick, hat, direction) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end for _, dir in ipairs(self._rawHatDirs[hat] or {}) do self._padDir[dir] = nil end @@ -4003,6 +4012,18 @@ function RomImporter:_disarmTextInput() end end +function RomImporter:_blurPanelFields() + if not (self._findSearchFocus or self._skinUrlFocus) then return end + if self._indexPrompt or self._rename or self._settingsText + or self._profileSavePrompt or self._profileRenamePrompt + or (self._syncModal and self._syncFocus) then + return + end + self._findSearchFocus = false + self._skinUrlFocus = false + self:_disarmTextInput() +end + function RomImporter:_beginRename(version, id) local label for _, slot in ipairs(self.slots[version] or {}) do diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d79d2b5d..67ca0414 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -580,8 +580,8 @@ local function buildRows(game) end rows = filtered end - -- ORIENTATION only on Android, the one platform Orientation.apply reaches. - if not Orientation.isAndroid() then + -- ORIENTATION only on the platforms Orientation.apply reaches (#1638). + if not (Orientation.isAndroid() or Orientation.isIOS()) then local filtered = {} for _, row in ipairs(rows) do if row.id ~= "orientation" then filtered[#filtered + 1] = row end diff --git a/src/ui/PadCursor.lua b/src/ui/PadCursor.lua index 2286e23a..4e8ea9b2 100644 --- a/src/ui/PadCursor.lua +++ b/src/ui/PadCursor.lua @@ -151,6 +151,7 @@ end function PadCursor.joystickpressed(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return nil end + if GamepadMap.isAccelerometer(joystick) then return nil end local padButton = GamepadMap.mapRawToGamepadButton(button) if padButton then return PadCursor.gamepadpressed(joystick, padButton) end return nil @@ -158,12 +159,14 @@ end function PadCursor.joystickreleased(joystick, button) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end local padButton = GamepadMap.mapRawToGamepadButton(button) if padButton then PadCursor.gamepadreleased(joystick, padButton) end end function PadCursor.joystickaxis(joystick, axisIndex, value) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end if axisIndex == 1 then PadCursor.gamepadaxis(joystick, "leftx", value) elseif axisIndex == 2 then @@ -173,6 +176,7 @@ end function PadCursor.joystickhat(joystick, hat, direction) if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end for _, d in ipairs(rawHatDirs[hat] or {}) do dir[d] = nil end diff --git a/src/ui/gen2/EvolutionAnim.lua b/src/ui/gen2/EvolutionAnim.lua index b3c4f02c..72a57224 100644 --- a/src/ui/gen2/EvolutionAnim.lua +++ b/src/ui/gen2/EvolutionAnim.lua @@ -252,6 +252,8 @@ function EvolutionAnim:setPhase(phase) full = self.full, }) end + local stack = self.game and self.game.stack + if stack and stack.top and stack:top() == self then stack:pop() end return end end @@ -321,8 +323,18 @@ end function EvolutionAnim:update(_dt) local input = self.game and self.game.input local phase = self.phase - -- onDone has already fired; the caller pops this state on its own beat. - if phase == "done" then return end + -- onDone has already fired. + if phase == "done" then + local stack = self.game and self.game.stack + if stack and stack.top and stack:top() == self then stack:pop() end + return + end + + if phase == "waitingLearn" then + local stack = self.game and self.game.stack + if stack and stack.top and stack:top() == self then self:nextLearn() end + return + end if phase == "flash" then return self:updateFlash(input) diff --git a/tests/engine/launcher_scroll_test.lua b/tests/engine/launcher_scroll_test.lua index b896fba3..89b48242 100644 --- a/tests/engine/launcher_scroll_test.lua +++ b/tests/engine/launcher_scroll_test.lua @@ -291,9 +291,15 @@ eq(edgeImp._pageScrollMax, 0, "with no page scroll left to catch the notch") check(ereg.y + ereg.h < 720, "and a region that ends above the safe area") edgeImp._padCursorActive = true edgeImp._padCursor = { x = ereg.x + 20, y = 719 } -edgeImp._padAxis = { lefty = 1 } edgeImp._padDir = {} pointer(ereg.x + 20, 719) +edgeImp:gamepadaxis(nil, "lefty", 1) +edgeImp:_updatePadCursor(0.5) +eq(edgeImp._wheelY or 0, 0, + "an axis that has never read under the deadzone cannot edge-scroll") +edgeImp._padCursor = { x = ereg.x + 20, y = 719 } +edgeImp:gamepadaxis(nil, "lefty", 0) +edgeImp:gamepadaxis(nil, "lefty", 1) edgeImp:_updatePadCursor(0.5) check((edgeImp._wheelY or 0) < 0, "the edge push synthesizes a notch") LauncherView.draw(edgeImp) diff --git a/tests/engine/orientation_option.lua b/tests/engine/orientation_option.lua index cb000198..5946fbfd 100644 --- a/tests/engine/orientation_option.lua +++ b/tests/engine/orientation_option.lua @@ -55,8 +55,12 @@ end love.system.getOS = function() return "OS X" end T.eq(Orientation.apply("portrait"), false, "desktop apply is a refused no-op") +-- iOS posed but no SDL loaded: the FFI path must fail closed, not claim the +-- lock took. love.system.getOS = function() return "iOS" end -T.eq(Orientation.apply("portrait"), false, "iOS defers to the Info.plist") +T.eq(Orientation.apply("portrait"), false, "iOS apply fails closed with no SDL") +local okIOS = pcall(Orientation.applyOptions, { orientation = "portrait" }) +T.eq(okIOS, true, "posed-iOS apply never raises") -- 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