diff --git a/conf.lua b/conf.lua index 38924550..c3e5b849 100644 --- a/conf.lua +++ b/conf.lua @@ -45,6 +45,12 @@ function love.conf(t) t.window.height = 1920 t.window.fullscreen = true t.window.highdpi = true + -- Android only (irrelevant on iOS): puts the save directory under the + -- app's external-files folder, which is readable/writable via USB or a + -- file manager with no runtime permission, so RomImporter can ask the + -- player to copy their ROM there instead of needing a native file + -- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua). + t.externalstorage = osName == "Android" else t.window.resizable = true end diff --git a/main.lua b/main.lua index a2e7a300..476cd271 100644 --- a/main.lua +++ b/main.lua @@ -184,7 +184,10 @@ end -- unfocused, so reset input on either transition rather than trust it. function love.focus(f) if editorMode then return end - if Importer then return end + if Importer then + if Importer.focus then Importer:focus(f) end + return + end Game:focus(f) end diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index c10ef99d..875d4b77 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -183,6 +183,18 @@ void vibrate(double seconds) env->DeleteLocalRef(activity); } +bool showFilePicker() +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + + jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z"); + jboolean result = env->CallStaticBooleanMethod(activity, method); + + env->DeleteLocalRef(activity); + return result; +} + /* * Helper functions for the filesystem module */ diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index 5e263f71..7902066b 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -59,6 +59,14 @@ bool openURL(const std::string &url); void vibrate(double seconds); +/** + * Shows the system's "pick a document" UI (Storage Access Framework). + * Returns true if the picker was launched; the picked file (if any) is + * copied asynchronously by GameActivity.onActivityResult into the app's + * external save directory, not returned here -- see src/import/RomImporter.lua. + **/ +bool showFilePicker(); + /* * Helper functions for the filesystem module */ diff --git a/mobile/android/love/src/jni/love/src/modules/filesystem/physfs/Filesystem.cpp b/mobile/android/love/src/jni/love/src/modules/filesystem/physfs/Filesystem.cpp index d3c65026..aee1ec4c 100644 --- a/mobile/android/love/src/jni/love/src/modules/filesystem/physfs/Filesystem.cpp +++ b/mobile/android/love/src/jni/love/src/modules/filesystem/physfs/Filesystem.cpp @@ -186,6 +186,18 @@ bool Filesystem::setIdentity(const char *ident, bool appendToPath) save_path_full = storage_path + std::string("/save/") + save_identity; + // love::android::mkdir is a single mkdir(), not mkdir -p: on a genuinely + // first-ever launch (nothing has touched this app's external-files dir + // before) save_directory doesn't exist yet either, so creating + // save_path_full in one step fails with ENOENT and PHYSFS_mount below + // silently never mounts anything for the rest of this process -- not + // just the save dir, but everything routed through it (save.lua/ + // options.lua, the ROM-derived asset cache, RomImporter's Android + // folder scan). Ensure each level exists in order instead. + if (!love::android::directoryExists(save_directory.c_str()) && + !love::android::mkdir(save_directory.c_str())) + SDL_Log("Error: Could not create save directory %s!", save_directory.c_str()); + if (!love::android::directoryExists(save_path_full.c_str()) && !love::android::mkdir(save_path_full.c_str())) SDL_Log("Error: Could not create save directory %s!", save_path_full.c_str()); @@ -338,6 +350,26 @@ bool Filesystem::setupWriteDirectory() std::string temp_writedir = getDriveRoot(save_path_full); std::string temp_createdir = skipDriveRoot(save_path_full); +#ifdef LOVE_ANDROID + // getUserDirectory() falls back to $HOME/getpwuid() (physfs_platform_posix.c), + // which is meaningless on Android and unrelated to save_path_full (an + // SDL_AndroidGet*StoragePath() subdirectory -- see setIdentity above), so + // the generic check below never matches and falls through to setting the + // write dir to the drive root ("/"), which no Android app can write to. + // Anchor to the real Android storage root instead. + std::string androidStorageRoot = isAndroidSaveExternal() + ? SDL_AndroidGetExternalStoragePath() : SDL_AndroidGetInternalStoragePath(); + if (save_path_full.find(androidStorageRoot) == 0) + { + temp_writedir = androidStorageRoot; + temp_createdir = save_path_full.substr(androidStorageRoot.length()); + + size_t startpos = temp_createdir.find_first_not_of('/'); + if (startpos != std::string::npos) + temp_createdir = temp_createdir.substr(startpos); + } + else +#endif // On some sandboxed platforms, physfs will break when its write directory // is the root of the drive and it tries to create a folder (even if the // folder's path is in a writable location.) If the user's home folder is diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index b7926592..3afbbca0 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -180,6 +180,15 @@ void System::vibrate(double seconds) const #endif } +bool System::pickFile() const +{ +#ifdef LOVE_ANDROID + return love::android::showFilePicker(); +#else + return false; +#endif +} + bool System::hasBackgroundMusic() const { #if defined(LOVE_ANDROID) diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index 861b4abc..6542820c 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -106,6 +106,15 @@ public: */ virtual void vibrate(double seconds) const; + /** + * Shows the platform's native "pick a file" UI, if one is available. + * Android only for now; the result (if any) is not returned here -- see + * love::android::showFilePicker and src/import/RomImporter.lua. + * + * @return Whether the picker was shown. + **/ + virtual bool pickFile() const; + /** * Gets if the user is playing music on background. * Throws an exception on unsupported platforms. diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index d0d2db79..567d2b46 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -95,6 +95,12 @@ int w_vibrate(lua_State *L) return 0; } +int w_pickFile(lua_State *L) +{ + luax_pushboolean(L, instance()->pickFile()); + return 1; +} + int w_hasBackgroundMusic(lua_State *L) { lua_pushboolean(L, instance()->hasBackgroundMusic()); @@ -110,6 +116,7 @@ static const luaL_Reg functions[] = { "getPowerInfo", w_getPowerInfo }, { "openURL", w_openURL }, { "vibrate", w_vibrate }, + { "pickFile", w_pickFile }, { "hasBackgroundMusic", w_hasBackgroundMusic }, { 0, 0 } }; diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index fb549273..8a9a63ae 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -61,6 +61,13 @@ public class GameActivity extends SDLActivity { protected final int[] recordAudioRequestDummy = new int[1]; public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2; public static final int RECORD_AUDIO_REQUEST_CODE = 3; + public static final int ROM_PICKER_REQUEST_CODE = 4; + // Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM + // is dropped so RomImporter's existing folder scan finds it -- see + // src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's + // save directory to getExternalFilesDir()/save/). + private static final String ROM_SAVE_IDENTITY = "pokemon-love2d"; + private static final String PICKED_ROM_FILENAME = "picked_rom.gb"; private static boolean immersiveActive = false; private static boolean needToCopyGameInArchive = false; private boolean storagePermissionUnnecessary = false; @@ -332,6 +339,64 @@ public class GameActivity extends SDLActivity { return openURL(url) == 0; } + /** + * Shows the system document picker (Storage Access Framework) so the + * player can pick their ROM from anywhere (Downloads, Drive, etc.) + * without needing to know where the app's external files folder is. + * Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any) + * arrives later in onActivityResult, not synchronously here. + */ + @Keep + public static boolean showRomFilePicker() { + if (android.os.Build.VERSION.SDK_INT < 19) return false; + GameActivity self = (GameActivity) mSingleton; + if (self == null) return false; + + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + try { + self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE); + return true; + } catch (Exception e) { + Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage()); + return false; + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode != ROM_PICKER_REQUEST_CODE) return; + if (resultCode != RESULT_OK || data == null || data.getData() == null) { + Log.d("GameActivity", "ROM picker returned no file (cancelled?)"); + return; + } + + Uri uri = data.getData(); + File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY); + if (!destDir.exists() && !destDir.mkdirs()) { + Log.d("GameActivity", "could not create " + destDir); + return; + } + File destFile = new File(destDir, PICKED_ROM_FILENAME); + + InputStream source; + try { + source = getContentResolver().openInputStream(uri); + } catch (FileNotFoundException e) { + Log.d("GameActivity", "could not open picked ROM: " + e.getMessage()); + return; + } + if (source == null) { + Log.d("GameActivity", "ContentResolver returned no stream for picked ROM"); + return; + } + if (!copyAssetFile(source, destFile.getPath())) { + Log.d("GameActivity", "could not copy picked ROM to " + destFile); + } + } + /** * Copies a given file from the assets folder to the destination. * diff --git a/scripts/build.sh b/scripts/build.sh index 6e915928..cc06d2fe 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -141,11 +141,20 @@ build_win() { say "building Windows (win64) app" local zip_name="love-$LOVE_VERSION-win64.zip" local love_zip="$CACHE/$zip_name" + # A cache hit only checks existence, not validity -- a prior run truncated + # by a network drop mid-download (curl still leaves the partial file if + # the exit code slips through) would otherwise be reused forever. + if [ -f "$love_zip" ] && ! unzip -tqq "$love_zip" >/dev/null 2>&1; then + warn "cached $zip_name is not a valid zip, removing and re-downloading" + rm -f "$love_zip" + fi if [ ! -f "$love_zip" ]; then say "downloading LÖVE $LOVE_VERSION win64 binaries" curl -fL --progress-bar \ "https://github.com/love2d/love/releases/download/$LOVE_VERSION/$zip_name" \ -o "$love_zip" || fail "download failed, check LOVE_VERSION or your network" + unzip -tqq "$love_zip" >/dev/null 2>&1 \ + || fail "downloaded $zip_name is not a valid zip (truncated download?)" fi local extract_dir="$WORK/love-win64" @@ -174,11 +183,19 @@ build_linux() { say "building Linux (x86_64 AppImage) app" local appimage_name="love-$LOVE_VERSION-x86_64.AppImage" local love_appimage="$CACHE/$appimage_name" + # Same cache-validity gap as the win64 zip above: an AppImage is just an + # ELF, so check the magic bytes before trusting a cached copy is complete. + if [ -f "$love_appimage" ] && [ "$(head -c 4 "$love_appimage" | od -An -tx1 | tr -d ' \n')" != "7f454c46" ]; then + warn "cached $appimage_name is not a valid ELF binary, removing and re-downloading" + rm -f "$love_appimage" + fi if [ ! -f "$love_appimage" ]; then say "downloading LÖVE $LOVE_VERSION Linux AppImage" curl -fL --progress-bar \ "https://github.com/love2d/love/releases/download/$LOVE_VERSION/$appimage_name" \ -o "$love_appimage" || fail "download failed, check LOVE_VERSION or your network" + [ "$(head -c 4 "$love_appimage" | od -An -tx1 | tr -d ' \n')" = "7f454c46" ] \ + || fail "downloaded $appimage_name is not a valid ELF binary (truncated download?)" fi chmod +x "$love_appimage" diff --git a/src/core/Game.lua b/src/core/Game.lua index 91bd2b98..13057e0a 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -349,17 +349,16 @@ function Game:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value) end --- Window lost focus or got minimized: any release event due while it was --- unfocused/hidden can be swallowed by the OS instead of delivered here, --- which would otherwise leave a held direction stuck on. +-- Window focus/visibility flips: a release due while unfocused/hidden can +-- be swallowed by the OS. Reset on both edges -- gaining focus with a +-- physically held key won't re-fire keypressed, so trusting leftover +-- state is worse than asking the player to re-press. function Game:focus(f) - if f then return end Input:reset() TouchInput:reset() end function Game:visible(v) - if v then return end Input:reset() TouchInput:reset() end @@ -369,6 +368,7 @@ end -- flags it owned. function Game:joystickremoved(joystick) Input:reset() + TouchInput:reset() end function Game:touchpressed(id, x, y) @@ -433,6 +433,7 @@ function Game:applyOptions(opts) require("src.render.Tilt").applyOptions(opts) require("src.render.GBCFX").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts) + Input:applyBindings(opts.bindings) end function Game:restoreSave(loaded, recovered) diff --git a/src/core/Input.lua b/src/core/Input.lua index 4088a98a..8155e3d6 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -3,7 +3,7 @@ local Input = {} -local BINDINGS = { +local DEFAULT_BINDINGS = { up = "up", w = "up", down = "down", s = "down", left = "left", a = "left", @@ -18,8 +18,11 @@ local BINDINGS = { -- Escape = start for desktop friendliness. -- LÖVE's standard gamepad mapping (SDL game controller DB), consistent --- across Xbox/PlayStation/generic controllers on desktop and mobile. -local GAMEPAD_BINDINGS = { +-- across Xbox/PlayStation/generic controllers on desktop and mobile. Some +-- third-party pads report their own SDL mapping for a given physical +-- button (e.g. Select/Back/View on off-brand XInput pads), which is what +-- src/ui/BindingsMenu.lua's rebinding is for -- see applyBindings below. +local DEFAULT_GAMEPAD_BINDINGS = { dpup = "up", dpdown = "down", dpleft = "left", dpright = "right", a = "a", b = "b", start = "start", back = "select", @@ -32,9 +35,33 @@ local STICK_ON = 0.5 local STICK_OFF = 0.3 function Input:init() + self:applyBindings(nil) self:reset() end +-- Layers a player's rebind choices (save.options.bindings, written by +-- src/ui/BindingsMenu.lua) on top of the defaults above. A rebind adds an +-- extra way to trigger that action instead of replacing the default key, +-- so e.g. Z/Enter/Space all still press A even after binding a 4th key to +-- it. Call whenever options load or change (see Game:applyOptions and +-- BindingsMenu:storeBinding) -- without this the menu records a choice +-- that never actually reaches gameplay. +function Input:applyBindings(overlay) + local keys, pads = {}, {} + for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end + for button, action in pairs(DEFAULT_GAMEPAD_BINDINGS) do pads[button] = action end + for actionId, binding in pairs(overlay or {}) do + if type(binding) == "table" then + if binding.key then keys[binding.key] = actionId end + if binding.pad then pads[binding.pad] = actionId end + elseif type(binding) == "string" then + keys[binding] = actionId + end + end + self.keyBindings = keys + self.padBindings = pads +end + -- Purely event-driven state (press sets true, release sets false) has no -- fallback if a release event never arrives -- focus loss, a minimized -- window, or a disconnected gamepad can all swallow the key-up/button-up @@ -44,45 +71,95 @@ function Input:reset() self.state = {} self.pressQueue = {} self.pressed = {} + self.sources = {} self.stickAxis = { x = 0, y = 0 } self.stickDir = nil end -function Input:keypressed(key) - local btn = BINDINGS[key] - if btn then +-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the +-- same GB button. Track them individually so releasing one doesn't clear +-- a hold another source still owns, and so a press+release that both land +-- before the next FixedStep can't be revived when step() drains the queue. +local function press(self, btn, source) + local sources = self.sources[btn] + if not sources then + sources = {} + self.sources[btn] = sources + end + if not sources[source] then + sources[source] = true table.insert(self.pressQueue, btn) end + self.state[btn] = true end -function Input:keyreleased(key) - local btn = BINDINGS[key] - if btn then +local function release(self, btn, source) + local sources = self.sources[btn] + if sources then + sources[source] = nil + if next(sources) == nil then + -- Leave an empty table (not nil) so step() can tell a real + -- source was released before the queue drained, versus a + -- synthetic pressQueue inject that never had sources at all. + self.state[btn] = false + end + else self.state[btn] = false end end +function Input:keypressed(key) + local btn = self.keyBindings[key] + if btn then + press(self, btn, "key:" .. key) + end +end + +function Input:keyreleased(key) + local btn = self.keyBindings[key] + if btn then + release(self, btn, "key:" .. key) + end +end + -- Called once per fixed step: promote queued presses to this step's edges. +-- Hold state is owned by live sources (updated in press/release), not +-- re-asserted here -- otherwise a same-frame press→release leaves the +-- button stuck on after the queue drains. +-- Synthetic injects (tests/drivers writing pressQueue directly, with no +-- source entry) still set state so scripted holds keep working. function Input:step() self.pressed = {} for _, btn in ipairs(self.pressQueue) do self.pressed[btn] = true - self.state[btn] = true + local sources = self.sources[btn] + if sources == nil then + -- synthetic pressQueue inject (tests/drivers): no live source map + self.state[btn] = true + elseif next(sources) ~= nil then + self.state[btn] = true + end + -- sources == {}: real press fully released before this step — keep up + end + for btn, sources in pairs(self.sources) do + if next(sources) == nil then + self.sources[btn] = nil + end end self.pressQueue = {} end function Input:gamepadpressed(joystick, button) - local btn = GAMEPAD_BINDINGS[button] + local btn = self.padBindings[button] if btn then - table.insert(self.pressQueue, btn) + press(self, btn, "pad:" .. button) end end function Input:gamepadreleased(joystick, button) - local btn = GAMEPAD_BINDINGS[button] + local btn = self.padBindings[button] if btn then - self.state[btn] = false + release(self, btn, "pad:" .. button) end end @@ -112,10 +189,10 @@ function Input:gamepadaxis(joystick, axis, value) if newDir ~= self.stickDir then if self.stickDir then - self.state[self.stickDir] = false + release(self, self.stickDir, "stick") end if newDir then - table.insert(self.pressQueue, newDir) + press(self, newDir, "stick") end self.stickDir = newDir end diff --git a/src/import/LuaWriter.lua b/src/import/LuaWriter.lua index 749bf129..d42d4899 100644 --- a/src/import/LuaWriter.lua +++ b/src/import/LuaWriter.lua @@ -88,9 +88,14 @@ end function LuaWriter.write(path, value) local parent = path:match("^(.*)/[^/]+$") - if parent then - local ok, err = love.filesystem.createDirectory(parent) - if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end + -- love.filesystem.createDirectory returns a single boolean (no error + -- value), so a second return here is always nil; getInfo after a failure + -- at least reports what is blocking the path (e.g. a file with that name). + if parent and not love.filesystem.createDirectory(parent) then + local info = love.filesystem.getInfo(parent) + local reason = info and ("a " .. info.type .. " already exists there") + or "unknown reason" + error("could not create " .. parent .. ": " .. reason) end local ok, err = love.filesystem.write(path, LuaWriter.encode(value)) if not ok then error("could not write " .. path .. ": " .. tostring(err)) end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index a2c3a132..1d342a27 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -226,6 +226,22 @@ local function commandOutput(command) return result ~= "" and result or nil end +-- LOVE 11.5 on Android has no native file picker (love.window.showFileDialog +-- is a LOVE 12 nightly-only addition) and never fires love.filedropped, so +-- neither desktop path below works there. conf.lua points the Android save +-- directory at the app's external-files folder instead (readable/writable +-- via USB or a file manager, no runtime permission needed), and this scans +-- it directly through love.filesystem -- already mounted at the physfs +-- root, so no io.* absolute-path handling is needed. +local function scanForRom() + for _, name in ipairs(love.filesystem.getDirectoryItems("")) do + if name:lower():match("%.gb$") and love.filesystem.getInfo(name, "file") then + return name + end + end + return nil +end + local function chooseRom() local platform = love.system.getOS() if platform == "OS X" then @@ -254,12 +270,14 @@ end function RomImporter.new(onComplete) local previousMarker = love.filesystem.read(MARKER_PATH) local returning = previousMarker ~= nil and previousMarker ~= CACHE_MARKER - return setmetatable({ + local android = love.system.getOS() == "Android" + local self = setmetatable({ onComplete = onComplete, logo = love.graphics.newImage("assets/logo/logo.png"), bcg = love.graphics.newImage("assets/logo/bcg.png"), state = "waiting", returning = returning, + android = android, status = returning and "More assets are needed from your ROM" or "Choose or drop a Pokemon Red ROM", detail = returning @@ -272,6 +290,30 @@ function RomImporter.new(onComplete) pulse = 0, button = {}, }, RomImporter) + + if android then + self.status = returning and "More ROM assets needed" or "Get your Pokemon Red ROM (.gb) in" + self.detail = "Tap Choose ROM to pick your file" + local name = scanForRom() + if name then + self:startData(love.filesystem.read(name), name) + end + end + + return self +end + +-- The system picker runs as a separate top activity, so LOVE's own +-- love.focus/love.visible pause while it's up (see main.lua) -- once the +-- player returns here with a file picked, GameActivity has already copied +-- it into the folder scanForRom checks, so a rescan on refocus picks it up +-- without the player needing to tap the button again. +function RomImporter:focus(f) + if not (f and self.android and self.state ~= "working") then return end + local name = scanForRom() + if name then + self:startData(love.filesystem.read(name), name) + end end function RomImporter:setError(message) @@ -362,6 +404,22 @@ end function RomImporter:choose() if self.state == "working" then return end + if self.android then + local name = scanForRom() + if name then + self:startData(love.filesystem.read(name), name) + elseif not love.system.pickFile() then + -- Picker unavailable (API < 19, or no document-picker app installed): + -- fall back to the USB folder-drop path. Not setError(): that status + -- text ("could not be imported") reads as a rejected file, not "none + -- found yet" -- and detail only renders 3 wrapped lines, so the path + -- again gets the line to itself. + self.state = "waiting" + self.status = "No picker available, copy your ROM into:" + self.detail = love.filesystem.getSaveDirectory() + end + return + end local path = chooseRom() if path then self:startPath(path) @@ -476,7 +534,8 @@ function RomImporter:draw() buttonWidth, "center") setColor255(74, 88, 72) love.graphics.setFont(smallFont) - love.graphics.printf("or drop the .gb file here", + love.graphics.printf( + self.android and "or copy the .gb via USB" or "or drop the .gb file here", 0, buttonY + buttonHeight + 12, width, "center") end diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua index 1707598a..22155885 100644 --- a/src/ui/BindingsMenu.lua +++ b/src/ui/BindingsMenu.lua @@ -1,11 +1,12 @@ -- Rebinding over the logical Game Boy buttons (gap C2's file-12 half, -- 12-ui-extensibility 4.4): one row per button, A arms a "PRESS A BUTTON" -- capture and the captured key or pad button lands in --- save.options.bindings -- the overlay src/core/Bindings.lua --- (04-mod-api-core) reads back over Input's fixed map. +-- save.options.bindings, which Input:applyBindings layers over its fixed +-- default map (see src/core/Input.lua and Game:applyOptions). local Font = require("src.render.Font") local ListMenu = require("src.ui.ListMenu") +local Input = require("src.core.Input") local BindingsMenu = setmetatable({}, { __index = ListMenu }) BindingsMenu.__index = BindingsMenu @@ -78,6 +79,7 @@ function BindingsMenu:storeBinding(slot, value) b[slot] = value opts.bindings[item.button.id] = b item.right = boundKey(opts.bindings, item.button):upper() + Input:applyBindings(opts.bindings) if game.writeOptions then game:writeOptions() end end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 11169be8..82e6ccda 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3100,9 +3100,14 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) local outdoor = Map.isOutdoor(self.map.def) require("src.core.Sound").play(Game.data, outdoor and "Go_Outside" or "Go_Inside") - -- stepping out of an outdoor door mat (the original's walk-out) + -- stepping out of an outdoor door/cave entrance (the original's + -- walk-out). Auto-walk leaves the mat, so the arrival disable + -- (warpEntryCell / justWarped) is unnecessary -- and would let you + -- stand on the door without re-entering if you hold back into it. if outdoor and self.player.facing == "down" and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then + self.warpEntryCell = nil + self.justWarped = false self:scriptMove(self.player, "down", 1) end end diff --git a/tests/input_hold_test.lua b/tests/input_hold_test.lua new file mode 100644 index 00000000..9700e159 --- /dev/null +++ b/tests/input_hold_test.lua @@ -0,0 +1,56 @@ +-- Same-frame press→release and multi-source hold regressions for Input.lua. +-- Self-contained: `luajit tests/input_hold_test.lua`; also dofile'd by +-- tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("input hold") +local check = S.check +local Input = require("src.core.Input") + +Input:init() + +-- Quick tap before the next FixedStep must edge-fire without leaving isDown. +Input:keypressed("up") +Input:keyreleased("up") +Input:step() +check(Input:wasPressed("up"), "same-frame tap still edges wasPressed") +check(not Input:isDown("up"), "same-frame tap does not stick isDown") + +Input:reset() +Input:keypressed("up") +Input:step() +check(Input:wasPressed("up"), "held press edges wasPressed") +check(Input:isDown("up"), "held press keeps isDown across step") +Input:step() +check(not Input:wasPressed("up"), "hold does not re-edge next step") +check(Input:isDown("up"), "hold stays down next step") +Input:keyreleased("up") +check(not Input:isDown("up"), "release clears isDown") + +-- W and Up both map to up; releasing one must not drop the other. +Input:reset() +Input:keypressed("w") +Input:keypressed("up") +Input:step() +Input:keyreleased("w") +check(Input:isDown("up"), "second source keeps up held after first release") +Input:keyreleased("up") +check(not Input:isDown("up"), "last source release clears up") + +-- Stick flick on→off before step must not stick. +Input:reset() +Input:gamepadaxis(nil, "leftx", -0.9) +Input:gamepadaxis(nil, "leftx", 0) +Input:step() +check(Input:wasPressed("left"), "stick flick edges wasPressed") +check(not Input:isDown("left"), "stick flick does not stick isDown") + +-- Drivers that only inject pressQueue still get a one-step hold. +Input:reset() +table.insert(Input.pressQueue, "down") +Input:step() +check(Input:wasPressed("down"), "synthetic pressQueue edges wasPressed") +check(Input:isDown("down"), "synthetic pressQueue sets isDown") + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 81b6e3ce..0cba823e 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -2578,6 +2578,9 @@ do check(status == 0 or status == true, "save_editor_mod_tests suite") end +-- ---------------------------------------------- input hold regressions +runSuites({ "tests/input_hold_test.lua" }) + -- ---------------------------------------------- parity workstream tests -- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, -- error()s if any assertion fails). Globbed, so dropping a new parity