diff --git a/android/app/src/main/cpp/native-lib.cpp b/android/app/src/main/cpp/native-lib.cpp index 54c98f0..3d800ee 100644 --- a/android/app/src/main/cpp/native-lib.cpp +++ b/android/app/src/main/cpp/native-lib.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,7 @@ #include "OSD/Logger.h" #include "Util/NewConfig.h" #include "Util/ConfigBuilders.h" +#include "Version.h" #include "BlockFile.h" #include "android_input_system.h" @@ -59,7 +61,10 @@ protected: // Emulator host -------------------------------------------------------------- +static struct Super3Host* g_host = nullptr; + struct Super3Host { + static constexpr int32_t STATE_FILE_VERSION = 3; Util::Config::Node config{"Global"}; AndroidInputSystem inputSystem; CInputs inputs{&inputSystem}; @@ -75,10 +80,29 @@ struct Super3Host { ROMSet roms; std::atomic ready{false}; std::string userDataRoot; + unsigned saveSlot = 0; + std::atomic requestSaveSlot{-1}; + std::atomic requestLoadSlot{-1}; + std::atomic requestMenuPause{-1}; // -1=no change, 0=resume, 1=pause + bool menuPaused = false; + bool threadsPausedByMenu = false; Super3Host() { ApplyDefaults(); } - void SetUserDataRoot(std::string root) { userDataRoot = std::move(root); } + void SetUserDataRoot(std::string root) + { + userDataRoot = std::move(root); + if (userDataRoot.empty()) + return; + try { + std::filesystem::create_directories(userDataRoot); + std::filesystem::create_directories(JoinPath(userDataRoot, "Saves")); + std::filesystem::current_path(userDataRoot); + SDL_Log("User data root: %s", userDataRoot.c_str()); + } catch (...) { + // Ignore any path errors; caller can still override with absolute paths. + } + } std::string NvramPathForGame() const { @@ -175,10 +199,13 @@ struct Super3Host { config.Set("InputJoyRight", "KEY_RIGHT"); config.Set("InputSteeringLeft", "KEY_LEFT"); config.Set("InputSteeringRight", "KEY_RIGHT"); - config.Set("InputAccelerator", "KEY_W"); - config.Set("InputBrake", "KEY_S"); - inputSystem.ApplyConfig(config); - } + config.Set("InputAccelerator", "KEY_W"); + config.Set("InputBrake", "KEY_S"); + config.Set("UISaveState", "KEY_F5"); + config.Set("UIChangeSlot", "KEY_F6"); + config.Set("UILoadState", "KEY_F7"); + inputSystem.ApplyConfig(config); + } void ApplyAndroidHardOverrides() { @@ -385,15 +412,160 @@ struct Super3Host { } } - void RunFrame() { - if (ready.load(std::memory_order_acquire) && model3) { - // Poll inputs once per frame (matches desktop OSD flow). - // Display geometry is used for mouse/lightgun normalization; for Android touch/key - // it mainly keeps the input system in a sane state. - inputs.Poll(&game, 0, 0, 496, 384); - model3->RunFrame(); + void ApplyMenuPaused(bool paused) + { + menuPaused = paused; + if (!model3) + return; + + if (paused) { + if (!threadsPausedByMenu) { + SDL_Log("Menu pause ON"); + model3->PauseThreads(); + SetAudioEnabled(false); + threadsPausedByMenu = true; + } + } else { + if (threadsPausedByMenu) { + SDL_Log("Menu pause OFF"); + model3->ResumeThreads(); + SetAudioEnabled(true); + threadsPausedByMenu = false; + } + } + } + + void RunFrame() { + if (ready.load(std::memory_order_acquire) && model3) { + const int pauseReq = requestMenuPause.exchange(-1, std::memory_order_acq_rel); + if (pauseReq != -1) { + ApplyMenuPaused(pauseReq == 1); + } + + const int saveReq = requestSaveSlot.exchange(-1, std::memory_order_acq_rel); + if (saveReq >= 0) { + const unsigned slot = static_cast(saveReq) % 10u; + const bool wasPaused = threadsPausedByMenu; + saveSlot = slot; + SDL_Log("UI save state requested (slot %u)", saveSlot); + if (!wasPaused) { + model3->PauseThreads(); + SetAudioEnabled(false); + } + SaveState(); + if (!wasPaused) { + model3->ResumeThreads(); + SetAudioEnabled(true); + } + } + + const int loadReq = requestLoadSlot.exchange(-1, std::memory_order_acq_rel); + if (loadReq >= 0) { + const unsigned slot = static_cast(loadReq) % 10u; + const bool wasPaused = threadsPausedByMenu; + saveSlot = slot; + SDL_Log("UI load state requested (slot %u)", saveSlot); + if (!wasPaused) { + model3->PauseThreads(); + SetAudioEnabled(false); + } + LoadState(); + if (!wasPaused) { + model3->ResumeThreads(); + SetAudioEnabled(true); + } + } + + // Poll inputs once per frame (matches desktop OSD flow). + // Display geometry is used for mouse/lightgun normalization; for Android touch/key + // it mainly keeps the input system in a sane state. + inputs.Poll(&game, 0, 0, 496, 384); + + // If a physical keyboard is attached, allow the canonical hotkeys too. + if (!threadsPausedByMenu) { + if (inputs.uiSaveState && inputs.uiSaveState->Pressed()) { + requestSaveSlot.store(static_cast(saveSlot), std::memory_order_release); + } else if (inputs.uiChangeSlot && inputs.uiChangeSlot->Pressed()) { + saveSlot = (saveSlot + 1) % 10; + SDL_Log("Save slot: %u", saveSlot); + } else if (inputs.uiLoadState && inputs.uiLoadState->Pressed()) { + requestLoadSlot.store(static_cast(saveSlot), std::memory_order_release); + } + } + + if (threadsPausedByMenu) model3->RenderFrame(); + else model3->RunFrame(); + } + } + + std::string SaveStatePath() const + { + const std::string base = userDataRoot.empty() ? std::string("super3") : userDataRoot; + return JoinPath(JoinPath(base, "Saves"), game.name + ".st" + std::to_string(saveSlot)); + } + + void SaveState() + { + if (!model3 || game.name.empty()) + return; + + const std::string filePath = SaveStatePath(); + try { + std::filesystem::create_directories(std::filesystem::path(filePath).parent_path()); + } catch (...) { + // ignore + } + + CBlockFile SaveState; + if (OKAY != SaveState.Create(filePath, "Supermodel Save State", "Supermodel Version " SUPERMODEL_VERSION)) + { + ErrorLog("Unable to save state to '%s'.", filePath.c_str()); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Unable to save state to '%s'.", filePath.c_str()); + return; + } + + int32_t fileVersion = STATE_FILE_VERSION; + SaveState.Write(&fileVersion, sizeof(fileVersion)); + SaveState.Write(game.name); + model3->SaveState(&SaveState); + SaveState.Close(); + SDL_Log("Saved state to '%s'.", filePath.c_str()); + } + + void LoadState() + { + if (!model3 || game.name.empty()) + return; + + const std::string filePath = SaveStatePath(); + CBlockFile SaveState; + if (OKAY != SaveState.Load(filePath)) + { + ErrorLog("Unable to load state from '%s'.", filePath.c_str()); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Unable to load state from '%s'.", filePath.c_str()); + return; + } + + if (OKAY != SaveState.FindBlock("Supermodel Save State")) + { + ErrorLog("'%s' does not appear to be a valid save state file.", filePath.c_str()); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "'%s' does not appear to be a valid save state file.", filePath.c_str()); + return; + } + + int32_t fileVersion; + SaveState.Read(&fileVersion, sizeof(fileVersion)); + if (fileVersion != STATE_FILE_VERSION) + { + ErrorLog("'%s' is incompatible with this version of Supermodel.", filePath.c_str()); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "'%s' is incompatible with this version of Supermodel.", filePath.c_str()); + return; + } + + model3->LoadState(&SaveState); + SaveState.Close(); + SDL_Log("Loaded state from '%s'.", filePath.c_str()); } - } bool InstallNew3D(unsigned xOff, unsigned yOff, unsigned xRes, unsigned yRes, unsigned totalXRes, unsigned totalYRes) { @@ -493,6 +665,7 @@ extern "C" int SDL_main(int argc, char* argv[]) { } Super3Host host; + g_host = &host; // Initialize renderer backends up-front. The core will attach VRAM/palette/register // pointers later (after it has initialized the tile generator). host.render2d.Init(0, 0, 496, 384, 496, 384); @@ -708,5 +881,45 @@ extern "C" int SDL_main(int argc, char* argv[]) { SDL_GL_DeleteContext(gl); SDL_DestroyWindow(window); SDL_Quit(); + g_host = nullptr; return 0; } + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_izzy2lost_super3_Super3Activity_nativeSetMenuPaused(JNIEnv*, jobject, jboolean paused) +{ + if (!g_host) + return JNI_FALSE; + g_host->requestMenuPause.store(paused ? 1 : 0, std::memory_order_release); + return JNI_TRUE; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_izzy2lost_super3_Super3Activity_nativeRequestSaveState(JNIEnv*, jobject, jint slot) +{ + if (!g_host) + return JNI_FALSE; + const int clamped = (slot < 0) ? 0 : (slot > 9 ? 9 : slot); + g_host->requestSaveSlot.store(clamped, std::memory_order_release); + return JNI_TRUE; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_izzy2lost_super3_Super3Activity_nativeRequestLoadState(JNIEnv*, jobject, jint slot) +{ + if (!g_host) + return JNI_FALSE; + const int clamped = (slot < 0) ? 0 : (slot > 9 ? 9 : slot); + g_host->requestLoadSlot.store(clamped, std::memory_order_release); + return JNI_TRUE; +} + +extern "C" JNIEXPORT jstring JNICALL +Java_com_izzy2lost_super3_Super3Activity_nativeGetLoadedGameName(JNIEnv* env, jobject) +{ + if (!g_host) + return nullptr; + if (g_host->game.name.empty()) + return nullptr; + return env->NewStringUTF(g_host->game.name.c_str()); +} diff --git a/android/app/src/main/java/com/izzy2lost/super3/Super3Activity.kt b/android/app/src/main/java/com/izzy2lost/super3/Super3Activity.kt index 8d8336a..5c6ca0e 100644 --- a/android/app/src/main/java/com/izzy2lost/super3/Super3Activity.kt +++ b/android/app/src/main/java/com/izzy2lost/super3/Super3Activity.kt @@ -1,7 +1,14 @@ package com.izzy2lost.super3 +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Build +import android.view.PixelCopy +import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.View @@ -9,8 +16,17 @@ import android.view.ViewGroup import android.widget.ImageButton import android.widget.LinearLayout import android.widget.RelativeLayout +import android.view.SurfaceView +import android.widget.TextView +import androidx.appcompat.app.AlertDialog import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import java.io.File +import java.io.FileOutputStream +import java.text.DateFormat +import java.util.Date import kotlin.concurrent.thread import org.libsdl.app.SDLActivity @@ -19,7 +35,26 @@ import org.libsdl.app.SDLActivity * library specified by SDL_MAIN_LIBRARY (set to "super3" in the manifest). */ class Super3Activity : SDLActivity() { + private val prefs by lazy { getSharedPreferences("super3_prefs", MODE_PRIVATE) } + private val mainHandler = Handler(Looper.getMainLooper()) private var overlayView: View? = null + private var overlayControlsEnabled: Boolean = true + private var menuPaused = false + private var saveDialogOpen = false + private var exitDialogOpen = false + private var userPaused = false + private var saveStateSlot = 0 + private var capturingThumbnail = false + private var gameName: String = "" + private var userDataRoot: File? = null + + private data class SaveSlot( + val slotIndex: Int, + val title: String, + val subtitle: String, + val hasData: Boolean, + val screenshotPath: String?, + ) override fun getLibraries(): Array = arrayOf( "SDL2", @@ -43,9 +78,16 @@ class Super3Activity : SDLActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val overlaysEnabled = getSharedPreferences("super3_prefs", MODE_PRIVATE) - .getBoolean("overlay_controls_enabled", true) - if (!overlaysEnabled) return + overlayControlsEnabled = prefs.getBoolean("overlay_controls_enabled", true) + saveStateSlot = prefs.getInt("save_state_slot", 0).coerceIn(0, 9) + gameName = intent.getStringExtra("gameName").orEmpty() + val userDataRootPath = intent.getStringExtra("userDataRoot").orEmpty() + userDataRoot = + if (userDataRootPath.isNotBlank()) { + File(userDataRootPath) + } else { + getExternalFilesDir(null)?.let { File(it, "super3") } + } val root = SDLActivity.getContentView() as? RelativeLayout ?: return if (overlayView != null) return @@ -60,7 +102,22 @@ class Super3Activity : SDLActivity() { ), ) - val game = intent.getStringExtra("gameName").orEmpty() + if (!overlayControlsEnabled) { + overlay.findViewById(R.id.overlay_controls_root)?.visibility = View.GONE + overlay.visibility = View.GONE + return + } + + overlay.findViewById(R.id.overlay_save_state)?.setOnClickListener { + showSaveStateDialog() + } + + overlay.findViewById(R.id.overlay_pause)?.setOnClickListener { + userPaused = !userPaused + updatePauseState() + } + + val game = gameName val gamesXml = intent.getStringExtra("gamesXmlPath").orEmpty() val isRacing = game.isNotBlank() && @@ -254,6 +311,20 @@ class Super3Activity : SDLActivity() { } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_BACK) { + if (event.action == KeyEvent.ACTION_UP) { + handleBackPress() + } + return true + } + return super.dispatchKeyEvent(event) + } + + override fun onBackPressed() { + handleBackPress() + } + override fun onDestroy() { overlayView?.let { v -> (v.parent as? ViewGroup)?.removeView(v) @@ -283,4 +354,303 @@ class Super3Activity : SDLActivity() { UserDataSync.syncInternalIntoTree(this, internalRoot, treeUri) } } + + private fun handleBackPress() { + if (exitDialogOpen) return + exitDialogOpen = true + updatePauseState() + MaterialAlertDialogBuilder( + this, + com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog, + ) + .setTitle(R.string.exit_game_title) + .setMessage(R.string.exit_game_message) + .setPositiveButton(R.string.exit_game_confirm) { _, _ -> + finish() + } + .setNegativeButton(R.string.exit_game_cancel) { dialog, _ -> + dialog.dismiss() + } + .setOnDismissListener { + exitDialogOpen = false + updatePauseState() + } + .show() + } + + private fun updatePauseState() { + val shouldPause = userPaused || exitDialogOpen || saveDialogOpen || capturingThumbnail + if (shouldPause == menuPaused) return + menuPaused = shouldPause + nativeSetMenuPaused(shouldPause) + } + + private fun showSaveStateDialog() { + val view = layoutInflater.inflate(R.layout.dialog_saves, null, false) + val recyclerView = view.findViewById(R.id.rv_save_slots) + recyclerView.layoutManager = LinearLayoutManager(this) + + val slots = buildSaveSlots() + var dialog: AlertDialog? = null + val adapter = + SaveSlotAdapter( + slots, + onSave = { slot -> + saveStateToSlot(slot) + dialog?.dismiss() + }, + onLoad = { slot -> + loadStateFromSlot(slot) + dialog?.dismiss() + }, + ) + recyclerView.adapter = adapter + + saveDialogOpen = true + updatePauseState() + dialog = + MaterialAlertDialogBuilder( + this, + com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog, + ) + .setTitle(getString(R.string.save_state_dialog_title)) + .setView(view) + .setNegativeButton(android.R.string.cancel) { d, _ -> + d.dismiss() + } + .setOnDismissListener { + saveDialogOpen = false + updatePauseState() + } + .create() + dialog.show() + } + + private fun buildSaveSlots(): List { + val formatter = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + val baseName = resolveSaveStateBaseName() + val slots = ArrayList(10) + for (i in 0 until 10) { + val title = getString(R.string.save_state_slot_format, i + 1) + val file = saveStateFile(i, baseName) + if (file != null && file.exists()) { + val stamp = formatter.format(Date(file.lastModified())) + val subtitle = getString(R.string.save_state_slot_saved_format, stamp) + val screenshot = saveStateScreenshotFile(i, baseName) + slots.add( + SaveSlot( + slotIndex = i, + title = title, + subtitle = subtitle, + hasData = true, + screenshotPath = screenshot?.takeIf { it.exists() }?.absolutePath, + ), + ) + } else { + slots.add( + SaveSlot( + slotIndex = i, + title = title, + subtitle = getString(R.string.save_state_slot_empty), + hasData = false, + screenshotPath = null, + ), + ) + } + } + return slots + } + + private fun resolveSaveStateBaseName(): String { + val nativeName = runCatching { nativeGetLoadedGameName() }.getOrNull() + if (!nativeName.isNullOrBlank()) return nativeName + return gameName + } + + private fun saveStateFile(slotIndex: Int, baseName: String = resolveSaveStateBaseName()): File? { + val root = userDataRoot ?: return null + if (baseName.isBlank()) return null + val slot = slotIndex.coerceIn(0, 9) + return File(File(root, "Saves"), "${baseName}.st$slot") + } + + private fun saveStateScreenshotFile(slotIndex: Int, baseName: String = resolveSaveStateBaseName()): File? { + val root = userDataRoot ?: return null + if (baseName.isBlank()) return null + val slot = slotIndex.coerceIn(0, 9) + return File(File(root, "Saves"), "${baseName}.st$slot.png") + } + + private fun saveStateToSlot(targetSlot: Int) { + val clamped = targetSlot.coerceIn(0, 9) + nativeRequestSaveState(clamped) + captureSaveStateScreenshot(clamped) + + saveStateSlot = clamped + prefs.edit().putInt("save_state_slot", saveStateSlot).apply() + } + + private fun loadStateFromSlot(targetSlot: Int) { + val clamped = targetSlot.coerceIn(0, 9) + nativeRequestLoadState(clamped) + + saveStateSlot = clamped + prefs.edit().putInt("save_state_slot", saveStateSlot).apply() + } + + private fun captureSaveStateScreenshot(slotIndex: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val outFile = saveStateScreenshotFile(slotIndex) ?: return + + val surfaceView = findSdlSurfaceView() + val w = surfaceView?.width ?: window.decorView.width + val h = surfaceView?.height ?: window.decorView.height + if (w <= 0 || h <= 0) { + window.decorView.post { captureSaveStateScreenshot(slotIndex) } + return + } + + capturingThumbnail = true + updatePauseState() + + // Give the UI a moment to dismiss the dialog and redraw the game frame. + mainHandler.postDelayed({ + val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + val requestTarget: Any? = surfaceView ?: window + val callback = PixelCopy.OnPixelCopyFinishedListener { result -> + try { + if (result == PixelCopy.SUCCESS) { + outFile.parentFile?.mkdirs() + val scaled = scaleDown(bitmap, targetWidth = 640) + FileOutputStream(outFile).use { fos -> + scaled.compress(Bitmap.CompressFormat.PNG, 90, fos) + } + if (scaled !== bitmap) scaled.recycle() + } + } catch (_: Throwable) { + } finally { + bitmap.recycle() + capturingThumbnail = false + updatePauseState() + } + } + when (requestTarget) { + is SurfaceView -> PixelCopy.request(requestTarget, bitmap, callback, mainHandler) + else -> PixelCopy.request(window, bitmap, callback, mainHandler) + } + }, 250L) + } + + private fun scaleDown(src: Bitmap, targetWidth: Int): Bitmap { + if (targetWidth <= 0) return src + if (src.width <= targetWidth) return src + val targetHeight = (src.height.toFloat() * (targetWidth.toFloat() / src.width.toFloat())).toInt().coerceAtLeast(1) + return Bitmap.createScaledBitmap(src, targetWidth, targetHeight, true) + } + + private fun findSdlSurfaceView(): SurfaceView? { + val root = SDLActivity.getContentView() as? ViewGroup ?: return null + return findFirstSurfaceView(root) + } + + private fun findFirstSurfaceView(view: View): SurfaceView? { + if (view is SurfaceView) return view + if (view !is ViewGroup) return null + for (i in 0 until view.childCount) { + val found = findFirstSurfaceView(view.getChildAt(i)) + if (found != null) return found + } + return null + } + + private external fun nativeSetMenuPaused(paused: Boolean): Boolean + private external fun nativeRequestSaveState(slot: Int): Boolean + private external fun nativeRequestLoadState(slot: Int): Boolean + private external fun nativeGetLoadedGameName(): String? + + private class SaveSlotAdapter( + private val slots: List, + private val onSave: (Int) -> Unit, + private val onLoad: (Int) -> Unit, + ) : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_save_slot, parent, false) + return VH(view) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + holder.bind(slots[position], onSave, onLoad) + } + + override fun getItemCount(): Int = slots.size + + class VH(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val title: TextView = itemView.findViewById(R.id.tv_slot_title) + private val subtitle: TextView = itemView.findViewById(R.id.tv_slot_timestamp) + private val screenshot: android.widget.ImageView = itemView.findViewById(R.id.iv_slot_screenshot) + private val saveButton: MaterialButton = itemView.findViewById(R.id.btn_slot_save) + private val loadButton: MaterialButton = itemView.findViewById(R.id.btn_slot_load) + + fun bind(slot: SaveSlot, onSave: (Int) -> Unit, onLoad: (Int) -> Unit) { + title.text = slot.title + subtitle.text = slot.subtitle + + val screenshotPath = slot.screenshotPath + if (!screenshotPath.isNullOrBlank()) { + val bmp = decodeSampledBitmap(screenshotPath, reqW = 240, reqH = 160) + if (bmp != null) { + screenshot.setImageBitmap(bmp) + screenshot.visibility = View.VISIBLE + screenshot.setOnClickListener { + showEnlargedScreenshot(itemView, screenshotPath, slot.title) + } + } else { + screenshot.setImageDrawable(null) + screenshot.visibility = View.GONE + screenshot.setOnClickListener(null) + } + } else { + screenshot.setImageDrawable(null) + screenshot.visibility = View.GONE + screenshot.setOnClickListener(null) + } + + saveButton.setOnClickListener { onSave(slot.slotIndex) } + loadButton.isEnabled = slot.hasData + loadButton.alpha = if (slot.hasData) 1.0f else 0.5f + loadButton.setOnClickListener { + if (slot.hasData) { + onLoad(slot.slotIndex) + } + } + } + + private fun showEnlargedScreenshot(anchor: View, path: String, title: String) { + val context = anchor.context + val bmp = BitmapFactory.decodeFile(path) ?: return + val dialogView = LayoutInflater.from(context).inflate(R.layout.dialog_screenshot_preview, null, false) + dialogView.findViewById(R.id.tv_screenshot_title)?.text = title + dialogView.findViewById(R.id.iv_enlarged_screenshot)?.setImageBitmap(bmp) + MaterialAlertDialogBuilder( + context, + com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog, + ) + .setView(dialogView) + .setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() } + .setOnDismissListener { bmp.recycle() } + .show() + } + + private fun decodeSampledBitmap(path: String, reqW: Int, reqH: Int): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sample = 1 + while ((bounds.outWidth / sample) > reqW * 2 || (bounds.outHeight / sample) > reqH * 2) { + sample *= 2 + } + return BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample }) + } + } + } } diff --git a/android/app/src/main/res/drawable/hard_drive_24px.xml b/android/app/src/main/res/drawable/hard_drive_24px.xml new file mode 100644 index 0000000..2447558 --- /dev/null +++ b/android/app/src/main/res/drawable/hard_drive_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/play_pause_24px.xml b/android/app/src/main/res/drawable/play_pause_24px.xml new file mode 100644 index 0000000..78262ca --- /dev/null +++ b/android/app/src/main/res/drawable/play_pause_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/save_24px.xml b/android/app/src/main/res/drawable/save_24px.xml new file mode 100644 index 0000000..56e551e --- /dev/null +++ b/android/app/src/main/res/drawable/save_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/layout/dialog_saves.xml b/android/app/src/main/res/layout/dialog_saves.xml new file mode 100644 index 0000000..06f8fcc --- /dev/null +++ b/android/app/src/main/res/layout/dialog_saves.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_screenshot_preview.xml b/android/app/src/main/res/layout/dialog_screenshot_preview.xml new file mode 100644 index 0000000..c8593f3 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_screenshot_preview.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/item_save_slot.xml b/android/app/src/main/res/layout/item_save_slot.xml new file mode 100644 index 0000000..281165b --- /dev/null +++ b/android/app/src/main/res/layout/item_save_slot.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/overlay_controls.xml b/android/app/src/main/res/layout/overlay_controls.xml index de46ef4..6ce907a 100644 --- a/android/app/src/main/res/layout/overlay_controls.xml +++ b/android/app/src/main/res/layout/overlay_controls.xml @@ -9,155 +9,192 @@ android:focusable="false" android:importantForAccessibility="no"> - - - - - - - - - - - - - + - - + android:layout_gravity="top|start" + android:layout_margin="12dp" + android:orientation="vertical"> + + + android:src="@drawable/save_24px" + android:tint="@color/brand_silver" /> + + + + + + + + + + + + + + + + + + + android:layout_width="match_parent" + android:layout_height="6dp" /> - + + + + + + + + - - diff --git a/android/app/src/main/res/menu/game_drawer_menu.xml b/android/app/src/main/res/menu/game_drawer_menu.xml new file mode 100644 index 0000000..de59427 --- /dev/null +++ b/android/app/src/main/res/menu/game_drawer_menu.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 0261c89..84d273a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,16 @@ SUPER3 + Save state + Exit game + Save states + Choose a save slot: + Slot %1$d + Saved %1$s + Empty + Save + Load + Exit game? + Are you sure you want to exit the game? + Exit + Cancel