Compare commits

..

2 Commits

Author SHA1 Message Date
bryanthaboi 943ae23e73 Update build.sh 2026-07-22 13:03:53 -04:00
bryanthaboi 839cf19088 bzinga 2026-07-22 12:28:51 -04:00
18 changed files with 445 additions and 35 deletions
+6
View File
@@ -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
+4 -1
View File
@@ -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
@@ -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
*/
@@ -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
*/
@@ -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
@@ -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)
@@ -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.
@@ -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 }
};
@@ -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/<identity>).
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.
*
+56 -5
View File
@@ -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,21 +183,63 @@ 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"
# Same fusion trick as the Windows exe: love looks for a zip appended to
# its own running binary, and an AppImage is just an ELF executable, so
# concatenating game.love onto it works the same way `cat love.exe
# game.love` does on Windows.
# The Windows-style `cat love.exe game.love` fusion does NOT work here:
# an AppImage is a small runtime ELF with a squashfs appended, and at
# launch the runtime mounts the squashfs and executes bin/love from
# *inside* it -- bytes appended to the outer file are never read, so
# users would just get vanilla LÖVE's no-game screen. Instead, unpack
# the squashfs, drop game.love in, point AppRun's FUSE_PATH hook at it
# (the hook ships commented-out in LÖVE's official AppImage), and glue
# runtime + repacked squashfs back together.
command -v unsquashfs >/dev/null && command -v mksquashfs >/dev/null \
|| fail "squashfs tools not found; install with: brew install squashfs"
# The squashfs starts right where the ELF ends:
# e_shoff + e_shnum * e_shentsize (all little-endian in the ELF64 header).
local e_shoff e_shentsize e_shnum sfs_offset
e_shoff=$(od -An -j40 -N8 -tu8 "$love_appimage" | tr -d ' ')
e_shentsize=$(od -An -j58 -N2 -tu2 "$love_appimage" | tr -d ' ')
e_shnum=$(od -An -j60 -N2 -tu2 "$love_appimage" | tr -d ' ')
sfs_offset=$((e_shoff + e_shentsize * e_shnum))
[ "$(dd if="$love_appimage" bs=1 skip="$sfs_offset" count=4 2>/dev/null)" = "hsqs" ] \
|| fail "no squashfs superblock at computed offset $sfs_offset (unexpected AppImage layout)"
local appdir="$WORK/linux-appdir"
rm -rf "$appdir"
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
cp "$LOVE_FILE" "$appdir/game.love"
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
# Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it.
local sfs_out="$WORK/game.squashfs"
rm -f "$sfs_out"
mksquashfs "$appdir" "$sfs_out" \
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
local out_bin="$WORK/$APP_NAME-x86_64.AppImage"
rm -f "$out_bin"
cat "$love_appimage" "$LOVE_FILE" > "$out_bin"
head -c "$sfs_offset" "$love_appimage" > "$out_bin"
cat "$sfs_out" >> "$out_bin"
chmod +x "$out_bin"
local zip_out="$DIST/linux/$APP_NAME-linux.zip"
+6 -5
View File
@@ -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)
+93 -16
View File
@@ -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
+8 -3
View File
@@ -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
+61 -2
View File
@@ -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
+4 -2
View File
@@ -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
+6 -1
View File
@@ -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
+56
View File
@@ -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()
+3
View File
@@ -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