mirror of
https://github.com/izzy2lost/Super3.git
synced 2026-07-05 15:18:38 -07:00
Added save states more ui buttons etc.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user