Added save states more ui buttons etc.

This commit is contained in:
izzy2lost
2025-12-19 11:54:15 -05:00
parent 5f2fb09798
commit b6f1348c72
11 changed files with 964 additions and 152 deletions
+226 -13
View File
@@ -1,6 +1,7 @@
#include <SDL.h>
#include <SDL_main.h>
#include <SDL_system.h>
#include <jni.h>
#include <string>
#include <filesystem>
#include <optional>
@@ -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<bool> ready{false};
std::string userDataRoot;
unsigned saveSlot = 0;
std::atomic<int> requestSaveSlot{-1};
std::atomic<int> requestLoadSlot{-1};
std::atomic<int> 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<unsigned>(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<unsigned>(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<int>(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<int>(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());
}
@@ -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<String> = 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<View>(R.id.overlay_controls_root)?.visibility = View.GONE
overlay.visibility = View.GONE
return
}
overlay.findViewById<View>(R.id.overlay_save_state)?.setOnClickListener {
showSaveStateDialog()
}
overlay.findViewById<View>(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<RecyclerView>(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<SaveSlot> {
val formatter = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
val baseName = resolveSaveStateBaseName()
val slots = ArrayList<SaveSlot>(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<SaveSlot>,
private val onSave: (Int) -> Unit,
private val onLoad: (Int) -> Unit,
) : RecyclerView.Adapter<SaveSlotAdapter.VH>() {
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<TextView>(R.id.tv_screenshot_title)?.text = title
dialogView.findViewById<android.widget.ImageView>(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 })
}
}
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M160,680L800,680Q800,680 800,680Q800,680 800,680L800,440L160,440L160,680Q160,680 160,680Q160,680 160,680ZM680,620Q705,620 722.5,602.5Q740,585 740,560Q740,535 722.5,517.5Q705,500 680,500Q655,500 637.5,517.5Q620,535 620,560Q620,585 637.5,602.5Q655,620 680,620ZM880,360L767,360L687,280L273,280L193,360L80,360L217,223Q228,212 242.5,206Q257,200 273,200L687,200Q703,200 717.5,206Q732,212 743,223L880,360ZM160,760Q127,760 103.5,736.5Q80,713 80,680L80,360L880,360L880,680Q880,713 856.5,736.5Q833,760 800,760L160,760Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M200,648L200,312L440,480L200,648ZM520,640L520,320L600,320L600,640L520,640ZM680,640L680,320L760,320L760,640L680,640Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M840,280L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120L840,280ZM760,314L646,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200L200,314Z" />
</vector>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/save_dialog_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/save_state_choose_slot"
android:textSize="14sp"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_save_slots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxHeight="400dp"
android:scrollbars="vertical" />
</LinearLayout>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_screenshot_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Save Slot 1"
android:textStyle="bold"
android:textSize="18sp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ImageView
android:id="@+id/iv_enlarged_screenshot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
android:layout_gravity="center"
android:background="#22000000"
android:padding="4dp" />
</ScrollView>
</LinearLayout>
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground">
<ImageView
android:id="@+id/iv_slot_screenshot"
android:layout_width="60dp"
android:layout_height="40dp"
android:scaleType="centerCrop"
android:adjustViewBounds="true"
android:layout_marginEnd="12dp"
android:visibility="gone"
android:clickable="true"
android:focusable="true"
android:padding="2dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_slot_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/save_state_slot_format"
android:textStyle="bold"
android:textSize="16sp" />
<TextView
android:id="@+id/tv_slot_timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/save_state_slot_empty"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginStart="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_slot_save"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="@string/save_state_action_save"
android:textSize="12sp"
android:layout_marginEnd="8dp"
android:minWidth="64dp"
app:icon="@drawable/save_24px"
app:iconPadding="6dp"
app:iconGravity="textStart" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_slot_load"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="@string/save_state_action_load"
android:textSize="12sp"
android:minWidth="64dp"
app:icon="@drawable/hard_drive_24px"
app:iconPadding="6dp"
app:iconGravity="textStart" />
</LinearLayout>
</LinearLayout>
@@ -9,155 +9,192 @@
android:focusable="false"
android:importantForAccessibility="no">
<ImageButton
android:id="@+id/overlay_coin"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_gravity="bottom|start"
android:layout_margin="16dp"
android:background="@drawable/overlay_ripple_circle"
android:contentDescription="Coin"
android:padding="10dp"
android:scaleType="fitCenter"
android:src="@drawable/coin" />
<ImageButton
android:id="@+id/overlay_wheel"
android:layout_width="190dp"
android:layout_height="190dp"
android:layout_gravity="bottom|start"
android:layout_marginStart="24dp"
android:layout_marginBottom="64dp"
android:background="@drawable/overlay_ripple_circle"
android:contentDescription="Steering"
android:padding="12dp"
android:scaleType="fitCenter"
android:src="@drawable/wheel"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/overlay_service"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:layout_margin="12dp"
android:minHeight="0dp"
android:minWidth="0dp"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:text="SERVICE"
android:textAllCaps="true"
android:textSize="12sp"
app:rippleColor="@color/overlay_ripple"
app:strokeWidth="2dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/overlay_test"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_margin="12dp"
android:minHeight="0dp"
android:minWidth="0dp"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:text="TEST"
android:textAllCaps="true"
android:textSize="12sp"
app:rippleColor="@color/overlay_ripple"
app:strokeWidth="2dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/overlay_start"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="18dp"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="START"
android:textAllCaps="true"
app:rippleColor="@color/overlay_ripple"
app:strokeWidth="2dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/overlay_reload"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:minHeight="0dp"
android:minWidth="0dp"
android:paddingHorizontal="14dp"
android:paddingVertical="10dp"
android:text="RELOAD"
android:textAllCaps="true"
android:textSize="12sp"
android:visibility="gone"
app:rippleColor="@color/overlay_ripple"
app:strokeWidth="2dp" />
<LinearLayout
android:id="@+id/overlay_pedals"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:clickable="false"
android:focusable="false"
android:orientation="vertical"
android:visibility="gone">
<ImageButton
android:id="@+id/overlay_coin"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_gravity="bottom|start"
android:layout_margin="16dp"
android:background="@drawable/overlay_ripple_circle"
android:contentDescription="Coin"
android:padding="10dp"
android:scaleType="fitCenter"
android:src="@drawable/coin" />
<ImageButton
android:id="@+id/overlay_shifter"
android:layout_width="92dp"
android:layout_height="120dp"
android:layout_gravity="end"
android:background="@drawable/overlay_ripple_rounded"
android:contentDescription="Shifter"
android:padding="8dp"
android:id="@+id/overlay_wheel"
android:layout_width="190dp"
android:layout_height="190dp"
android:layout_gravity="bottom|start"
android:layout_marginStart="24dp"
android:layout_marginBottom="64dp"
android:background="@drawable/overlay_ripple_circle"
android:contentDescription="Steering"
android:padding="12dp"
android:scaleType="fitCenter"
android:src="@drawable/shifter"
android:src="@drawable/wheel"
android:visibility="gone" />
<Space
android:layout_width="match_parent"
android:layout_height="6dp" />
<LinearLayout
android:id="@+id/overlay_pedal_row"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
android:layout_gravity="top|start"
android:layout_margin="12dp"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/overlay_service"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:text="SERVICE"
android:textAllCaps="true"
android:textSize="12sp"
app:rippleColor="@color/overlay_ripple"
app:strokeWidth="2dp" />
<ImageButton
android:id="@+id/overlay_brake"
android:layout_width="92dp"
android:layout_height="92dp"
android:background="@drawable/overlay_ripple_rounded"
android:contentDescription="Brake"
android:id="@+id/overlay_save_state"
android:layout_width="54dp"
android:layout_height="54dp"
android:layout_marginTop="10dp"
android:background="@drawable/overlay_ripple_circle"
android:contentDescription="Save states"
android:padding="8dp"
android:scaleType="fitCenter"
android:src="@drawable/brakepedal" />
android:src="@drawable/save_24px"
android:tint="@color/brand_silver" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_margin="12dp"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/overlay_test"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:text="TEST"
android:textAllCaps="true"
android:textSize="12sp"
app:rippleColor="@color/overlay_ripple"
app:strokeWidth="2dp" />
<ImageButton
android:id="@+id/overlay_pause"
android:layout_width="54dp"
android:layout_height="54dp"
android:layout_marginTop="10dp"
android:background="@drawable/overlay_ripple_circle"
android:contentDescription="Pause / resume"
android:padding="8dp"
android:scaleType="fitCenter"
android:src="@drawable/play_pause_24px"
android:tint="@color/brand_silver" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/overlay_start"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="18dp"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="START"
android:textAllCaps="true"
app:rippleColor="@color/overlay_ripple"
app:strokeWidth="2dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/overlay_reload"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:minHeight="0dp"
android:minWidth="0dp"
android:paddingHorizontal="14dp"
android:paddingVertical="10dp"
android:text="RELOAD"
android:textAllCaps="true"
android:textSize="12sp"
android:visibility="gone"
app:rippleColor="@color/overlay_ripple"
app:strokeWidth="2dp" />
<LinearLayout
android:id="@+id/overlay_pedals"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:clickable="false"
android:focusable="false"
android:orientation="vertical"
android:visibility="gone">
<ImageButton
android:id="@+id/overlay_shifter"
android:layout_width="92dp"
android:layout_height="120dp"
android:layout_gravity="end"
android:background="@drawable/overlay_ripple_rounded"
android:contentDescription="Shifter"
android:padding="8dp"
android:scaleType="fitCenter"
android:src="@drawable/shifter"
android:visibility="gone" />
<Space
android:layout_width="10dp"
android:layout_height="match_parent" />
android:layout_width="match_parent"
android:layout_height="6dp" />
<ImageButton
android:id="@+id/overlay_gas"
android:layout_width="92dp"
android:layout_height="92dp"
android:background="@drawable/overlay_ripple_rounded"
android:contentDescription="Gas"
android:padding="8dp"
android:scaleType="fitCenter"
android:src="@drawable/gaspedal" />
<LinearLayout
android:id="@+id/overlay_pedal_row"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageButton
android:id="@+id/overlay_brake"
android:layout_width="92dp"
android:layout_height="92dp"
android:background="@drawable/overlay_ripple_rounded"
android:contentDescription="Brake"
android:padding="8dp"
android:scaleType="fitCenter"
android:src="@drawable/brakepedal" />
<Space
android:layout_width="10dp"
android:layout_height="match_parent" />
<ImageButton
android:id="@+id/overlay_gas"
android:layout_width="92dp"
android:layout_height="92dp"
android:background="@drawable/overlay_ripple_rounded"
android:contentDescription="Gas"
android:padding="8dp"
android:scaleType="fitCenter"
android:src="@drawable/gaspedal" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_save_state"
android:title="@string/game_menu_save_state"
android:checkable="false" />
<item
android:id="@+id/action_exit_game"
android:title="@string/game_menu_exit_game"
android:checkable="false" />
</menu>
@@ -1,3 +1,16 @@
<resources>
<string name="app_name">SUPER3</string>
<string name="game_menu_save_state">Save state</string>
<string name="game_menu_exit_game">Exit game</string>
<string name="save_state_dialog_title">Save states</string>
<string name="save_state_choose_slot">Choose a save slot:</string>
<string name="save_state_slot_format">Slot %1$d</string>
<string name="save_state_slot_saved_format">Saved %1$s</string>
<string name="save_state_slot_empty">Empty</string>
<string name="save_state_action_save">Save</string>
<string name="save_state_action_load">Load</string>
<string name="exit_game_title">Exit game?</string>
<string name="exit_game_message">Are you sure you want to exit the game?</string>
<string name="exit_game_confirm">Exit</string>
<string name="exit_game_cancel">Cancel</string>
</resources>