Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
@@ -512,8 +512,9 @@ gains a field instead of the name gaining a prefix.
|
||||
id under Gen 1's `name` key, which is the one payload difference the
|
||||
numeric flag space forces.
|
||||
- *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`,
|
||||
`ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`,
|
||||
`ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script
|
||||
`ui.options.rows`, `ui.party.submenu`, `ui.party.grid_navigation`,
|
||||
`ui.naming.grid`, `ui.pc.items`, `ui.list_menu`, `transition.style`.
|
||||
`ui.list_menu` covers Gold's script
|
||||
menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title
|
||||
menus draw with does not raise it yet, so those two are composed through
|
||||
their own hooks only.
|
||||
|
||||
@@ -291,6 +291,78 @@ function closeSkinStudio()
|
||||
end
|
||||
end
|
||||
|
||||
local function makeLauncher()
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||
return RomImporter.new(function(version)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end, {
|
||||
launcher = true,
|
||||
forceImport = forceImport,
|
||||
onEditSave = openEditor,
|
||||
onEditTouchControls = openTouchControlsEditor,
|
||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||
and openSkinStudio or nil,
|
||||
})
|
||||
end
|
||||
|
||||
local function returnToLauncher()
|
||||
if not Game then return end
|
||||
|
||||
pcall(function() require("src.core.Music").stop() end)
|
||||
pcall(function() require("src.core.Sound").stop() end)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.DiscordPresence"] then
|
||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||
end
|
||||
if package.loaded["src.core.gen2.Clock"] then
|
||||
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
|
||||
end
|
||||
if package.loaded["src.net.Gen1Tls"] then
|
||||
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
|
||||
end
|
||||
if love.audio and love.audio.stop then
|
||||
pcall(love.audio.stop)
|
||||
end
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local currentVersion = GameVersion.get()
|
||||
if currentVersion then
|
||||
require("src.import.CacheFs").unmountVersion(currentVersion)
|
||||
end
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
if Runtime.reset then
|
||||
Runtime.reset()
|
||||
end
|
||||
|
||||
Game = nil
|
||||
autopilot = nil
|
||||
driverCo = nil
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
|
||||
require("src.core.Orientation").applyOptions(
|
||||
require("src.core.SaveData").loadOptions())
|
||||
|
||||
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||
|
||||
if love.window and love.window.setTitle then
|
||||
local Version = require("src.core.Version")
|
||||
love.window.setTitle(Version.title("Gen 1 Recompilation Project"))
|
||||
end
|
||||
|
||||
Importer = makeLauncher()
|
||||
end
|
||||
|
||||
function bootGame(version)
|
||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||
@@ -382,7 +454,7 @@ function love.load(args)
|
||||
|
||||
-- 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.
|
||||
-- the launcher would rotate freely until options are applied at boot.
|
||||
-- No-op on desktop / iOS / when options.lua does not exist yet.
|
||||
require("src.core.Orientation").applyOptions(
|
||||
require("src.core.SaveData").loadOptions())
|
||||
@@ -442,8 +514,8 @@ function love.load(args)
|
||||
-- (#767) only pays off if something fills that catalog this early, and no
|
||||
-- restart could: the ordering is the same on every launch. Read the
|
||||
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
|
||||
-- translation reaches the launcher too. Game:load replaces this with the
|
||||
-- real merged catalog once a version boots.
|
||||
-- translation reaches the launcher too. The active game's loader replaces
|
||||
-- this with the real merged catalog once a version boots.
|
||||
do
|
||||
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||
@@ -484,17 +556,7 @@ function love.load(args)
|
||||
-- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold
|
||||
-- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md).
|
||||
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
||||
Importer = RomImporter.new(function(version)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end, {
|
||||
launcher = true,
|
||||
forceImport = forceImport,
|
||||
onEditSave = openEditor,
|
||||
onEditTouchControls = openTouchControlsEditor,
|
||||
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||
and openSkinStudio or nil,
|
||||
})
|
||||
Importer = makeLauncher()
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
@@ -827,6 +889,27 @@ function love.handlers.audioreset()
|
||||
if Sound then pcall(Sound.onDeviceReset) end
|
||||
end
|
||||
|
||||
function love.handlers.intent_game(version)
|
||||
if type(version) ~= "string" or version == "" then return end
|
||||
version = version:lower():gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
if GameVersion.VERSIONS and not GameVersion.VERSIONS[version] then return end
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
if not RomImporter.isReady(version) then return end
|
||||
|
||||
local currentVersion = GameVersion.get()
|
||||
if Game and currentVersion == version then
|
||||
return
|
||||
end
|
||||
|
||||
if Game then
|
||||
returnToLauncher()
|
||||
end
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end
|
||||
|
||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if editorMode then
|
||||
-- iOS synthesizes mousepressed for the primary touch; forwarding here
|
||||
@@ -1032,11 +1115,16 @@ function love.quit()
|
||||
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
||||
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||
-- claims the hook) is exactly the condition below.
|
||||
local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android")
|
||||
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||
return Game and not Importer and not quitToLauncher and not scripted
|
||||
and not launchedIntoGame
|
||||
and (isAndroid or not launchedIntoGame)
|
||||
end)
|
||||
if wouldReturnToLauncher then
|
||||
if isAndroid then
|
||||
returnToLauncher()
|
||||
return true -- abort this quit; the restart lands back in the launcher
|
||||
end
|
||||
quitToLauncher = true
|
||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||
-- once, so the restart really does land in the launcher (#887). A failed
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/love"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="${NAME}" >
|
||||
<meta-data
|
||||
android:name="android.allow_multiple_resumed_activities"
|
||||
@@ -39,7 +40,7 @@
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:label="${NAME}"
|
||||
android:launchMode="singleInstance"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="${ORIENTATION}"
|
||||
android:resizeableActivity="false"
|
||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" >
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -3,4 +3,9 @@
|
||||
<color name="colorPrimary">#3F51B5</color>
|
||||
<color name="colorPrimaryDark">#303F9F</color>
|
||||
<color name="colorAccent">#FF4081</color>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
<color name="shortcut_red">#E53935</color>
|
||||
<color name="shortcut_blue">#1E88E5</color>
|
||||
<color name="shortcut_yellow">#FDD835</color>
|
||||
<color name="shortcut_gold">#D4AF37</color>
|
||||
</resources>
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
#include "common/Module.h"
|
||||
#include "audio/Audio.h"
|
||||
#include "audio/openal/Audio.h"
|
||||
#include "event/Event.h"
|
||||
|
||||
namespace love
|
||||
{
|
||||
@@ -282,6 +283,70 @@ bool restartApp()
|
||||
return result;
|
||||
}
|
||||
|
||||
bool updateAppShortcuts(const std::vector<std::string> &versions)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
if (activity == nullptr)
|
||||
return false;
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "updateAppShortcuts", "([Ljava/lang/String;)Z");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jclass stringClass = env->FindClass("java/lang/String");
|
||||
jobjectArray array = env->NewObjectArray((jsize) versions.size(), stringClass, nullptr);
|
||||
for (size_t i = 0; i < versions.size(); ++i)
|
||||
{
|
||||
jstring jstr = env->NewStringUTF(versions[i].c_str());
|
||||
env->SetObjectArrayElement(array, (jsize) i, jstr);
|
||||
env->DeleteLocalRef(jstr);
|
||||
}
|
||||
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, array);
|
||||
|
||||
env->DeleteLocalRef(array);
|
||||
env->DeleteLocalRef(stringClass);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string getLaunchGame()
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
if (activity == nullptr)
|
||||
return "";
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "getLaunchGame", "()Ljava/lang/String;");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return "";
|
||||
}
|
||||
|
||||
jstring jgame = (jstring) env->CallStaticObjectMethod(activity, method);
|
||||
if (jgame == nullptr)
|
||||
{
|
||||
env->DeleteLocalRef(activity);
|
||||
return "";
|
||||
}
|
||||
|
||||
const char *str = env->GetStringUTFChars(jgame, nullptr);
|
||||
std::string result = (str != nullptr) ? str : "";
|
||||
if (str != nullptr)
|
||||
env->ReleaseStringUTFChars(jgame, str);
|
||||
|
||||
env->DeleteLocalRef(jgame);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
|
||||
{
|
||||
if (url == nullptr || destPath == nullptr)
|
||||
@@ -1488,4 +1553,32 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas
|
||||
love::audio::openal::pushAudioResetEvent();
|
||||
}
|
||||
|
||||
static void pushGameIntentEvent(const char *game)
|
||||
{
|
||||
auto eventmodule = love::Module::getInstance<love::event::Event>(love::Module::M_EVENT);
|
||||
if (eventmodule == nullptr || game == nullptr)
|
||||
return;
|
||||
|
||||
std::vector<love::Variant> args;
|
||||
args.push_back(love::Variant(std::string(game)));
|
||||
|
||||
love::event::Message *msg = new love::event::Message("intent_game", args);
|
||||
eventmodule->push(msg);
|
||||
msg->release();
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game)
|
||||
{
|
||||
(void) cls;
|
||||
if (game == nullptr)
|
||||
return;
|
||||
const char *str = env->GetStringUTFChars(game, nullptr);
|
||||
if (str != nullptr)
|
||||
{
|
||||
pushGameIntentEvent(str);
|
||||
env->ReleaseStringUTFChars(game, str);
|
||||
}
|
||||
}
|
||||
|
||||
#endif // LOVE_ANDROID
|
||||
|
||||
@@ -90,6 +90,16 @@ bool syncHealthSteps();
|
||||
**/
|
||||
bool restartApp();
|
||||
|
||||
/**
|
||||
* Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
|
||||
**/
|
||||
bool updateAppShortcuts(const std::vector<std::string> &versions);
|
||||
|
||||
/**
|
||||
* Returns the game version requested via initial launch Intent (if any).
|
||||
**/
|
||||
std::string getLaunchGame();
|
||||
|
||||
/**
|
||||
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
|
||||
* no curl binary, so this is the transport src/core/HostShell.lua uses there
|
||||
|
||||
@@ -245,6 +245,25 @@ bool System::restartApp() const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::updateShortcuts(const std::vector<std::string> &versions) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::updateAppShortcuts(versions);
|
||||
#else
|
||||
LOVE_UNUSED(versions);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string System::getLaunchGame() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::getLaunchGame();
|
||||
#else
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::httpDownload(const char *url, const char *destPath,
|
||||
const char *userAgent, const char *accept) const
|
||||
{
|
||||
|
||||
@@ -143,6 +143,9 @@ public:
|
||||
**/
|
||||
virtual bool restartApp() const;
|
||||
|
||||
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
|
||||
virtual std::string getLaunchGame() const;
|
||||
|
||||
/**
|
||||
* Blocking HTTPS GET into an absolute host path (Android only; false
|
||||
* elsewhere). Android has no curl, which is what every other platform
|
||||
|
||||
@@ -283,6 +283,34 @@ int w_tlsClose(lua_State *L)
|
||||
return 0;
|
||||
}
|
||||
|
||||
int w_updateShortcuts(lua_State *L)
|
||||
{
|
||||
if (!lua_istable(L, 1))
|
||||
return luaL_error(L, "Expected table of game version strings");
|
||||
|
||||
std::vector<std::string> versions;
|
||||
int len = (int) luax_objlen(L, 1);
|
||||
for (int i = 1; i <= len; ++i)
|
||||
{
|
||||
lua_rawgeti(L, 1, i);
|
||||
if (lua_isstring(L, -1))
|
||||
versions.push_back(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
luax_pushboolean(L, instance()->updateShortcuts(versions));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_getLaunchGame(lua_State *L)
|
||||
{
|
||||
std::string game = instance()->getLaunchGame();
|
||||
if (game.empty())
|
||||
lua_pushnil(L);
|
||||
else
|
||||
luax_pushstring(L, game);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static const luaL_Reg functions[] =
|
||||
{
|
||||
{ "getOS", w_getOS },
|
||||
@@ -297,6 +325,8 @@ static const luaL_Reg functions[] =
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "updateShortcuts", w_updateShortcuts },
|
||||
{ "getLaunchGame", w_getLaunchGame },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
{ "httpPost", w_httpPost },
|
||||
{ "httpRequest", w_httpRequest },
|
||||
|
||||
@@ -69,8 +69,11 @@ import android.os.Vibrator;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.*;
|
||||
import android.content.pm.ShortcutInfo;
|
||||
import android.content.pm.ShortcutManager;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.drawable.Icon;
|
||||
import android.view.*;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
@@ -159,6 +162,10 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
private static native void nativeAudioDeviceChanged();
|
||||
|
||||
private static native void nativeOnGameIntent(String game);
|
||||
|
||||
private static String initialGame = "";
|
||||
|
||||
private AudioManager.OnAudioFocusChangeListener audioFocusListener = null;
|
||||
private Object audioFocusRequest = null;
|
||||
private Object audioDeviceCallback = null;
|
||||
@@ -228,6 +235,10 @@ public class GameActivity extends SDLActivity {
|
||||
embed = getResources().getBoolean(R.bool.embed);
|
||||
needToCopyGameInArchive = embed;
|
||||
|
||||
Intent startIntent = getIntent();
|
||||
if (startIntent != null && startIntent.hasExtra("game")) {
|
||||
initialGame = startIntent.getStringExtra("game");
|
||||
}
|
||||
if (!embed) {
|
||||
Intent intent = getIntent();
|
||||
handleIntent(intent);
|
||||
@@ -261,6 +272,12 @@ public class GameActivity extends SDLActivity {
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
Log.d("GameActivity", "onNewIntent() with " + intent);
|
||||
if (intent != null && intent.hasExtra("game")) {
|
||||
String game = intent.getStringExtra("game");
|
||||
if (game != null && !game.isEmpty()) {
|
||||
nativeOnGameIntent(game);
|
||||
}
|
||||
}
|
||||
if (!embed) {
|
||||
handleIntent(intent);
|
||||
resetNative();
|
||||
@@ -673,6 +690,95 @@ public class GameActivity extends SDLActivity {
|
||||
return true; // unreachable, but keeps the JNI signature honest
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static String getLaunchGame() {
|
||||
return initialGame != null ? initialGame : "";
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean updateAppShortcuts(String[] readyVersions) {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (android.os.Build.VERSION.SDK_INT < 25) return false;
|
||||
try {
|
||||
Context context = self.getApplicationContext();
|
||||
ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class);
|
||||
if (shortcutManager == null) return false;
|
||||
|
||||
if (readyVersions == null || readyVersions.length == 0) {
|
||||
shortcutManager.removeAllDynamicShortcuts();
|
||||
return true;
|
||||
}
|
||||
|
||||
List<ShortcutInfo> shortcuts = new ArrayList<>();
|
||||
int maxShortcuts = Math.min(readyVersions.length, 4);
|
||||
|
||||
for (int i = 0; i < maxShortcuts; i++) {
|
||||
String ver = readyVersions[i];
|
||||
if (ver == null || ver.isEmpty()) continue;
|
||||
String lower = ver.toLowerCase();
|
||||
String shortLabel;
|
||||
String longLabel;
|
||||
int iconResId;
|
||||
|
||||
switch (lower) {
|
||||
case "red":
|
||||
shortLabel = "Play Red";
|
||||
longLabel = "Play Red";
|
||||
iconResId = context.getResources().getIdentifier("ic_shortcut_red", "drawable", context.getPackageName());
|
||||
break;
|
||||
case "blue":
|
||||
shortLabel = "Play Blue";
|
||||
longLabel = "Play Blue";
|
||||
iconResId = context.getResources().getIdentifier("ic_shortcut_blue", "drawable", context.getPackageName());
|
||||
break;
|
||||
case "yellow":
|
||||
shortLabel = "Play Yellow";
|
||||
longLabel = "Play Yellow";
|
||||
iconResId = context.getResources().getIdentifier("ic_shortcut_yellow", "drawable", context.getPackageName());
|
||||
break;
|
||||
case "gold":
|
||||
shortLabel = "Play Gold";
|
||||
longLabel = "Play Gold";
|
||||
iconResId = context.getResources().getIdentifier("ic_shortcut_gold", "drawable", context.getPackageName());
|
||||
break;
|
||||
default:
|
||||
String capitalized = lower.substring(0, 1).toUpperCase() + lower.substring(1);
|
||||
shortLabel = "Play " + capitalized;
|
||||
longLabel = "Play " + capitalized;
|
||||
iconResId = context.getResources().getIdentifier("ic_shortcut_" + lower, "drawable", context.getPackageName());
|
||||
break;
|
||||
}
|
||||
|
||||
if (iconResId == 0) {
|
||||
iconResId = context.getResources().getIdentifier("ic_launcher_foreground", "drawable", context.getPackageName());
|
||||
}
|
||||
|
||||
Intent intent = new Intent(context, GameActivity.class);
|
||||
intent.setAction(Intent.ACTION_VIEW);
|
||||
intent.putExtra("game", lower);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
|
||||
ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, "shortcut_" + lower)
|
||||
.setShortLabel(shortLabel)
|
||||
.setLongLabel(longLabel)
|
||||
.setIntent(intent);
|
||||
|
||||
if (iconResId != 0) {
|
||||
builder.setIcon(Icon.createWithResource(context, iconResId));
|
||||
}
|
||||
|
||||
shortcuts.add(builder.build());
|
||||
}
|
||||
|
||||
shortcutManager.setDynamicShortcuts(shortcuts);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not update shortcuts: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
|
||||
* and used by src/core/HostShell.lua. Android ships no curl binary, so
|
||||
|
||||
@@ -2527,11 +2527,7 @@ end
|
||||
|
||||
-- the HUD label drawn in place of the level for a statused mon
|
||||
function BattleState:statusLabel(mon)
|
||||
local record = Status.recordFor(self.data.statuses, mon.status)
|
||||
if record then
|
||||
return record.hudLabel or record.label or mon.status
|
||||
end
|
||||
return mon.status
|
||||
return Status.hudLabelFor(self.data.statuses, mon.status)
|
||||
end
|
||||
|
||||
-- the one accuracy roll (MoveHitTest), hooked as battle.accuracy
|
||||
|
||||
@@ -54,6 +54,13 @@ end
|
||||
-- freeze the English. They are already translatable through the
|
||||
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
|
||||
--
|
||||
-- Do not add a matching hudLabel = "..." below: Status.hudLabelFor reads
|
||||
-- hudLabel before label, and Registry:patch only overrides the fields a
|
||||
-- mod actually passes, so a label-only translation patch would be
|
||||
-- shadowed by this hudLabel forever. Nothing in this codebase gives
|
||||
-- hudLabel a value different from label -- setting it here only recreates
|
||||
-- that trap for no observed benefit.
|
||||
--
|
||||
-- The five persistent conditions as records: the beforeMove gauntlet, the
|
||||
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
||||
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
||||
@@ -61,7 +68,7 @@ end
|
||||
-- read these fields, so a mod's sixth status plugs into every consumer.
|
||||
Status.RECORDS = {
|
||||
SLP = {
|
||||
id = "SLP", label = "SLP", hudLabel = "SLP",
|
||||
id = "SLP", label = "SLP",
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 40,
|
||||
beforeMove = function(battler, _, battle)
|
||||
@@ -82,7 +89,7 @@ Status.RECORDS = {
|
||||
end,
|
||||
},
|
||||
FRZ = {
|
||||
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
|
||||
id = "FRZ", label = "FRZ",
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 30,
|
||||
beforeMove = function(battler, _, battle)
|
||||
@@ -96,7 +103,7 @@ Status.RECORDS = {
|
||||
end,
|
||||
},
|
||||
PSN = {
|
||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
||||
id = "PSN", label = "PSN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
residual = damageOverTime("_HurtByPoisonText",
|
||||
Strings.source("%s's\nhurt by poison!")),
|
||||
@@ -112,7 +119,7 @@ Status.RECORDS = {
|
||||
end,
|
||||
},
|
||||
BRN = {
|
||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
||||
id = "BRN", label = "BRN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
statPenalty = { stat = "attack", div = 2 },
|
||||
residual = damageOverTime("_HurtByBurnText",
|
||||
@@ -124,7 +131,7 @@ Status.RECORDS = {
|
||||
end,
|
||||
},
|
||||
PAR = {
|
||||
id = "PAR", label = "PAR", hudLabel = "PAR",
|
||||
id = "PAR", label = "PAR",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
statPenalty = { stat = "speed", div = 4 },
|
||||
beforeMovePriority = 10,
|
||||
@@ -160,6 +167,14 @@ function Status.recordFor(statuses, id)
|
||||
return (statuses or Status.RECORDS)[id]
|
||||
end
|
||||
|
||||
-- the HUD label for a status id: a mod's patched hudLabel/label if the
|
||||
-- merged registry has one, the raw id otherwise (BattleState.statusLabel,
|
||||
-- SummaryMenu.draw and PartyMenu.draw all read this the same way)
|
||||
function Status.hudLabelFor(statuses, id)
|
||||
local record = Status.recordFor(statuses, id)
|
||||
return record and (record.hudLabel or record.label) or id
|
||||
end
|
||||
|
||||
local function battleStatuses(battle)
|
||||
return battle and battle.data and battle.data.statuses
|
||||
end
|
||||
|
||||
@@ -176,6 +176,7 @@ function Game:makeTitleState()
|
||||
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||
end
|
||||
end,
|
||||
onExit = self.onExit,
|
||||
})
|
||||
title.screenId = title.screenId or "TitleState"
|
||||
return title
|
||||
|
||||
@@ -320,6 +320,7 @@ function Game2:showMainMenu()
|
||||
onNewGame = function() self:newGame() end,
|
||||
onContinue = function(save) self:continueGame(save) end,
|
||||
onOption = function() self:showOptions(function() self:showMainMenu() end) end,
|
||||
onExit = self.onExit,
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local IssueReport = {}
|
||||
|
||||
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
|
||||
local TEMPLATE = "bug_report.yml"
|
||||
|
||||
local function clean(value)
|
||||
if value == nil then return nil end
|
||||
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if text == "" or text == "unknown" or text == "Unknown" then return nil end
|
||||
return text
|
||||
end
|
||||
|
||||
local function call(fn, ...)
|
||||
if type(fn) ~= "function" then return nil end
|
||||
local ok, a, b, c, d, e = pcall(fn, ...)
|
||||
if not ok then return nil end
|
||||
return a, b, c, d, e
|
||||
end
|
||||
|
||||
local function invoke(fn, ...)
|
||||
if type(fn) ~= "function" then return false end
|
||||
local ok, result = pcall(fn, ...)
|
||||
return ok, result
|
||||
end
|
||||
|
||||
local function commandValue(command)
|
||||
if not io or type(io.popen) ~= "function" then return nil end
|
||||
local ok, pipe = pcall(io.popen, command, "r")
|
||||
if not ok or not pipe then return nil end
|
||||
local readOK, value = pcall(pipe.read, pipe, "*l")
|
||||
pcall(pipe.close, pipe)
|
||||
if not readOK then return nil end
|
||||
return clean(value)
|
||||
end
|
||||
|
||||
local function percentEncode(value)
|
||||
local text = tostring(value or "")
|
||||
return (text:gsub("([^%w%-_%.~])", function(char)
|
||||
return ("%%%02X"):format(char:byte())
|
||||
end))
|
||||
end
|
||||
|
||||
local function formOS(raw)
|
||||
local values = {
|
||||
["OS X"] = "macOS",
|
||||
macOS = "macOS",
|
||||
Windows = "Windows",
|
||||
Linux = "Linux",
|
||||
Android = "Android",
|
||||
iOS = "iOS",
|
||||
NX = "Nintendo Switch",
|
||||
UWP = "Xbox",
|
||||
Xbox = "Xbox",
|
||||
}
|
||||
return values[raw] or ""
|
||||
end
|
||||
|
||||
local function loveVersion()
|
||||
local major, minor, revision, codename = call(love and love.getVersion)
|
||||
if not major then return "" end
|
||||
local result = tostring(major) .. "." .. tostring(minor) .. "." .. tostring(revision)
|
||||
if codename and codename ~= "" then result = result .. " (" .. tostring(codename) .. ")" end
|
||||
return result
|
||||
end
|
||||
|
||||
local function appVersion()
|
||||
local version = clean(Version.engine)
|
||||
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
|
||||
return version
|
||||
end
|
||||
|
||||
local function deviceModel(rawOS, system)
|
||||
local model = clean(call(system.getModel))
|
||||
if model then return model end
|
||||
if rawOS == "OS X" or rawOS == "macOS" then
|
||||
return commandValue("sysctl -n hw.model 2>/dev/null")
|
||||
end
|
||||
if rawOS == "Windows" then
|
||||
return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL")
|
||||
end
|
||||
if rawOS == "Linux" then
|
||||
return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
|
||||
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null")
|
||||
end
|
||||
if rawOS == "Android" then
|
||||
return commandValue("getprop ro.product.model 2>/dev/null")
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function modRows(context)
|
||||
if context and type(context.mods) == "table" then return context.mods end
|
||||
local ok, LauncherMods = pcall(require, "src.mods.LauncherMods")
|
||||
if ok and LauncherMods and LauncherMods.list then
|
||||
local listed = call(LauncherMods.list)
|
||||
if type(listed) == "table" then return listed end
|
||||
end
|
||||
return {}
|
||||
end
|
||||
|
||||
local function modNames(rows, safeMode)
|
||||
local enabled = {}
|
||||
for _, mod in ipairs(rows or {}) do
|
||||
if type(mod) == "table" then
|
||||
local name = clean(mod.name or mod.id)
|
||||
if name and not safeMode and mod.enabled == true then
|
||||
enabled[#enabled + 1] = name
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(enabled)
|
||||
return enabled
|
||||
end
|
||||
|
||||
local function metadata(options, context)
|
||||
local system = love and love.system or {}
|
||||
local graphics = love and love.graphics or {}
|
||||
local window = love and love.window or {}
|
||||
local rawOS = clean(call(system.getOS))
|
||||
local model = deviceModel(rawOS, system)
|
||||
local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo)
|
||||
local width, height = call(graphics.getDimensions)
|
||||
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
|
||||
local modeWidth, modeHeight, flags = call(window.getMode)
|
||||
local safeMode = SaveData.isSafeMode(options)
|
||||
local rows = modRows(context or {})
|
||||
local enabledMods = modNames(rows, safeMode)
|
||||
local lines = { "Diagnostics:" }
|
||||
local function add(label, value)
|
||||
value = clean(value)
|
||||
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
|
||||
end
|
||||
add("Platform", formOS(rawOS))
|
||||
local hardware = model
|
||||
if rendererDevice and rendererDevice ~= model then
|
||||
hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice
|
||||
end
|
||||
add("Device", hardware)
|
||||
local rendererDetails = clean(renderer)
|
||||
if rendererDetails and clean(rendererVersion) then
|
||||
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
|
||||
end
|
||||
add("Renderer", rendererDetails)
|
||||
local displayWidth, displayHeight = width or modeWidth or pixelWidth, height or modeHeight or pixelHeight
|
||||
if displayWidth and displayHeight then
|
||||
add("Display", tostring(displayWidth) .. "x" .. tostring(displayHeight))
|
||||
end
|
||||
if pixelWidth and pixelHeight
|
||||
and (pixelWidth ~= displayWidth or pixelHeight ~= displayHeight) then
|
||||
add("Pixel display", tostring(pixelWidth) .. "x" .. tostring(pixelHeight))
|
||||
end
|
||||
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
|
||||
local version = appVersion()
|
||||
add("App", version ~= "" and Version.title() or "gen1recomp")
|
||||
add("LÖVE", loveVersion())
|
||||
if safeMode then add("Safe mode", "on") end
|
||||
return {
|
||||
rawOS = rawOS,
|
||||
os = formOS(rawOS),
|
||||
device = model,
|
||||
version = version,
|
||||
safeMode = safeMode,
|
||||
enabledMods = enabledMods,
|
||||
metadata = table.concat(lines, "\n"),
|
||||
}
|
||||
end
|
||||
|
||||
function IssueReport.build(options, context)
|
||||
options = options or SaveData.loadOptions()
|
||||
context = context or {}
|
||||
local info = metadata(options, context)
|
||||
local fields = {
|
||||
summary = "",
|
||||
mods_which = #info.enabledMods > 0 and table.concat(info.enabledMods, ", ") or "",
|
||||
version = info.version or "",
|
||||
location = "",
|
||||
screenshot = "",
|
||||
steps = "",
|
||||
expected = "",
|
||||
extra = info.metadata,
|
||||
}
|
||||
local params = {
|
||||
"template=" .. percentEncode(TEMPLATE),
|
||||
"title=" .. percentEncode("bug: replace this with a meaningful title"),
|
||||
}
|
||||
local order = { "summary", "mods_which",
|
||||
"version", "location", "screenshot", "steps", "expected", "extra" }
|
||||
for _, key in ipairs(order) do
|
||||
params[#params + 1] = key .. "=" .. percentEncode(fields[key])
|
||||
end
|
||||
return FORM_URL .. "?" .. table.concat(params, "&"), fields, info
|
||||
end
|
||||
|
||||
function IssueReport.open(options, context)
|
||||
local url = IssueReport.build(options, context)
|
||||
local system = love and love.system or {}
|
||||
local opened, openResult = invoke(system.openURL, url)
|
||||
if opened and openResult ~= false then
|
||||
return true, url
|
||||
end
|
||||
local copied, copyResult = invoke(system.setClipboardText, url)
|
||||
if copied and copyResult ~= false then
|
||||
return true, url, "Issue URL copied to the clipboard."
|
||||
end
|
||||
local filesystem = love and love.filesystem or {}
|
||||
local written, writeResult = invoke(filesystem.write, "issue-report-url.txt", url)
|
||||
if written and writeResult ~= false then
|
||||
return true, url, "Issue URL saved to issue-report-url.txt."
|
||||
end
|
||||
return false, url, "No browser, clipboard, or writable save directory is available for the issue report."
|
||||
end
|
||||
|
||||
IssueReport.percentEncode = percentEncode
|
||||
IssueReport.metadata = metadata
|
||||
|
||||
return IssueReport
|
||||
@@ -67,10 +67,23 @@ local function argFlag(argv, name)
|
||||
return false
|
||||
end
|
||||
|
||||
local cachedIntentGame = nil
|
||||
|
||||
-- Returns version, slotId (either may be nil). Command line wins over env,
|
||||
-- so a shortcut can override a machine-wide default.
|
||||
function LaunchOptions.resolve(argv)
|
||||
if cachedIntentGame == nil then
|
||||
if love.system and love.system.getOS and love.system.getOS() == "Android"
|
||||
and love.system.getLaunchGame then
|
||||
cachedIntentGame = normalizeVersion(love.system.getLaunchGame()) or false
|
||||
else
|
||||
cachedIntentGame = false
|
||||
end
|
||||
end
|
||||
local intentGame = cachedIntentGame or nil
|
||||
|
||||
local game = normalizeVersion(argValue(argv, "game"))
|
||||
or intentGame
|
||||
or normalizeVersion(os.getenv("POKEPORT_GAME"))
|
||||
or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
|
||||
local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT")
|
||||
|
||||
@@ -300,6 +300,7 @@ function SaveData.defaultOptions()
|
||||
-- Native mod enablement is an installation option, not save-slot data.
|
||||
-- Missing entries mean enabled so newly installed mods work by default.
|
||||
mods = {},
|
||||
safeMode = false,
|
||||
-- Mods the player forced past the target gate (Loader:_gateGeneration).
|
||||
-- modsGen2[id][version] = true, one answer per game; a bare `true` is the
|
||||
-- pre-per-game shape and means the Gen 2 games only (see modForced).
|
||||
@@ -386,6 +387,16 @@ function SaveData.mergeOptions(loaded)
|
||||
return opts
|
||||
end
|
||||
|
||||
function SaveData.isSafeMode(options)
|
||||
return type(options) == "table" and options.safeMode == true
|
||||
end
|
||||
|
||||
function SaveData.setSafeMode(options, enabled)
|
||||
if type(options) ~= "table" then return false end
|
||||
options.safeMode = enabled == true
|
||||
return options.safeMode
|
||||
end
|
||||
|
||||
function SaveData.encode(data)
|
||||
return SaveSerializer.encode(data)
|
||||
end
|
||||
|
||||
@@ -422,7 +422,7 @@ local function discoverModSchemas(opts)
|
||||
-- except experimental mods, which stay off until opted in.
|
||||
local flag = require("src.core.SaveData").modEnabled(opts, m.id)
|
||||
local enabled = flag == true or (flag == nil and not m.experimental)
|
||||
if enabled then
|
||||
if enabled and not SaveData.isSafeMode(opts) then
|
||||
local chunk = fs.load(path .. "/" .. m.options_schema)
|
||||
if chunk then
|
||||
local okR, schema = pcall(chunk)
|
||||
@@ -521,9 +521,49 @@ local function modRows(opts, mod)
|
||||
return true
|
||||
end }
|
||||
end
|
||||
for _, row in ipairs(rows) do
|
||||
row.safeModeBlocked = true
|
||||
if row.step then
|
||||
local step = row.step
|
||||
row.step = function(dir)
|
||||
if SaveData.isSafeMode(opts) then return false end
|
||||
return step(dir)
|
||||
end
|
||||
end
|
||||
if row.setText then
|
||||
local setText = row.setText
|
||||
row.setText = function(text)
|
||||
if SaveData.isSafeMode(opts) then return false end
|
||||
return setText(text)
|
||||
end
|
||||
end
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function troubleshootingRows(opts, hooks)
|
||||
return {
|
||||
{
|
||||
label = Strings("SAFE MODE"),
|
||||
actionLabel = function()
|
||||
return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on")
|
||||
end,
|
||||
action = function()
|
||||
SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts))
|
||||
return true
|
||||
end,
|
||||
},
|
||||
{
|
||||
label = Strings("REPORT ISSUE"),
|
||||
actionLabel = Strings("Report bug"),
|
||||
action = function()
|
||||
if hooks and hooks.reportIssue then hooks.reportIssue(opts) end
|
||||
return false
|
||||
end,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- ------- Gen 2 (Gold)
|
||||
--
|
||||
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
|
||||
@@ -704,6 +744,10 @@ function LauncherSettings.open(hooks, version)
|
||||
sections[#sections + 1] = { title = mod.name, rows = rows }
|
||||
end
|
||||
end
|
||||
sections[#sections + 1] = {
|
||||
title = Strings("TROUBLESHOOTING"),
|
||||
rows = troubleshootingRows(opts, hooks),
|
||||
}
|
||||
return {
|
||||
opts = opts,
|
||||
version = version,
|
||||
|
||||
@@ -682,6 +682,7 @@ end
|
||||
|
||||
local function modStatusColor(status)
|
||||
if status == "ok" then return Strings("Ready"), PAL.green end
|
||||
if status == "safe_mode" then return Strings("Safe mode"), PAL.yellow end
|
||||
if status == "needs_import" then return Strings("Import required"), PAL.yellow end
|
||||
if status == "conflict" then return Strings("Conflict"), PAL.red end
|
||||
-- not a fault: the mod is intact, this is simply not a game it is for
|
||||
@@ -1814,10 +1815,11 @@ end
|
||||
|
||||
-- One compact coloured checkbox for each game. The cartridge colour carries
|
||||
-- the game identity even when the row is narrow.
|
||||
local function modGameCheckbox(x, y, size, checked, game, id)
|
||||
local color = cartColor(game)
|
||||
local focused = Kit.focusable(id, x, y, size, size)
|
||||
local hot = focused or Kit.hover(x, y, size, size)
|
||||
local function modGameCheckbox(x, y, size, checked, game, id, enabled)
|
||||
enabled = enabled ~= false
|
||||
local color = enabled and cartColor(game) or PAL.steel
|
||||
local focused = enabled and Kit.focusable(id, x, y, size, size)
|
||||
local hot = enabled and (focused or Kit.hover(x, y, size, size))
|
||||
if love.graphics then
|
||||
Theme.fillRounded(x, y, size, size, PAL.bg, 1)
|
||||
if checked then
|
||||
@@ -1829,12 +1831,13 @@ local function modGameCheckbox(x, y, size, checked, game, id)
|
||||
hot and Theme.A.focus or Theme.A.hairline, 1)
|
||||
end
|
||||
end
|
||||
return Kit.press(x, y, size, size) or Kit._activateId == id
|
||||
return enabled and (Kit.press(x, y, size, size) or Kit._activateId == id)
|
||||
end
|
||||
|
||||
local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
imp:_ensureMods()
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local safeMode = imp.safeMode == true
|
||||
local mods = imp.mods or {}
|
||||
local gap = m.gap
|
||||
local cy = y
|
||||
@@ -1866,9 +1869,11 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
action = function() imp:chooseMod() end })
|
||||
btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), {
|
||||
kind = "warn", font = "small",
|
||||
enabled = not safeMode,
|
||||
action = function() imp:_setAllMods(false) end })
|
||||
btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), {
|
||||
kind = "good", font = "small",
|
||||
enabled = not safeMode,
|
||||
action = function() imp:_setAllMods(true) end })
|
||||
btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), {
|
||||
font = "small",
|
||||
@@ -1917,7 +1922,9 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
|
||||
-- notice line
|
||||
local noticeText, noticeCol
|
||||
if imp.modNotice then
|
||||
if safeMode then
|
||||
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in Settings to change mod toggles.", PAL.yellow
|
||||
elseif imp.modNotice then
|
||||
noticeText = imp.modNotice.text
|
||||
noticeCol = imp.modNotice.ok and PAL.green or PAL.red
|
||||
else
|
||||
@@ -2043,7 +2050,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
|
||||
if modGameCheckbox(tx, gamesY, togH,
|
||||
mod.enabledByVersion and mod.enabledByVersion[game] == true,
|
||||
game, togKey) then
|
||||
game, togKey, not safeMode) then
|
||||
local version = game
|
||||
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
|
||||
flipped = true
|
||||
@@ -3079,6 +3086,7 @@ local function buildProfilesModal(imp, m)
|
||||
btn(imp, place(swBtnW), ry + math.floor(4 * m.s), swBtnW, rowH - math.floor(8 * m.s), "prof-sw-" .. i,
|
||||
Strings("Switch"), {
|
||||
kind = "good", font = "micro",
|
||||
enabled = not imp.safeMode,
|
||||
action = function()
|
||||
LauncherMods.applyProfile(p.name, options)
|
||||
if imp._refreshMods then imp:_refreshMods() end
|
||||
@@ -3105,8 +3113,10 @@ local function buildModHeaderActionsModal(imp, m)
|
||||
local btns = {
|
||||
{ label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end },
|
||||
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
|
||||
{ label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end },
|
||||
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end },
|
||||
{ label = Strings("Enable all mods"), kind = "good", enabled = not imp.safeMode,
|
||||
action = function() imp:_setAllMods(true) end },
|
||||
{ label = Strings("Disable all mods"), kind = "warn", enabled = not imp.safeMode,
|
||||
action = function() imp:_setAllMods(false) end },
|
||||
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end },
|
||||
}
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
@@ -3119,6 +3129,7 @@ local function buildModHeaderActionsModal(imp, m)
|
||||
for i, b in ipairs(btns) do
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, {
|
||||
kind = b.kind or "ghost", font = "small",
|
||||
enabled = b.enabled,
|
||||
action = function()
|
||||
imp._modHeaderActionsPopup = nil
|
||||
b.action()
|
||||
@@ -3737,6 +3748,7 @@ end
|
||||
|
||||
local function buildSettingsModal(imp, m)
|
||||
local model = imp._settings
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(640 * m.s)
|
||||
local h = math.floor(math.min(m.H - 2 * m.pad, m.H * 0.9))
|
||||
@@ -3826,6 +3838,8 @@ local function buildSettingsModal(imp, m)
|
||||
item.header)
|
||||
else
|
||||
local row = item.row
|
||||
local rowEnabled = not row.safeModeBlocked
|
||||
or not SaveData.isSafeMode(model.opts)
|
||||
local key = "set-" .. i
|
||||
Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline")
|
||||
local ix = px + pad + math.floor(12 * m.s)
|
||||
@@ -3855,6 +3869,7 @@ local function buildSettingsModal(imp, m)
|
||||
ctlY + (m.btnH - Kit.textHeight("small")) / 2, PAL.detail)
|
||||
btn(imp, rx - ew, ctlY, ew, m.btnH,
|
||||
key .. "-edit", Strings("Edit"), { kind = "accent", font = "small",
|
||||
enabled = rowEnabled,
|
||||
action = function()
|
||||
imp._settingsText = { row = row, text = tostring(row.value() or ""),
|
||||
maxLen = row.editText.maxLen }
|
||||
@@ -3863,12 +3878,14 @@ local function buildSettingsModal(imp, m)
|
||||
elseif row.action then
|
||||
-- A plain action row (Reset rebinds, Touch controls): the whole right
|
||||
-- side is one button rather than a value ladder.
|
||||
local aw = Kit.textWidth("small", row.actionLabel or Strings("Run"))
|
||||
local actionLabel = type(row.actionLabel) == "function"
|
||||
and row.actionLabel() or row.actionLabel or Strings("Run")
|
||||
local aw = Kit.textWidth("small", actionLabel)
|
||||
+ math.floor(24 * m.s)
|
||||
Kit.text("small", Kit.ellipsize("small", row.label,
|
||||
labelW or (inner - aw - math.floor(12 * m.s))), ix, labelY, PAL.text)
|
||||
btn(imp, rx - aw, ctlY, aw, m.btnH,
|
||||
key .. "-act", row.actionLabel or Strings("Run"), {
|
||||
key .. "-act", actionLabel, {
|
||||
kind = row.danger and "danger" or "ghost", font = "small",
|
||||
action = function()
|
||||
if row.action() ~= false then model.save() end
|
||||
@@ -3884,12 +3901,14 @@ local function buildSettingsModal(imp, m)
|
||||
or valW
|
||||
btn(imp, rx - stepW, ctlY, stepW, m.btnH,
|
||||
key .. "-next", ">", { font = "small",
|
||||
enabled = rowEnabled,
|
||||
action = function() if row.step and row.step(1) then model.save() end end })
|
||||
Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), vw),
|
||||
rx - stepW - vw, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw,
|
||||
PAL.heading)
|
||||
btn(imp, rx - stepW - vw - stepW, ctlY, stepW,
|
||||
m.btnH, key .. "-prev", "<", { font = "small",
|
||||
enabled = rowEnabled,
|
||||
action = function() if row.step and row.step(-1) then model.save() end end })
|
||||
end
|
||||
end
|
||||
@@ -4046,6 +4065,7 @@ local function buildDepResolverModal(imp, m)
|
||||
local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s)
|
||||
btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, {
|
||||
kind = "warn", font = "small",
|
||||
enabled = not imp.safeMode,
|
||||
action = function()
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.setEnabled(dep.id, false, imp.modScope)
|
||||
|
||||
@@ -136,6 +136,9 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
||||
-- costs nothing on a current cache and is the difference between every
|
||||
-- trainer battle opening with a picture and opening with none.
|
||||
"assets/generated/battle/trainers/falkner.png",
|
||||
-- BattleStart_TrainerHuds cannot draw its party rows from a cache made
|
||||
-- before the four ball tiles were extracted (#1502).
|
||||
"assets/generated/battle/hud/balls.png",
|
||||
"assets/generated/audio/programs.bin",
|
||||
},
|
||||
}
|
||||
@@ -326,6 +329,32 @@ function RomImporter.isReady(version)
|
||||
return marker == markerFor(version) and allRequiredFilesExist(version)
|
||||
end
|
||||
|
||||
function RomImporter.syncAndroidShortcuts(activeVersion)
|
||||
if not (love.system and love.system.getOS and love.system.getOS() == "Android"
|
||||
and love.system.updateShortcuts) then
|
||||
return false
|
||||
end
|
||||
|
||||
local allVersions = { "red", "blue", "yellow", "gold" }
|
||||
local ready = {}
|
||||
local seen = {}
|
||||
|
||||
if activeVersion and RomImporter.isReady(activeVersion) then
|
||||
table.insert(ready, activeVersion)
|
||||
seen[activeVersion] = true
|
||||
end
|
||||
|
||||
for _, v in ipairs(allVersions) do
|
||||
if not seen[v] and RomImporter.isReady(v) then
|
||||
table.insert(ready, v)
|
||||
seen[v] = true
|
||||
if #ready >= 4 then break end
|
||||
end
|
||||
end
|
||||
|
||||
return love.system.updateShortcuts(ready)
|
||||
end
|
||||
|
||||
-- Load the import manifest for a version and confirm it matches that ROM.
|
||||
local function sha1(data)
|
||||
local digest = love.data.hash("sha1", data)
|
||||
@@ -1393,6 +1422,7 @@ function RomImporter.new(onComplete, opts)
|
||||
self.romName[version] = "pokemon_" .. info.id
|
||||
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
|
||||
end
|
||||
RomImporter.syncAndroidShortcuts()
|
||||
self:_applyLastVersionTab()
|
||||
self:_queueBaseRomScan()
|
||||
|
||||
@@ -1787,6 +1817,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
|
||||
self.workState = "complete"
|
||||
self.completeVersion = version
|
||||
self.status = "Ready"
|
||||
RomImporter.syncAndroidShortcuts(version)
|
||||
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
||||
-- overwriting it with a "Starting…" line that never boots from here.
|
||||
if self.launcher and self.isNX and type(displayName) == "string" then
|
||||
@@ -3598,6 +3629,10 @@ function RomImporter:_openSettings()
|
||||
-- The tab rides along: the editor persists the layout into that game's own
|
||||
-- option block, and Gold's is not the flat Gen 1 one (#1100).
|
||||
local hooks = {}
|
||||
local version = self.tab
|
||||
hooks.reportIssue = function(opts)
|
||||
return self:_reportIssue(opts, version)
|
||||
end
|
||||
if self.onEditTouchControls then
|
||||
local version = self.tab
|
||||
hooks.editTouchControls = function()
|
||||
@@ -3615,11 +3650,13 @@ function RomImporter:_openSettings()
|
||||
-- The tab the gear was opened on decides the row set: Gold reads a
|
||||
-- different option block entirely, and offering it Gen 1's rows meant a
|
||||
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
|
||||
local version = self.tab
|
||||
local ok, model = pcall(function()
|
||||
return require("src.import.LauncherSettings").open(hooks, version)
|
||||
end)
|
||||
if ok and model then self._settings = model end
|
||||
if ok and model then
|
||||
self._settings = model
|
||||
self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's
|
||||
@@ -3630,8 +3667,38 @@ function RomImporter:_quitApp()
|
||||
end
|
||||
|
||||
function RomImporter:_closeSettings()
|
||||
if self._settings then self._settings.save() end
|
||||
local model = self._settings
|
||||
if model then
|
||||
model.save()
|
||||
local safeMode = require("src.core.SaveData").isSafeMode(model.opts)
|
||||
if safeMode ~= self._settingsSafeModeAtOpen then
|
||||
self.mods = nil
|
||||
self.safeMode = safeMode
|
||||
self._modSortCache = nil
|
||||
self._modInfoFetch = nil
|
||||
end
|
||||
end
|
||||
self._settings = nil
|
||||
self._settingsSafeModeAtOpen = nil
|
||||
end
|
||||
|
||||
function RomImporter:_reportIssue(options, version)
|
||||
local ok, IssueReport = pcall(require, "src.core.IssueReport")
|
||||
if not ok then
|
||||
self.modNotice = { ok = false, text = "Could not prepare the issue report." }
|
||||
return false
|
||||
end
|
||||
local opened, url, reason = IssueReport.open(options, {
|
||||
version = version,
|
||||
mods = self.mods,
|
||||
})
|
||||
if not opened then
|
||||
self.modNotice = { ok = false, text = reason or "Could not open the issue report." }
|
||||
return false
|
||||
end
|
||||
self._lastIssueReportURL = url
|
||||
if reason then self.modNotice = { ok = true, text = reason } end
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_commitSettingsText()
|
||||
@@ -3971,6 +4038,8 @@ end
|
||||
-- so a still list costs nothing after the first paint.
|
||||
function RomImporter:_refreshMods()
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
|
||||
-- Once per session, ahead of the first listing: pull in any mod the player
|
||||
-- unzipped beside the executable, which an ordinary (non-portable) install
|
||||
-- has no way to read. It happens here rather than behind a button because
|
||||
@@ -4110,6 +4179,10 @@ end
|
||||
-- so that game's checkbox and status chips reflect the new resolution.
|
||||
-- Enabling an experimental mod arms a confirmation for that same game.
|
||||
function RomImporter:_toggleMod(id, confirmed, version)
|
||||
if self.safeMode then
|
||||
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
|
||||
return
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local cur, experimental = false, false
|
||||
for _, m in ipairs(self.mods or {}) do
|
||||
@@ -4150,6 +4223,10 @@ end
|
||||
-- must not be the way around it. Disabling needs no confirm -- it is the
|
||||
-- recovery action, and Delete is the only destructive one on this panel.
|
||||
function RomImporter:_setAllMods(want, confirmed)
|
||||
if self.safeMode then
|
||||
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
|
||||
return
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local ids, experimental = {}, false
|
||||
for _, m in ipairs(self.mods or {}) do
|
||||
|
||||
@@ -291,6 +291,7 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
local ordered = {}
|
||||
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
|
||||
table.sort(ordered, function(a, b) return a.id < b.id end)
|
||||
local safeMode = SaveData.isSafeMode(options)
|
||||
|
||||
-- the override is one answer per game (SaveData.modForced), the same scope
|
||||
-- the loader resolves it under
|
||||
@@ -304,17 +305,24 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
-- matching the loader -- except experimental mods, which stay off until
|
||||
-- the player opts in. Scoped through modScope, so this reads exactly what
|
||||
-- setEnabled writes and the loader loads for the selected game.
|
||||
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
|
||||
if decided == nil then decided = not m.experimental end
|
||||
if decided then enabledSet[m.id] = true end
|
||||
if not safeMode then
|
||||
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
|
||||
if decided == nil then decided = not m.experimental end
|
||||
if decided then enabledSet[m.id] = true end
|
||||
end
|
||||
end
|
||||
|
||||
local out = {}
|
||||
for _, m in ipairs(ordered) do
|
||||
local enabled = enabledSet[m.id] == true
|
||||
local enabled = not safeMode and enabledSet[m.id] == true
|
||||
local forced = forcedFor(m.id)
|
||||
local status, detail =
|
||||
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
|
||||
local status, detail
|
||||
if safeMode then
|
||||
status, detail = "safe_mode", "Disabled by safe mode"
|
||||
else
|
||||
status, detail =
|
||||
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
|
||||
end
|
||||
-- nil, not false, when the panel is showing every game at once
|
||||
local here = nil
|
||||
if version then here = ModTargets.runsHere(m, version, nil, forced) end
|
||||
@@ -332,7 +340,8 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
local answers = {}
|
||||
for _, game in ipairs(GameVersion.ORDER) do
|
||||
local answer = SaveData.modEnabled(options, m.id, game)
|
||||
answers[game] = answer == true or (answer == nil and not m.experimental)
|
||||
answers[game] = not safeMode
|
||||
and (answer == true or (answer == nil and not m.experimental))
|
||||
end
|
||||
return answers
|
||||
end)(),
|
||||
@@ -346,6 +355,7 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
-- panel is showing (src/mods/ModTargets.lua)
|
||||
targets = ModTargets.chip(m),
|
||||
targetsHere = here,
|
||||
safeMode = safeMode,
|
||||
}
|
||||
end
|
||||
return out
|
||||
@@ -586,6 +596,7 @@ end
|
||||
-- answer. The loader and the in-game manager use the same scope on next boot.
|
||||
function LauncherMods.setEnabled(id, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
if SaveData.isSafeMode(options) then return false end
|
||||
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
@@ -599,6 +610,7 @@ end
|
||||
-- and leaves a half-applied state behind if one of them fails.
|
||||
function LauncherMods.setAllEnabled(ids, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
if SaveData.isSafeMode(options) then return false end
|
||||
local scope = SaveData.modScope(version)
|
||||
for _, id in ipairs(ids or {}) do
|
||||
if scope then
|
||||
@@ -1184,6 +1196,7 @@ end
|
||||
|
||||
function LauncherMods.applyProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
if SaveData.isSafeMode(options) then return false end
|
||||
local profiles = options.modProfiles or {}
|
||||
local targetProfile
|
||||
for _, p in ipairs(profiles) do
|
||||
|
||||
@@ -259,6 +259,7 @@ function Loader.new(opts)
|
||||
modInput = {}, modEnv = {}, stepsQueues = {},
|
||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||
dev = dev,
|
||||
safeMode = false,
|
||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||
-- active version is set once in main.lua's bootGame before anything
|
||||
-- builds a loader, and a run never changes generation underneath one.
|
||||
@@ -300,6 +301,8 @@ end
|
||||
function Loader:_loadState()
|
||||
self.disabled = {}
|
||||
local options = SaveData.loadOptions(self.fs)
|
||||
self.safeMode = SaveData.isSafeMode(options)
|
||||
Runtime.safeMode = self.safeMode
|
||||
local scope = self:_enableScope()
|
||||
local ids = {}
|
||||
for id in pairs(options.mods or {}) do ids[id] = true end
|
||||
@@ -415,6 +418,7 @@ function Loader:_writeOptionSchemas()
|
||||
end
|
||||
|
||||
function Loader:setEnabled(id, enabled)
|
||||
if self.safeMode then return false end
|
||||
if not self.mods[id] then return false end
|
||||
self.disabled[id] = not enabled
|
||||
self.mods[id].enabled = enabled
|
||||
@@ -427,6 +431,7 @@ end
|
||||
-- choice could not be persisted for a game, so the caller does not promise a
|
||||
-- restart will honour it.
|
||||
function Loader:setGen2Forced(id, forced)
|
||||
if self.safeMode then return false, false end
|
||||
if not self.mods[id] then return false, false end
|
||||
self.gen2Forced[id] = forced or nil
|
||||
self:_saveState()
|
||||
@@ -1539,6 +1544,9 @@ function Loader:load(data)
|
||||
require("src.mods.Builtins").install(self.content, data, self.generation)
|
||||
self:_loadState()
|
||||
self:_discover()
|
||||
if self.safeMode then
|
||||
for id in pairs(self.mods) do self.disabled[id] = true end
|
||||
end
|
||||
-- Existing installs stored one shared answer. Once their manifests are
|
||||
-- known, split that answer across every game before the next launcher/game
|
||||
-- toggle can change one independently. _loadState already used the same
|
||||
@@ -1574,7 +1582,7 @@ function Loader:load(data)
|
||||
-- the one build where its env var is set.
|
||||
for id, mod in pairs(self.mods) do
|
||||
local envName = mod.manifest.force_enable_env
|
||||
if envName and os.getenv(envName) == "1" then
|
||||
if not self.safeMode and envName and os.getenv(envName) == "1" then
|
||||
self.disabled[id] = nil
|
||||
end
|
||||
end
|
||||
@@ -1740,6 +1748,7 @@ function Loader:status()
|
||||
local manifest = {}
|
||||
for key, value in pairs(mod.manifest) do manifest[key] = value end
|
||||
manifest.enabled = mod.enabled ~= false
|
||||
manifest.safeMode = self.safeMode == true
|
||||
manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled")
|
||||
manifest.error = mod.failure
|
||||
-- set instead of `error` when the mod was left out for a reason that is
|
||||
|
||||
@@ -365,9 +365,13 @@ end
|
||||
|
||||
function ManagerState:detailRows(m)
|
||||
local rows = {}
|
||||
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
||||
action = function() self:beginToggle(m) end }
|
||||
if self:schemaFor(m) then
|
||||
if Runtime.safeMode then
|
||||
rows[#rows + 1] = { label = "SAFE MODE ACTIVE", inert = true }
|
||||
else
|
||||
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
||||
action = function() self:beginToggle(m) end }
|
||||
end
|
||||
if not Runtime.safeMode and self:schemaFor(m) then
|
||||
rows[#rows + 1] = { label = Strings("OPTIONS.."),
|
||||
action = function() self:openOptions(m) end }
|
||||
end
|
||||
@@ -383,7 +387,8 @@ function ManagerState:detailRows(m)
|
||||
-- what this mod does.
|
||||
local loader = self.game.mods
|
||||
local version, gen = self:targetGame()
|
||||
if loader and loader.setGen2Forced and not ModTargets.supports(m, version, gen) then
|
||||
if loader and loader.setGen2Forced and not Runtime.safeMode
|
||||
and not ModTargets.supports(m, version, gen) then
|
||||
rows[#rows + 1] = {
|
||||
label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"),
|
||||
action = function() self:toggleGen2Force(m) end }
|
||||
@@ -674,6 +679,10 @@ end
|
||||
-- ------- the enable/disable flow
|
||||
|
||||
function ManagerState:beginToggle(m)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return
|
||||
end
|
||||
if not m then return end
|
||||
local want = not m.enabled
|
||||
local loader = self.game.mods
|
||||
@@ -711,6 +720,10 @@ end
|
||||
-- override is scoped to THIS game, and a boot that cannot name one keeps it in
|
||||
-- memory only, which the notice says rather than promising a restart.
|
||||
function ManagerState:toggleGen2Force(m)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return
|
||||
end
|
||||
local loader = self.game.mods
|
||||
if not (loader and loader.setGen2Forced) then return end
|
||||
local want = not m.gen2Forced
|
||||
@@ -743,6 +756,10 @@ function ManagerState:enableScope()
|
||||
end
|
||||
|
||||
function ManagerState:commitToggle(apply)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return
|
||||
end
|
||||
local loader = self.game.mods
|
||||
local opts = self:optionsTable()
|
||||
local scope = self:enableScope()
|
||||
@@ -757,6 +774,10 @@ function ManagerState:commitToggle(apply)
|
||||
end
|
||||
|
||||
function ManagerState:discardChanges()
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return
|
||||
end
|
||||
local loader = self.game.mods
|
||||
local opts = self:optionsTable()
|
||||
local scope = self:enableScope()
|
||||
@@ -802,6 +823,10 @@ function ManagerState:persistOptions()
|
||||
end
|
||||
|
||||
function ManagerState:applyProfile(p)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return
|
||||
end
|
||||
local mods = self:manifestMap()
|
||||
local set = self:enabledSet()
|
||||
local combined = {}
|
||||
@@ -963,6 +988,10 @@ function ManagerState:optionValue(modId, row)
|
||||
end
|
||||
|
||||
function ManagerState:setOption(modId, key, value)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return false
|
||||
end
|
||||
local save = self.game.save
|
||||
if save and save.options then
|
||||
save.options.modOptions = save.options.modOptions or {}
|
||||
@@ -1123,6 +1152,10 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
end
|
||||
|
||||
function ManagerState:openOptions(m)
|
||||
if Runtime.safeMode then
|
||||
self:notify("SAFE MODE ACTIVE")
|
||||
return
|
||||
end
|
||||
local schema = self:schemaFor(m)
|
||||
if not schema then
|
||||
self:notify("NO OPTIONS")
|
||||
|
||||
@@ -33,11 +33,21 @@ Runtime.currentMod = nil
|
||||
-- currentMod went back to nil (src/mods/Sandbox.lua)
|
||||
Runtime.modRequire = nil
|
||||
|
||||
Runtime.safeMode = false
|
||||
|
||||
function Runtime.install(events, hooks, errors)
|
||||
Runtime.events, Runtime.hooks = events, hooks
|
||||
Runtime.errors = errors
|
||||
end
|
||||
|
||||
function Runtime.reset()
|
||||
Runtime.events = NullEvents
|
||||
Runtime.hooks = NullHooks
|
||||
Runtime.errors = nil
|
||||
Runtime.currentMod = nil
|
||||
Runtime.modRequire = nil
|
||||
end
|
||||
|
||||
-- attribute a runtime failure to the mod that owns the offending record.
|
||||
-- "base" is the engine's own owner id: a vanilla record that fails is a
|
||||
-- console line, not something the manager can ask the player to disable.
|
||||
|
||||
@@ -180,8 +180,8 @@ local function release(game)
|
||||
and mon.ot == game.save.player.name then
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
|
||||
:gsub("{RAM:wNameBuffer}", name)))
|
||||
((t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
|
||||
:gsub("{RAM:wNameBuffer}", name))))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
|
||||
@@ -18,6 +18,7 @@ local Theme = require("src.ui.Theme")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Map = require("src.world.Map")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Status = require("src.battle.Status")
|
||||
|
||||
local PartyMenu = {}
|
||||
PartyMenu.__index = PartyMenu
|
||||
@@ -821,7 +822,7 @@ function PartyMenu:draw()
|
||||
if mon.hp <= 0 then
|
||||
Font.draw(Strings("FNT"), 136, y)
|
||||
elseif mon.status then
|
||||
Font.draw(mon.status, 136, y)
|
||||
Font.draw(Status.hudLabelFor(self.game.data.statuses, mon.status), 136, y)
|
||||
end
|
||||
-- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill:
|
||||
-- tinting the fill AND running it through the row's zone
|
||||
|
||||
@@ -14,6 +14,7 @@ local Font = require("src.render.Font")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Status = require("src.battle.Status")
|
||||
|
||||
local SummaryMenu = {}
|
||||
SummaryMenu.__index = SummaryMenu
|
||||
@@ -145,7 +146,7 @@ function SummaryMenu:draw()
|
||||
HudTiles.drawHPBar(data, 11, 3, mon, 1, barZoned) -- wHPBarType 1
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
|
||||
Font.draw(Strings("STATUS/"), 72, 48)
|
||||
Font.draw(mon.status or "OK", 128, 48)
|
||||
Font.draw(Status.hudLabelFor(data.statuses, mon.status) or "OK", 128, 48)
|
||||
|
||||
-- stats box (0,8) 10x10: names rows 9/11/13/15, values indented
|
||||
Font.drawBox(0, 8, 10, 10)
|
||||
|
||||
@@ -198,6 +198,7 @@ function TitleState.new(game, opts)
|
||||
self.game = game
|
||||
self.onNewGame = opts.onNewGame
|
||||
self.onContinue = opts.onContinue
|
||||
self.onExit = opts.onExit
|
||||
-- branding comes from field.title with the shipped art as fallback, so
|
||||
-- a total conversion rebrands the title without replacing the screen
|
||||
local title = (game.data.field and game.data.field.title) or {}
|
||||
@@ -514,7 +515,9 @@ function TitleState:openMenu()
|
||||
require("src.ui.Screens").push(game, "OptionsMenu")
|
||||
end })
|
||||
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
|
||||
if love.event and love.event.quit then
|
||||
if self.onExit then
|
||||
self.onExit()
|
||||
elseif love.event and love.event.quit then
|
||||
love.event.quit()
|
||||
end
|
||||
end })
|
||||
|
||||
@@ -2290,6 +2290,7 @@ function BattleState:openParty(forced)
|
||||
-- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list
|
||||
-- carries BattleMonMenu; PickPartyMonInBattle has no submenu.
|
||||
prompt = forced and "which" or "choose",
|
||||
battle = true,
|
||||
battleSubmenu = not forced,
|
||||
onCancel = function()
|
||||
stack:pop()
|
||||
@@ -2762,6 +2763,7 @@ function BattleState:openShiftParty()
|
||||
self.phase = "submenu"
|
||||
Screens.push(self.game, "Gen2PartyMenu", {
|
||||
prompt = "which",
|
||||
battle = true,
|
||||
onCancel = function()
|
||||
stack:pop()
|
||||
self.phase = "resolving"
|
||||
@@ -3135,6 +3137,7 @@ function BattleState:useOnPartyMon(itemId, action)
|
||||
self.phase = "submenu"
|
||||
Screens.push(self.game, "Gen2PartyMenu", {
|
||||
prompt = "useItem",
|
||||
battle = true,
|
||||
party = self.battle.party or (self.save and self.save.party),
|
||||
onCancel = function()
|
||||
stack:pop()
|
||||
|
||||
@@ -76,6 +76,23 @@ local BATTLE_SUBMENU_LEFT, BATTLE_SUBMENU_TOP = 11, 11
|
||||
|
||||
-- HP bar is 6 tiles wide (48px) in the party list.
|
||||
|
||||
local function gridIndex(index, count, direction)
|
||||
if count < 1 then return nil end
|
||||
local row, col = math.floor((index - 1) / 2), (index - 1) % 2
|
||||
if direction == "left" or direction == "right" then
|
||||
local other = row * 2 + (1 - col) + 1
|
||||
return other <= count and other or index
|
||||
end
|
||||
local step = direction == "up" and -1 or direction == "down" and 1
|
||||
if not step then return nil end
|
||||
local rows = math.ceil(count / 2)
|
||||
for offset = 1, rows do
|
||||
local other = ((row + step * offset) % rows) * 2 + col + 1
|
||||
if other <= count then return other end
|
||||
end
|
||||
return index
|
||||
end
|
||||
|
||||
function PartyMenu:wantsFillScale() return true end
|
||||
function PartyMenu:drawsWidescreen() return true end
|
||||
|
||||
@@ -114,6 +131,7 @@ function PartyMenu.new(game, opts)
|
||||
self.wantsSubmenu = opts.submenu == true
|
||||
-- BattleMenu_PKMN's `callfar BattleMonMenu` (engine/battle/core.asm:4810).
|
||||
self.wantsBattleSubmenu = opts.battleSubmenu == true
|
||||
self.battle = opts.battle == true
|
||||
self.submenu = nil
|
||||
-- The held slot while SwitchPartyMons' second pick is open; nil otherwise.
|
||||
self.switchFrom = nil
|
||||
@@ -146,6 +164,13 @@ function PartyMenu:isCancel()
|
||||
return self.index > #self.party
|
||||
end
|
||||
|
||||
function PartyMenu:gridNavigation()
|
||||
if not self.battle
|
||||
or not Runtime.wantsHook("ui.party.grid_navigation") then return false end
|
||||
return Runtime.call("ui.party.grid_navigation", function() return false end,
|
||||
self) == true
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- mon submenu
|
||||
|
||||
-- GetMonSubmenuItems, in its own order: every field move the mon knows first,
|
||||
@@ -477,7 +502,18 @@ function PartyMenu:update(_dt)
|
||||
return
|
||||
end
|
||||
local total = self:count()
|
||||
if input:wasPressed("up") then
|
||||
local grid
|
||||
if self:gridNavigation() then
|
||||
local direction = input:wasPressed("left") and "left"
|
||||
or input:wasPressed("right") and "right"
|
||||
or input:wasPressed("up") and "up"
|
||||
or input:wasPressed("down") and "down"
|
||||
grid = gridIndex(self.index, #self.party, direction)
|
||||
end
|
||||
if grid then
|
||||
self.index = grid
|
||||
self:storeCursor()
|
||||
elseif input:wasPressed("up") then
|
||||
self.index = self.index > 1 and self.index - 1 or total
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index < total and self.index + 1 or 1
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Test returning to launcher from Gen 1 and Gen 2 on Android without closing the process
|
||||
-- luajit tests/engine/android_exit_to_launcher_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local TitleState = require("src.ui.TitleState")
|
||||
local Gen2MainMenu = require("src.ui.gen2.MainMenu")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
-- 1. Test Gen 1 TitleState onExit callback support
|
||||
do
|
||||
local exitCalled = false
|
||||
local dummyGame = {
|
||||
data = { field = {} },
|
||||
stack = {
|
||||
states = {},
|
||||
push = function(self, state) table.insert(self.states, state) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
},
|
||||
}
|
||||
local state = TitleState.new(dummyGame, {
|
||||
onExit = function()
|
||||
exitCalled = true
|
||||
end,
|
||||
})
|
||||
state:openMenu()
|
||||
local menu = dummyGame.stack:top()
|
||||
check(menu ~= nil, "TitleState:openMenu opens a menu")
|
||||
local exitItem = nil
|
||||
for _, item in ipairs(menu.items or {}) do
|
||||
if tostring(item.label):find("EXIT", 1, true) then
|
||||
exitItem = item
|
||||
break
|
||||
end
|
||||
end
|
||||
check(exitItem ~= nil, "Gen 1 TitleState menu contains an EXIT GAME item")
|
||||
if exitItem and exitItem.onSelect then
|
||||
exitItem.onSelect()
|
||||
end
|
||||
check(exitCalled, "Selecting EXIT GAME in Gen 1 TitleState invokes onExit callback")
|
||||
end
|
||||
|
||||
-- 2. Test Gen 2 MainMenu onExit callback support
|
||||
do
|
||||
local exitCalled = false
|
||||
local dummyGame2 = {
|
||||
data = {},
|
||||
stack = {
|
||||
states = {},
|
||||
push = function(self, state) table.insert(self.states, state) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
},
|
||||
}
|
||||
local menu = Gen2MainMenu.new(dummyGame2, {
|
||||
hasSave = false,
|
||||
onExit = function()
|
||||
exitCalled = true
|
||||
end,
|
||||
})
|
||||
menu:choose("exit")
|
||||
check(exitCalled, "Selecting EXIT GAME in Gen 2 MainMenu invokes onExit callback")
|
||||
end
|
||||
|
||||
-- 3. Test Runtime.reset restores NullEvents and NullHooks
|
||||
do
|
||||
Runtime.install({ emit = function() end }, { call = function() end }, { "err" })
|
||||
check(Runtime.errors ~= nil, "Runtime has errors list after install")
|
||||
Runtime.reset()
|
||||
check(Runtime.errors == nil, "Runtime.reset clears errors")
|
||||
check(Runtime.currentMod == nil, "Runtime.reset clears currentMod")
|
||||
check(Runtime.modRequire == nil, "Runtime.reset clears modRequire")
|
||||
end
|
||||
|
||||
T.finish("android_exit_to_launcher_test")
|
||||
@@ -0,0 +1,81 @@
|
||||
-- tests/engine/android_shortcuts_payload_test.lua
|
||||
-- Tests Android dynamic shortcuts synchronization, launch options intent resolution,
|
||||
-- and love.handlers.intent_game in-process game hot-swapping.
|
||||
-- luajit tests/engine/android_shortcuts_payload_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
require("main")
|
||||
|
||||
-- 1. Verify LaunchOptions handles getLaunchGame on Android
|
||||
local LaunchOptions = require("src.core.LaunchOptions")
|
||||
|
||||
local savedOS = love.system and love.system.getOS
|
||||
local savedGetLaunchGame = love.system and love.system.getLaunchGame
|
||||
|
||||
love.system = love.system or {}
|
||||
love.system.getOS = function() return "Android" end
|
||||
love.system.getLaunchGame = function() return "gold" end
|
||||
|
||||
local game, slot = LaunchOptions.resolve({})
|
||||
check(game == "gold", "LaunchOptions resolves intent game from love.system.getLaunchGame on Android")
|
||||
|
||||
local gameCli, slotCli = LaunchOptions.resolve({ "--game=red" })
|
||||
check(gameCli == "red", "CLI --game flag overrides intent game")
|
||||
|
||||
-- 2. Verify RomImporter.syncAndroidShortcuts ranking and 4-item cap
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local originalIsReady = RomImporter.isReady
|
||||
local capturedShortcuts = nil
|
||||
|
||||
love.system.updateShortcuts = function(versions)
|
||||
capturedShortcuts = versions
|
||||
return true
|
||||
end
|
||||
|
||||
-- Mock isReady
|
||||
RomImporter.isReady = function(v)
|
||||
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
|
||||
end
|
||||
|
||||
local ok = RomImporter.syncAndroidShortcuts("gold")
|
||||
check(ok == true, "syncAndroidShortcuts returns true on Android")
|
||||
check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items")
|
||||
check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first")
|
||||
|
||||
-- Test with subset of ready games (e.g. only Red and Gold)
|
||||
RomImporter.isReady = function(v)
|
||||
return v == "red" or v == "gold"
|
||||
end
|
||||
|
||||
capturedShortcuts = nil
|
||||
RomImporter.syncAndroidShortcuts("red")
|
||||
check(#capturedShortcuts == 2, "syncAndroidShortcuts only includes ready ROMs")
|
||||
check(capturedShortcuts[1] == "red" and capturedShortcuts[2] == "gold", "ready ROMs correctly passed")
|
||||
|
||||
-- Test on non-Android platform (safe no-op)
|
||||
love.system.getOS = function() return "Linux" end
|
||||
capturedShortcuts = nil
|
||||
local nonAndroidOk = RomImporter.syncAndroidShortcuts("red")
|
||||
check(nonAndroidOk == false, "syncAndroidShortcuts safely no-ops on non-Android")
|
||||
check(capturedShortcuts == nil, "no shortcuts updated on non-Android")
|
||||
|
||||
-- 3. Verify love.handlers.intent_game definition
|
||||
check(type(love.handlers.intent_game) == "function", "main.lua defines love.handlers.intent_game")
|
||||
|
||||
-- Restore
|
||||
RomImporter.isReady = originalIsReady
|
||||
if savedOS then
|
||||
love.system.getOS = savedOS
|
||||
else
|
||||
love.system.getOS = nil
|
||||
end
|
||||
love.system.getLaunchGame = savedGetLaunchGame
|
||||
love.system.updateShortcuts = nil
|
||||
|
||||
print("8/8 checks passed (android_shortcuts_payload_test)")
|
||||
@@ -397,7 +397,8 @@ local GEN2_HOOKS = {
|
||||
"world.tod", "map.palette", "fieldmove.eligibility",
|
||||
-- menus and the battle intro
|
||||
"ui.start_menu.items", "ui.title_menu.items", "ui.options.rows",
|
||||
"ui.party.submenu", "ui.naming.grid", "ui.pc.items", "ui.list_menu",
|
||||
"ui.party.submenu", "ui.party.grid_navigation", "ui.naming.grid",
|
||||
"ui.pc.items", "ui.list_menu",
|
||||
"transition.style",
|
||||
-- battle
|
||||
"battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
-- BoxMenu's Yellow-only "Pikachu looks unhappy" release path
|
||||
-- (release()'s isYellow()/species=="PIKACHU"/otId/ot branch) pushes its
|
||||
-- TextBox with `TextBox.new(game, (...):gsub(...))` -- the gsub call is
|
||||
-- the last argument, unparenthesized, so Lua expands its second return
|
||||
-- value (the substitution count) into TextBox.new's third parameter,
|
||||
-- onDone. TextBox.lua later calls onDone() unconditionally once the box
|
||||
-- is dismissed, and a number is not callable: every release of your own
|
||||
-- caught Pikachu in Yellow crashed, regardless of its nickname (unlike
|
||||
-- the separate %-escape gsub bug, this needs no special save content --
|
||||
-- ordinary play reaches it every time). ROM-free: registers a fake
|
||||
-- Data.pokemon.PIKACHU cloned from the fixture species so the species ==
|
||||
-- "PIKACHU" check can be exercised without a real ROM import.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local ids = T.fixtures.ids
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
-- clone a real fixture species under the literal id release() checks for
|
||||
Data.pokemon.PIKACHU = Data.pokemon[ids.species[1]]
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local BoxMenu = require("src.ui.BoxMenu")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local realCry, realPlay = Sound.playCry, Sound.play
|
||||
Sound.playCry = function() end
|
||||
Sound.play = function() end
|
||||
|
||||
local stack = { states = {} }
|
||||
function stack:push(s) self.states[#self.states + 1] = s end
|
||||
function stack:pop()
|
||||
local t = self.states[#self.states]
|
||||
self.states[#self.states] = nil
|
||||
return t
|
||||
end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
function stack:update(dt)
|
||||
local t = self:top()
|
||||
if t and t.update then t:update(dt) end
|
||||
end
|
||||
|
||||
local pressed = {}
|
||||
local function press(btn)
|
||||
pressed = { [btn] = true }
|
||||
stack:update(1 / 60)
|
||||
pressed = {}
|
||||
end
|
||||
|
||||
local function topMt() return getmetatable(stack:top()) end
|
||||
local function mash(btn, cond, n)
|
||||
for _ = 1, (n or 400) do
|
||||
if cond() then return true end
|
||||
press(btn)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
GameVersion.set("yellow")
|
||||
|
||||
local save = SaveData.newGame()
|
||||
local game = {
|
||||
data = Data,
|
||||
save = save,
|
||||
stack = stack,
|
||||
input = {
|
||||
wasPressed = function(_, key) return pressed[key] or false end,
|
||||
isDown = function() return false end,
|
||||
},
|
||||
}
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 1
|
||||
|
||||
local box = Boxes.active(save)
|
||||
local mon = Pokemon.new(Data, "PIKACHU", 5)
|
||||
mon.otId = save.player.id
|
||||
mon.ot = save.player.name
|
||||
box[1] = mon
|
||||
|
||||
stack:push(BoxMenu.new(game))
|
||||
press("down"); press("down"); press("a") -- open RELEASE list
|
||||
T.check(topMt() == ListMenu, "RELEASE opens the box list")
|
||||
|
||||
-- release() calls Sound.playCry (stubbed) then pushes the "unhappy"
|
||||
-- TextBox before any confirmation prompt -- pre-fix this line itself
|
||||
-- raises "attempt to call field 'onDone' (a number value)" the moment
|
||||
-- TextBox.new stores the leaked count and something dismisses the box.
|
||||
local ok, err = pcall(function()
|
||||
press("a") -- choose the Pikachu; release() runs synchronously here
|
||||
T.check(topMt() == TextBox, "the unhappy-Pikachu TextBox opens directly, no confirm prompt")
|
||||
-- dismiss it: this is what calls onDone, which is where the pre-fix
|
||||
-- leaked count used to crash
|
||||
mash("a", function() return topMt() ~= TextBox end)
|
||||
end)
|
||||
T.check(ok, "releasing your own caught Pikachu in Yellow does not crash: " .. tostring(err))
|
||||
|
||||
GameVersion.set("red")
|
||||
Sound.playCry, Sound.play = realCry, realPlay
|
||||
T.finish("pikachu_unhappy_release_crash")
|
||||
@@ -26,5 +26,7 @@ check(helperStart ~= nil, "requiredFilesFor helper exists")
|
||||
local helper = src:sub(helperStart, start)
|
||||
check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil,
|
||||
"requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE")
|
||||
check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil,
|
||||
"Gold caches require the trainer HUD ball sheet")
|
||||
|
||||
T.finish()
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("safe mode and issue report")
|
||||
local check = S.check
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local IssueReport = require("src.core.IssueReport")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local options = SaveData.defaultOptions()
|
||||
check(not SaveData.isSafeMode(options), "safe mode defaults off")
|
||||
SaveData.setSafeMode(options, true)
|
||||
check(SaveData.isSafeMode(options), "safe mode can be enabled")
|
||||
|
||||
local manifests = {
|
||||
{ id = "alpha", name = "Alpha", version = "1.0.0", experimental = false,
|
||||
raw = {}, dependencySpecs = {}, conflictSpecs = {} },
|
||||
{ id = "beta", name = "Beta", version = "1.0.0", experimental = false,
|
||||
raw = {}, dependencySpecs = {}, conflictSpecs = {} },
|
||||
}
|
||||
options.mods.alpha = false
|
||||
options.mods.beta = true
|
||||
local rows = LauncherMods.deriveList(manifests, options, "red")
|
||||
check(#rows == 2, "safe mode keeps installed mods visible")
|
||||
check(not rows[1].enabled and not rows[2].enabled,
|
||||
"safe mode disables every launcher mod row")
|
||||
check(rows[1].status == "safe_mode" and rows[2].status == "safe_mode",
|
||||
"safe mode explains every disabled launcher row")
|
||||
|
||||
SaveData.setSafeMode(options, false)
|
||||
rows = LauncherMods.deriveList(manifests, options, "red")
|
||||
local byId = {}
|
||||
for _, row in ipairs(rows) do byId[row.id] = row end
|
||||
check(not byId.alpha.enabled and byId.beta.enabled,
|
||||
"turning safe mode off restores saved mod choices")
|
||||
|
||||
local previousLove = _G.love
|
||||
local openedURL
|
||||
_G.love = {
|
||||
getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end,
|
||||
system = {
|
||||
getOS = function() return "iOS" end,
|
||||
getModel = function() return "iPad Test" end,
|
||||
openURL = function(url) openedURL = url end,
|
||||
},
|
||||
graphics = {
|
||||
getRendererInfo = function()
|
||||
return "Metal", "3.0", "Apple", "Simulator GPU"
|
||||
end,
|
||||
getDimensions = function() return 1024, 768 end,
|
||||
getPixelDimensions = function() return 2048, 1536 end,
|
||||
},
|
||||
window = {
|
||||
getMode = function() return 1024, 768, { fullscreen = false } end,
|
||||
},
|
||||
}
|
||||
|
||||
local url, fields, info = IssueReport.build({
|
||||
safeMode = true,
|
||||
lastVersion = "gold",
|
||||
}, {
|
||||
version = "gold",
|
||||
mods = { { id = "alpha", name = "Alpha", enabled = true } },
|
||||
})
|
||||
check(url:find("template=bug_report.yml", 1, true) ~= nil,
|
||||
"report URL selects the bug form")
|
||||
check(url:find("title=bug%3A%20replace%20this%20with%20a%20meaningful%20title", 1, true) ~= nil,
|
||||
"report URL uses the requested bug title")
|
||||
check(info.os == "iOS",
|
||||
"report metadata maps the platform")
|
||||
check(fields.mods_which == "",
|
||||
"safe mode leaves the optional mod list blank")
|
||||
check(not url:find("game=", 1, true)
|
||||
and not url:find("os=", 1, true)
|
||||
and not url:find("mods_enabled=", 1, true),
|
||||
"report URL omits unsupported dropdown and checkbox prefills")
|
||||
check(fields.summary == "" and fields.location == "" and fields.screenshot == ""
|
||||
and fields.steps == "" and fields.expected == "",
|
||||
"report leaves user-entered fields blank")
|
||||
check(info.metadata:find("Device: iPad Test", 1, true) ~= nil
|
||||
and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil
|
||||
and info.metadata:find("Safe mode: on", 1, true) ~= nil,
|
||||
"report metadata includes device and app details")
|
||||
check(not info.metadata:find("unknown", 1, true),
|
||||
"report metadata omits unknown values")
|
||||
check(not info.metadata:find("Game id", 1, true)
|
||||
and not info.metadata:find("Game:", 1, true)
|
||||
and not info.metadata:find("Mods:", 1, true)
|
||||
and not info.metadata:find("Processors", 1, true)
|
||||
and not info.metadata:find("Power", 1, true),
|
||||
"report metadata omits redundant system fields")
|
||||
|
||||
local previousEngine = Version.engine
|
||||
Version.engine = "0.1.50"
|
||||
local _, versionFields, versionInfo = IssueReport.build({}, { mods = {} })
|
||||
check(versionFields.version == "0.1.50"
|
||||
and versionInfo.metadata:find("App: gen1recomp v0.1.50", 1, true) ~= nil,
|
||||
"report uses the stamped app version")
|
||||
Version.engine = previousEngine
|
||||
local _, _, developmentInfo = IssueReport.build({}, { mods = {} })
|
||||
check(not developmentInfo.metadata:find("0.0.0-dev", 1, true),
|
||||
"report omits an unstamped development version")
|
||||
|
||||
local previousOS = love.system.getOS
|
||||
local previousModel = love.system.getModel
|
||||
local previousIO = _G.io
|
||||
love.system.getOS = function() return "OS X" end
|
||||
love.system.getModel = nil
|
||||
_G.io = {
|
||||
popen = function()
|
||||
return {
|
||||
read = function() return "MacBookPro18,3" end,
|
||||
close = function() end,
|
||||
}
|
||||
end,
|
||||
}
|
||||
local desktopInfo = IssueReport.metadata({}, { mods = {} })
|
||||
check(desktopInfo.device == "MacBookPro18,3",
|
||||
"report finds desktop device model when LOVE has no model")
|
||||
love.system.getOS = function() return "UWP" end
|
||||
local xboxInfo = IssueReport.metadata({}, { mods = {} })
|
||||
check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform")
|
||||
love.system.getOS = previousOS
|
||||
love.system.getModel = previousModel
|
||||
_G.io = previousIO
|
||||
|
||||
local opened = IssueReport.open({ safeMode = false }, {
|
||||
version = "red",
|
||||
mods = {},
|
||||
})
|
||||
check(opened and openedURL and openedURL:find("title=bug%3A%20replace%20this%20with%20a%20meaningful%20title", 1, true) ~= nil,
|
||||
"report action opens the generated URL")
|
||||
|
||||
_G.love = previousLove
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,166 @@
|
||||
-- SummaryMenu.lua:148 and PartyMenu.lua:824 used to draw mon.status as a
|
||||
-- bare literal ("PSN", "PAR", "BRN", "FRZ", "SLP"), invisible to any
|
||||
-- translation a mod supplies. Unlike the strings catalog, a mod translates
|
||||
-- status abbreviations through the statuses content registry
|
||||
-- (mod.content.statuses:patch(id, { label = value }), label only), the
|
||||
-- same registry src/battle/BattleState.lua:statusLabel already reads in
|
||||
-- battle. This test drives both screens' status draw with a mod-patched
|
||||
-- registry and checks the patched label reaches Font.draw, not the raw
|
||||
-- status id.
|
||||
--
|
||||
-- It also guards a second bug found alongside the first: Status.RECORDS'
|
||||
-- five vanilla entries used to set hudLabel to the same literal as label
|
||||
-- ("FRZ", hudLabel = "FRZ", ...). Since Status.hudLabelFor (and
|
||||
-- BattleState:statusLabel before it) reads "hudLabel or label", and
|
||||
-- Registry:patch only overrides the fields a mod actually passes, a
|
||||
-- real label-only patch left the untouched vanilla hudLabel shadowing it
|
||||
-- forever -- the translation was stored but never displayed anywhere,
|
||||
-- in or out of battle.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
love = love or {}
|
||||
love.graphics = {
|
||||
setColor = function() end,
|
||||
rectangle = function() end,
|
||||
draw = function() end,
|
||||
push = function() end, pop = function() end,
|
||||
translate = function() end, scale = function() end,
|
||||
}
|
||||
|
||||
package.loaded["src.render.Font"] = {
|
||||
draw = function() end,
|
||||
drawCode = function() end,
|
||||
drawBox = function() end,
|
||||
}
|
||||
package.loaded["src.render.HudTiles"] = {
|
||||
statusTile = function() end,
|
||||
tile = function() end,
|
||||
drawHPBar = function() end,
|
||||
}
|
||||
package.loaded["src.render.PaletteFX"] = {
|
||||
shader = function() return nil end,
|
||||
pal = function() return nil end,
|
||||
markTrueColor = function() end,
|
||||
}
|
||||
package.loaded["src.ui.Theme"] = { cursor = 0, cursorHollow = 0 }
|
||||
package.loaded["src.render.Assets"] = {}
|
||||
package.loaded["src.world.FieldDefaults"] = {}
|
||||
package.loaded["src.world.Map"] = {}
|
||||
package.loaded["src.mods.Runtime"] = { wantsHook = function() return false end }
|
||||
package.loaded["src.ui.Screens"] = {}
|
||||
package.loaded["src.core.Logger"] = { warn = function() end }
|
||||
|
||||
local Status = require("src.battle.Status")
|
||||
|
||||
-- a mod's registered status translation, same shape mod.content.statuses:
|
||||
-- patch(id, { label = ..., hudLabel = ... }) merges into Data.statuses
|
||||
local function moddedStatuses()
|
||||
local statuses = {}
|
||||
for id, record in pairs(Status.RECORDS) do statuses[id] = record end
|
||||
statuses.PSN = { id = "PSN", label = "PSN", hudLabel = "TOX" }
|
||||
return statuses
|
||||
end
|
||||
|
||||
local Font = package.loaded["src.render.Font"]
|
||||
local drawn
|
||||
local origDraw = Font.draw
|
||||
Font.draw = function(text, x, y)
|
||||
drawn[#drawn + 1] = { text = text, x = x, y = y }
|
||||
return origDraw(text, x, y)
|
||||
end
|
||||
|
||||
local function mkDef()
|
||||
return { name = "BULBASAUR", dex = 1, types = { "GRASS" } }
|
||||
end
|
||||
|
||||
local function mkMon(status)
|
||||
return {
|
||||
nickname = "SAUR", species = "BULBASAUR", level = 5,
|
||||
hp = 10, stats = { hp = 10, attack = 5, defense = 5, speed = 5, special = 5 },
|
||||
status = status,
|
||||
}
|
||||
end
|
||||
|
||||
-- ---- SummaryMenu: page 1's STATUS/ line (~line 148-151) ----
|
||||
do
|
||||
local SummaryMenu = assert(loadfile("src/ui/SummaryMenu.lua"))()
|
||||
local game = {
|
||||
data = { pokemon = { BULBASAUR = mkDef() }, statuses = moddedStatuses() },
|
||||
save = { player = { id = 1, name = "RED" } },
|
||||
}
|
||||
local menu = setmetatable(
|
||||
{ game = game, mon = mkMon("PSN"), page = 1 }, SummaryMenu)
|
||||
drawn = {}
|
||||
menu:draw()
|
||||
local statusDraw
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == 128 and d.y == 48 then statusDraw = d end
|
||||
end
|
||||
T.check(statusDraw ~= nil, "SummaryMenu draws a status label at (128,48)")
|
||||
T.eq(statusDraw.text, "TOX",
|
||||
"SummaryMenu draws the mod-patched hudLabel, not the raw status id")
|
||||
end
|
||||
|
||||
-- vanilla (no mod): falls back to the plain id, same as before the fix
|
||||
do
|
||||
local SummaryMenu = assert(loadfile("src/ui/SummaryMenu.lua"))()
|
||||
local game = {
|
||||
data = { pokemon = { BULBASAUR = mkDef() }, statuses = nil },
|
||||
save = { player = { id = 1, name = "RED" } },
|
||||
}
|
||||
local menu = setmetatable(
|
||||
{ game = game, mon = mkMon("PSN"), page = 1 }, SummaryMenu)
|
||||
drawn = {}
|
||||
menu:draw()
|
||||
local statusDraw
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == 128 and d.y == 48 then statusDraw = d end
|
||||
end
|
||||
T.eq(statusDraw.text, "PSN",
|
||||
"SummaryMenu still shows the vanilla PSN label with no mod loaded")
|
||||
end
|
||||
|
||||
-- ---- PartyMenu: the roster row's status column (~line 824-827) ----
|
||||
do
|
||||
local PartyMenu = assert(loadfile("src/ui/PartyMenu.lua"))()
|
||||
PartyMenu.drawIcon = function() end
|
||||
local game = {
|
||||
data = { pokemon = { BULBASAUR = mkDef() }, statuses = moddedStatuses(),
|
||||
text = {} },
|
||||
save = { party = { mkMon("PSN") } },
|
||||
}
|
||||
local list = setmetatable({ game = game, index = 1 }, PartyMenu)
|
||||
drawn = {}
|
||||
list:draw()
|
||||
local statusDraw
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == 136 then statusDraw = d end
|
||||
end
|
||||
T.check(statusDraw ~= nil, "PartyMenu draws a status label at x=136")
|
||||
T.eq(statusDraw.text, "TOX",
|
||||
"PartyMenu draws the mod-patched hudLabel, not the raw status id")
|
||||
end
|
||||
|
||||
-- ---- real Registry:patch, not a hand-built table: a label-only patch (the
|
||||
-- shape a translation mod would send for every one of the 5 vanilla
|
||||
-- statuses) must reach the HUD despite the vanilla record already
|
||||
-- defining hudLabel ----
|
||||
do
|
||||
local Registry = require("src.mods.Registry")
|
||||
local reg = Registry.new("statuses", { semantics = "record", target = "statuses" })
|
||||
reg.base = function() return Status.RECORDS end
|
||||
local LABEL_ONLY_PATCH = { SLP = "SOM", FRZ = "GEL", PSN = "PSN", BRN = "BRU", PAR = "PAR" }
|
||||
for id, translated in pairs(LABEL_ONLY_PATCH) do
|
||||
reg:patch(id, { label = translated }, "mod")
|
||||
end
|
||||
local merged = {}
|
||||
for id in pairs(Status.RECORDS) do merged[id] = reg:get(id) end
|
||||
for id, translated in pairs(LABEL_ONLY_PATCH) do
|
||||
T.eq(Status.hudLabelFor(merged, id), translated,
|
||||
"a label-only mod patch on " .. id .. " reaches the HUD label")
|
||||
end
|
||||
end
|
||||
|
||||
T.finish("status_abbreviation_translation_test")
|
||||
@@ -14,6 +14,7 @@ local NamingScreen = require("src.ui.NamingScreen")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local Gen2PartyMenu = require("src.ui.gen2.PartyMenu")
|
||||
local Player = require("src.world.Player")
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
@@ -295,6 +296,16 @@ do
|
||||
menu:update(0)
|
||||
check(menu.index == 4,
|
||||
"removing the hook restores native list navigation immediately")
|
||||
|
||||
local gold = Gen2PartyMenu.new(game, { battle = true })
|
||||
unsub = wrap("ui.party.grid_navigation", function() return true end)
|
||||
gold:update(0)
|
||||
check(gold.index == 3,
|
||||
"a Gold battle party can follow the same companion grid")
|
||||
unsub()
|
||||
gold:update(0)
|
||||
check(gold.index == 4,
|
||||
"Gold restores native party list navigation without the hook")
|
||||
end
|
||||
|
||||
-- ------- music.volume (distance / indoor muffling)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generates Android Adaptive Icon (based on cleaned love.png Pokéball + Gen1Recomp emblem)
|
||||
and 3D Cartridge Shortcut assets for all density buckets (mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi).
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections import deque
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RES_DIR = os.path.join(ROOT, "mobile", "android", "app", "src", "main", "res")
|
||||
|
||||
DENSITIES = {
|
||||
"mdpi": {"shortcut": 48, "adaptive": 108},
|
||||
"hdpi": {"shortcut": 72, "adaptive": 162},
|
||||
"xhdpi": {"shortcut": 96, "adaptive": 216},
|
||||
"xxhdpi": {"shortcut": 144, "adaptive": 324},
|
||||
"xxxhdpi": {"shortcut": 192, "adaptive": 432},
|
||||
}
|
||||
|
||||
SHELL_COLORS = {
|
||||
"red": {"main": (230, 45, 55), "dark": (175, 25, 35), "light": (255, 90, 100)},
|
||||
"blue": {"main": (35, 125, 235), "dark": (20, 85, 175), "light": (80, 165, 255)},
|
||||
"yellow": {"main": (255, 205, 10), "dark": (210, 160, 0), "light": (255, 230, 80)},
|
||||
"gold": {"main": (225, 170, 40), "dark": (170, 120, 20), "light": (245, 200, 80)},
|
||||
}
|
||||
|
||||
def extract_cleaned_love_emblem():
|
||||
"""Extracts the Pokéball/Gen1Recomp emblem from love.png with transparent bg and deepened blacks."""
|
||||
src_path = os.path.join(RES_DIR, "drawable-xxxhdpi", "love.png")
|
||||
if not os.path.exists(src_path):
|
||||
src_path = os.path.join(RES_DIR, "drawable-xxhdpi", "love.png")
|
||||
src = Image.open(src_path).convert("RGBA")
|
||||
arr = np.array(src, dtype=np.uint8)
|
||||
h, w = arr.shape[:2]
|
||||
|
||||
visited = np.zeros((h, w), dtype=bool)
|
||||
bg_mask = np.zeros((h, w), dtype=bool)
|
||||
|
||||
queue = deque()
|
||||
for x in range(w):
|
||||
queue.append((0, x)); queue.append((h-1, x))
|
||||
for y in range(h):
|
||||
queue.append((y, 0)); queue.append((y, w-1))
|
||||
|
||||
bg_ref = np.array([255, 237, 254], dtype=float)
|
||||
while queue:
|
||||
y, x = queue.popleft()
|
||||
if visited[y, x]: continue
|
||||
visited[y, x] = True
|
||||
color = arr[y, x, :3].astype(float)
|
||||
if np.max(np.abs(color - bg_ref)) < 28:
|
||||
bg_mask[y, x] = True
|
||||
for dy, dx in [(-1,0), (1,0), (0,-1), (0,1)]:
|
||||
ny, nx = y + dy, x + dx
|
||||
if 0 <= ny < h and 0 <= nx < w and not visited[ny, nx]:
|
||||
queue.append((ny, nx))
|
||||
|
||||
out_arr = arr.copy().astype(float)
|
||||
out_arr[bg_mask, 3] = 0
|
||||
|
||||
# Deepen the soft blacks/outlines for crispness:
|
||||
fg_mask = ~bg_mask
|
||||
rgb = out_arr[fg_mask, :3]
|
||||
lum = 0.299 * rgb[:, 0] + 0.587 * rgb[:, 1] + 0.114 * rgb[:, 2]
|
||||
|
||||
for i in range(len(rgb)):
|
||||
l = lum[i]
|
||||
if l < 110:
|
||||
factor = (l / 110.0) ** 1.8
|
||||
rgb[i, 0] = max(0, rgb[i, 0] * factor * 0.7)
|
||||
rgb[i, 1] = max(0, rgb[i, 1] * factor * 0.7)
|
||||
rgb[i, 2] = max(0, rgb[i, 2] * factor * 0.8)
|
||||
|
||||
out_arr[fg_mask, :3] = np.clip(rgb, 0, 255)
|
||||
return Image.fromarray(out_arr.astype(np.uint8))
|
||||
|
||||
CLEANED_EMBLEM = extract_cleaned_love_emblem()
|
||||
|
||||
def create_adaptive_foreground(size):
|
||||
emblem = CLEANED_EMBLEM.copy()
|
||||
target_size = int(size * 0.78)
|
||||
emblem.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
|
||||
|
||||
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
x = (size - emblem.width) // 2
|
||||
y = (size - emblem.height) // 2
|
||||
canvas.paste(emblem, (x, y), emblem)
|
||||
return canvas
|
||||
|
||||
def create_adaptive_monochrome(size):
|
||||
fg = create_adaptive_foreground(size)
|
||||
arr = np.array(fg, dtype=float)
|
||||
lum = 0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]
|
||||
alpha = arr[:, :, 3]
|
||||
|
||||
mono_alpha = np.zeros_like(alpha)
|
||||
valid = alpha > 20
|
||||
mono_alpha[valid & (lum > 70)] = 255
|
||||
mono_alpha[valid & (lum <= 70)] = 0
|
||||
|
||||
mono_img = np.zeros((size, size, 4), dtype=np.uint8)
|
||||
mono_img[:, :, 0] = 255
|
||||
mono_img[:, :, 1] = 255
|
||||
mono_img[:, :, 2] = 255
|
||||
mono_img[:, :, 3] = mono_alpha.astype(np.uint8)
|
||||
|
||||
return Image.fromarray(mono_img)
|
||||
|
||||
def render_3d_cartridge(version, size):
|
||||
colors = SHELL_COLORS[version]
|
||||
S = size * 4
|
||||
|
||||
# Transparent canvas so the cartridge sits directly on launcher's white circle plate
|
||||
canvas = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
# Tight padding to maximize cartridge size
|
||||
pad_x = int(S * 0.05)
|
||||
pad_y = int(S * 0.03)
|
||||
cw = S - pad_x * 2
|
||||
ch = S - pad_y * 2
|
||||
|
||||
# Soft drop shadow
|
||||
shadow = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
||||
sdraw = ImageDraw.Draw(shadow)
|
||||
sdraw.rounded_rectangle([pad_x + 8, pad_y + 14, pad_x + cw + 8, pad_y + ch + 14],
|
||||
radius=int(S * 0.05), fill=(0, 0, 0, 90))
|
||||
shadow = shadow.filter(ImageFilter.GaussianBlur(int(S * 0.03)))
|
||||
canvas.paste(shadow, (0, 0), shadow)
|
||||
|
||||
radius = int(S * 0.045)
|
||||
depth = int(S * 0.03)
|
||||
draw.rounded_rectangle([pad_x, pad_y + depth, pad_x + cw, pad_y + ch + depth],
|
||||
radius=radius, fill=colors["dark"])
|
||||
draw.rounded_rectangle([pad_x, pad_y, pad_x + cw, pad_y + ch],
|
||||
radius=radius, fill=colors["main"])
|
||||
|
||||
notch_w = int(cw * 0.65)
|
||||
notch_h = int(ch * 0.07)
|
||||
notch_x = pad_x + (cw - notch_w) // 2
|
||||
notch_y = pad_y + int(ch * 0.035)
|
||||
draw.rounded_rectangle([notch_x, notch_y, notch_x + notch_w, notch_y + notch_h],
|
||||
radius=int(notch_h * 0.4), fill=colors["dark"])
|
||||
draw.rounded_rectangle([notch_x, notch_y - 2, notch_x + notch_w, notch_y + notch_h - 2],
|
||||
radius=int(notch_h * 0.4), fill=colors["light"])
|
||||
|
||||
label_margin_x = int(cw * 0.09)
|
||||
label_top_y = pad_y + int(ch * 0.20)
|
||||
label_w = cw - label_margin_x * 2
|
||||
label_h = int(ch * 0.70)
|
||||
label_x = pad_x + label_margin_x
|
||||
|
||||
draw.rounded_rectangle([label_x - 3, label_top_y - 3, label_x + label_w + 3, label_top_y + label_h + 3],
|
||||
radius=int(radius * 0.7), fill=colors["dark"])
|
||||
|
||||
label_path = os.path.join(ROOT, "assets", "labels", f"{version}.png")
|
||||
if os.path.exists(label_path):
|
||||
label_img = Image.open(label_path).convert("RGBA")
|
||||
label_img = label_img.resize((label_w, label_h), Image.Resampling.LANCZOS)
|
||||
|
||||
mask = Image.new("L", (label_w, label_h), 0)
|
||||
mdraw = ImageDraw.Draw(mask)
|
||||
mdraw.rounded_rectangle([0, 0, label_w, label_h], radius=int(radius * 0.5), fill=255)
|
||||
|
||||
canvas.paste(label_img, (label_x, label_top_y), mask)
|
||||
else:
|
||||
draw.rounded_rectangle([label_x, label_top_y, label_x + label_w, label_top_y + label_h],
|
||||
radius=int(radius * 0.5), fill=(240, 240, 240, 255))
|
||||
|
||||
draw.rounded_rectangle([pad_x, pad_y, pad_x + cw, pad_y + ch],
|
||||
radius=radius, outline=colors["light"], width=max(2, int(S * 0.008)))
|
||||
|
||||
return canvas.resize((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
def main():
|
||||
os.makedirs(os.path.join(RES_DIR, "values"), exist_ok=True)
|
||||
os.makedirs(os.path.join(RES_DIR, "mipmap-anydpi-v26"), exist_ok=True)
|
||||
|
||||
for density, sizes in DENSITIES.items():
|
||||
drawable_dir = os.path.join(RES_DIR, f"drawable-{density}")
|
||||
os.makedirs(drawable_dir, exist_ok=True)
|
||||
|
||||
fg = create_adaptive_foreground(sizes["adaptive"])
|
||||
fg.save(os.path.join(drawable_dir, "ic_launcher_foreground.png"), "PNG")
|
||||
|
||||
for ver in ("red", "blue", "yellow", "gold"):
|
||||
cart = render_3d_cartridge(ver, sizes["shortcut"])
|
||||
cart.save(os.path.join(drawable_dir, f"ic_shortcut_{ver}.png"), "PNG")
|
||||
|
||||
print(f"Generated assets for drawable-{density}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||