Merge pull request #1546 from 1Jamie/feat/android-exit-game-to-launcher
feat(android): add adaptive icons, dynamic shortcuts, in-process hot-swap, and exit-to-launcher
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -326,6 +326,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 +1419,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 +1814,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
|
||||
|
||||
@@ -38,6 +38,14 @@ function Runtime.install(events, hooks, errors)
|
||||
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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)")
|
||||
@@ -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()
|
||||