Save and load reorg and keyboard handling (#6929)

* Load and Save Game Screens rework - Linting

* Load and Save Game Screens rework - Modularize and Keyboard

* Load and Save Game Screens rework - error handling

* Load and Save Game Screens rework - Move other save/load code

* Load and Save Game Screens rework - More Keyboard

* Load and Save Game Screens rework - Increase clipboard limit

* Load and Save Game Screens rework - Post-merge patch

* Load and Save Game Screens rework - Home, End, harden

* Load and Save Game Screens rework - Post-merge patch again

* Load and Save Game Screens rework - reviews
This commit is contained in:
SomeTroglodyte
2022-05-31 15:31:19 +02:00
committed by GitHub
parent 983a9b705e
commit 1b008905f6
15 changed files with 627 additions and 341 deletions

View File

@ -598,6 +598,9 @@ Copy saved game to clipboard =
Could not load game =
Load [saveFileName] =
Delete save =
[saveFileName] deleted successfully. =
Insufficient permissions to delete [saveFileName]. =
Failed to delete [saveFileName]. =
Saved at =
Saving... =
Overwrite existing file? =
@ -610,6 +613,7 @@ Load from custom location =
Could not load game from custom location! =
Save to custom location =
Could not save game to custom location! =
Download missing mods =
Missing mods are downloaded successfully. =
Could not load the missing mods! =
Could not download mod list. =

View File

@ -66,6 +66,7 @@ object Constants {
const val close = "Close"
const val yes = "Yes"
const val no = "No"
const val loading = "Loading..."
const val barbarians = "Barbarians"
const val spectator = "Spectator"

View File

@ -27,6 +27,7 @@ import com.unciv.ui.newgamescreen.NewGameScreen
import com.unciv.ui.pickerscreens.ModManagementScreen
import com.unciv.ui.popup.*
import com.unciv.ui.saves.LoadGameScreen
import com.unciv.ui.saves.QuickSave
import com.unciv.ui.utils.*
import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip
import com.unciv.ui.worldscreen.mainmenu.WorldScreenMenuPopup
@ -175,49 +176,7 @@ class MainMenuScreen: BaseScreen() {
curWorldScreen.popups.filterIsInstance(WorldScreenMenuPopup::class.java).forEach(Popup::close)
return
}
val loadingPopup = Popup(this)
loadingPopup.addGoodSizedLabel("Loading...")
loadingPopup.open()
launchCrashHandling("autoLoadGame") {
// Load game from file to class on separate thread to avoid ANR...
fun outOfMemory() {
postCrashHandlingRunnable {
loadingPopup.close()
ToastPopup("Not enough memory on phone to load game!", this@MainMenuScreen)
}
}
val savedGame: GameInfo
try {
savedGame = game.gameSaver.loadLatestAutosave()
} catch (oom: OutOfMemoryError) {
outOfMemory()
return@launchCrashHandling
} catch (ex: Exception) {
postCrashHandlingRunnable {
loadingPopup.close()
ToastPopup("Cannot resume game!", this@MainMenuScreen)
}
return@launchCrashHandling
}
if (savedGame.gameParameters.isOnlineMultiplayer) {
try {
game.onlineMultiplayer.loadGame(savedGame)
} catch (oom: OutOfMemoryError) {
outOfMemory()
}
} else {
postCrashHandlingRunnable { /// ... and load it into the screen on main thread for GL context
try {
game.loadGame(savedGame)
} catch (oom: OutOfMemoryError) {
outOfMemory()
}
}
}
}
QuickSave.autoLoadGame(this)
}
private fun quickstartNewGame() {

View File

@ -73,8 +73,12 @@ class GameSaver(
fun canLoadFromCustomSaveLocation() = customFileLocationHelper != null
fun deleteSave(gameName: String) {
getSave(gameName).delete()
/** Deletes a save.
* @return `true` if successful.
* @throws SecurityException when delete access was denied
*/
fun deleteSave(gameName: String): Boolean {
return getSave(gameName).delete()
}
fun deleteMultiplayerSave(gameName: String) {

View File

@ -204,7 +204,7 @@ class CityConstructionsTable(private val cityScreen: CityScreen) {
val constructionsScrollY = availableConstructionsScrollPane.scrollY
if (!availableConstructionsTable.hasChildren()) { //
availableConstructionsTable.add("Loading...".toLabel()).pad(10f)
availableConstructionsTable.add(Constants.loading.toLabel()).pad(10f)
}
launchCrashHandling("Construction info gathering - ${cityScreen.city.name}") {

View File

@ -5,6 +5,7 @@ import com.badlogic.gdx.Input
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.Constants
import com.unciv.logic.MapSaver
import com.unciv.logic.UncivShowableException
import com.unciv.models.ruleset.RulesetCache
@ -89,7 +90,7 @@ class MapEditorLoadTab(
Gdx.app.postRunnable {
if (!needPopup) return@postRunnable
popup = Popup(editorScreen).apply {
addGoodSizedLabel("Loading...")
addGoodSizedLabel(Constants.loading)
open()
}
}

View File

@ -1,13 +1,14 @@
package com.unciv.ui.multiplayer
import com.unciv.Constants
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.center
import com.unciv.ui.utils.toLabel
class LoadDeepLinkScreen : BaseScreen() {
init {
val loadingLabel = "Loading...".toLabel()
val loadingLabel = Constants.loading.toLabel()
stage.addActor(loadingLabel)
loadingLabel.center(stage)
}
}
}

View File

@ -26,7 +26,7 @@ open class PickerScreen(disableScroll: Boolean = false) : BaseScreen() {
init {
pickerPane.setFillParent(true)
stage.addActor(pickerPane)
ensureLayout()
ensureLayout()
}
/** Make sure that anyone relying on sizes of the tables within this class during construction gets correct size readings.
@ -39,7 +39,7 @@ open class PickerScreen(disableScroll: Boolean = false) : BaseScreen() {
* Initializes the [Close button][closeButton]'s action (and the Back/ESC handler)
* to return to the [previousScreen] if specified, or else to the world screen.
*/
fun setDefaultCloseAction(previousScreen: BaseScreen?=null) {
fun setDefaultCloseAction(previousScreen: BaseScreen? = null) {
val closeAction = {
if (previousScreen != null) game.setScreen(previousScreen)
else game.resetToWorldScreen()

View File

@ -3,12 +3,10 @@ package com.unciv.ui.saves
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.ui.CheckBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame
import com.badlogic.gdx.utils.SerializationException
import com.unciv.Constants
import com.unciv.logic.GameSaver
import com.unciv.logic.MissingModsException
import com.unciv.logic.UncivShowableException
@ -16,90 +14,126 @@ import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.translations.tr
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.pickerscreens.Github
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.utils.*
import com.unciv.ui.utils.UncivDateFormat.formatDate
import java.util.*
import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip
import java.io.FileNotFoundException
import java.util.concurrent.CancellationException
import com.unciv.ui.utils.AutoScrollPane as ScrollPane
class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = true) {
lateinit var selectedSave: String
private val copySavedGameToClipboardButton = "Copy saved game to clipboard".toTextButton()
private val saveTable = Table()
private val deleteSaveButton = "Delete save".toTextButton()
private val errorLabel = "".toLabel(Color.RED)
private val loadMissingModsButton = "Download missing mods".toTextButton()
private val showAutosavesCheckbox = CheckBox("Show autosaves".tr(), skin)
class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() {
private val copySavedGameToClipboardButton = getCopyExistingSaveToClipboardButton()
private val errorLabel = "".toLabel(Color.RED).apply { isVisible = false }
private val loadMissingModsButton = getLoadMissingModsButton()
private var missingModsToLoad = ""
companion object {
private const val loadGame = "Load game"
private const val loadFromCustomLocation = "Load from custom location"
private const val loadFromClipboard = "Load copied data"
private const val copyExistingSaveToClipboard = "Copy saved game to clipboard"
private const val downloadMissingMods = "Download missing mods"
}
init {
setDefaultCloseAction(previousScreen)
rightSideTable.initRightSideTable()
rightSideButton.onClick(::onLoadGame)
keyPressDispatcher[KeyCharAndCode.RETURN] = ::onLoadGame
}
resetWindowState()
topTable.add(ScrollPane(saveTable))
override fun resetWindowState() {
super.resetWindowState()
copySavedGameToClipboardButton.disable()
rightSideButton.setText(loadGame.tr())
rightSideButton.disable()
}
val rightSideTable = getRightSideTable()
override fun onExistingSaveSelected(saveGameFile: FileHandle) {
copySavedGameToClipboardButton.enable()
rightSideButton.setText("Load [$selectedSave]".tr())
rightSideButton.enable()
}
topTable.add(rightSideTable)
private fun Table.initRightSideTable() {
add(getLoadFromClipboardButton()).row()
addLoadFromCustomLocationButton()
add(errorLabel).row()
add(loadMissingModsButton).row()
add(deleteSaveButton).row()
add(copySavedGameToClipboardButton).row()
add(showAutosavesCheckbox).row()
}
rightSideButton.onClick {
val loadingPopup = Popup( this)
loadingPopup.addGoodSizedLabel("Loading...")
loadingPopup.open()
launchCrashHandling("Load Game") {
try {
// This is what can lead to ANRs - reading the file and setting the transients, that's why this is in another thread
val loadedGame = game.gameSaver.loadGameByName(selectedSave)
postCrashHandlingRunnable { UncivGame.Current.loadGame(loadedGame) }
} catch (ex: Exception) {
postCrashHandlingRunnable {
loadingPopup.close()
val cantLoadGamePopup = Popup(this@LoadGameScreen)
cantLoadGamePopup.addGoodSizedLabel("It looks like your saved game can't be loaded!").row()
if (ex is UncivShowableException && ex.localizedMessage != null) {
// thrown exceptions are our own tests and can be shown to the user
cantLoadGamePopup.addGoodSizedLabel(ex.localizedMessage).row()
cantLoadGamePopup.addCloseButton()
cantLoadGamePopup.open()
} else {
cantLoadGamePopup.addGoodSizedLabel("If you could copy your game data (\"Copy saved game to clipboard\" - ").row()
cantLoadGamePopup.addGoodSizedLabel(" paste into an email to yairm210@hotmail.com)").row()
cantLoadGamePopup.addGoodSizedLabel("I could maybe help you figure out what went wrong, since this isn't supposed to happen!").row()
cantLoadGamePopup.addCloseButton()
cantLoadGamePopup.open()
ex.printStackTrace()
}
private fun onLoadGame() {
if (selectedSave.isEmpty()) return
val loadingPopup = Popup( this)
loadingPopup.addGoodSizedLabel(Constants.loading)
loadingPopup.open()
launchCrashHandling(loadGame) {
try {
// This is what can lead to ANRs - reading the file and setting the transients, that's why this is in another thread
val loadedGame = game.gameSaver.loadGameByName(selectedSave)
postCrashHandlingRunnable { game.loadGame(loadedGame) }
} catch (ex: Exception) {
postCrashHandlingRunnable {
loadingPopup.close()
if (ex is MissingModsException) {
handleLoadGameException("Could not load game", ex)
return@postCrashHandlingRunnable
}
val cantLoadGamePopup = Popup(this@LoadGameScreen)
cantLoadGamePopup.addGoodSizedLabel("It looks like your saved game can't be loaded!").row()
if (ex is SerializationException)
cantLoadGamePopup.addGoodSizedLabel("The file data seems to be corrupted.").row()
if (ex.cause is FileNotFoundException && (ex.cause as FileNotFoundException).message?.contains("Permission denied") == true) {
cantLoadGamePopup.addGoodSizedLabel("You do not have sufficient permissions to access the file.").row()
} else if (ex is UncivShowableException) {
// thrown exceptions are our own tests and can be shown to the user
cantLoadGamePopup.addGoodSizedLabel(ex.message).row()
} else {
cantLoadGamePopup.addGoodSizedLabel("If you could copy your game data (\"Copy saved game to clipboard\" - ").row()
cantLoadGamePopup.addGoodSizedLabel(" paste into an email to yairm210@hotmail.com)").row()
cantLoadGamePopup.addGoodSizedLabel("I could maybe help you figure out what went wrong, since this isn't supposed to happen!").row()
ex.printStackTrace()
}
cantLoadGamePopup.addCloseButton()
cantLoadGamePopup.open()
}
}
}
}
private fun getRightSideTable(): Table {
val rightSideTable = Table()
rightSideTable.defaults().pad(10f)
val loadFromClipboardButton = "Load copied data".toTextButton()
loadFromClipboardButton.onClick {
try {
val clipboardContentsString = Gdx.app.clipboard.contents.trim()
val loadedGame = GameSaver.gameInfoFromString(clipboardContentsString)
UncivGame.Current.loadGame(loadedGame)
} catch (ex: Exception) {
handleLoadGameException("Could not load game from clipboard!", ex)
private fun getLoadFromClipboardButton(): TextButton {
val pasteButton = loadFromClipboard.toTextButton()
val pasteHandler: ()->Unit = {
launchCrashHandling(loadFromClipboard) {
try {
val clipboardContentsString = Gdx.app.clipboard.contents.trim()
val loadedGame = GameSaver.gameInfoFromString(clipboardContentsString)
postCrashHandlingRunnable { game.loadGame(loadedGame) }
} catch (ex: Exception) {
postCrashHandlingRunnable { handleLoadGameException("Could not load game from clipboard!", ex) }
}
}
}
rightSideTable.add(loadFromClipboardButton).row()
if (game.gameSaver.canLoadFromCustomSaveLocation()) {
val loadFromCustomLocation = "Load from custom location".toTextButton()
loadFromCustomLocation.onClick {
game.gameSaver.loadGameFromCustomLocation { result ->
pasteButton.onClick(pasteHandler)
val ctrlV = KeyCharAndCode.ctrl('v')
keyPressDispatcher[ctrlV] = pasteHandler
pasteButton.addTooltip(ctrlV)
return pasteButton
}
private fun Table.addLoadFromCustomLocationButton() {
if (!game.gameSaver.canLoadFromCustomSaveLocation()) return
val loadFromCustomLocation = loadFromCustomLocation.toTextButton()
loadFromCustomLocation.onClick {
errorLabel.isVisible = false
loadFromCustomLocation.setText(Constants.loading.tr())
loadFromCustomLocation.disable()
launchCrashHandling(Companion.loadFromCustomLocation) {
game.gameSaver.loadGameFromCustomLocation { result ->
if (result.isError()) {
handleLoadGameException("Could not load game from custom location!", result.exception)
} else if (result.isSuccessful()) {
@ -107,73 +141,82 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t
}
}
}
rightSideTable.add(loadFromCustomLocation).row()
}
rightSideTable.add(errorLabel).row()
add(loadFromCustomLocation).row()
}
loadMissingModsButton.onClick {
private fun getCopyExistingSaveToClipboardButton(): TextButton {
val copyButton = copyExistingSaveToClipboard.toTextButton()
val copyHandler: ()->Unit = {
launchCrashHandling(copyExistingSaveToClipboard) {
try {
val gameText = game.gameSaver.getSave(selectedSave).readString()
Gdx.app.clipboard.contents = if (gameText[0] == '{') Gzip.zip(gameText) else gameText
} catch (ex: Throwable) {
ex.printStackTrace()
ToastPopup("Could not save game to clipboard!", this@LoadGameScreen)
}
}
}
copyButton.onClick(copyHandler)
copyButton.disable()
val ctrlC = KeyCharAndCode.ctrl('c')
keyPressDispatcher[ctrlC] = copyHandler
copyButton.addTooltip(ctrlC)
return copyButton
}
private fun getLoadMissingModsButton(): TextButton {
val button = downloadMissingMods.toTextButton()
button.onClick {
loadMissingMods()
}
loadMissingModsButton.isVisible = false
rightSideTable.add(loadMissingModsButton).row()
deleteSaveButton.onClick {
game.gameSaver.deleteSave(selectedSave)
resetWindowState()
}
deleteSaveButton.disable()
rightSideTable.add(deleteSaveButton).row()
copySavedGameToClipboardButton.disable()
copySavedGameToClipboardButton.onClick {
val gameText = game.gameSaver.getSave(selectedSave).readString()
val gzippedGameText = Gzip.zip(gameText)
Gdx.app.clipboard.contents = gzippedGameText
}
rightSideTable.add(copySavedGameToClipboardButton).row()
showAutosavesCheckbox.isChecked = false
showAutosavesCheckbox.onChange {
updateLoadableGames(showAutosavesCheckbox.isChecked)
}
rightSideTable.add(showAutosavesCheckbox).row()
return rightSideTable
button.isVisible = false
return button
}
private fun handleLoadGameException(primaryText: String, ex: Exception?) {
var errorText = primaryText.tr()
if (ex is UncivShowableException) errorText += "\n${ex.message}"
errorLabel.setText(errorText)
if (ex is UncivShowableException) errorText += "\n${ex.localizedMessage}"
ex?.printStackTrace()
if (ex is MissingModsException) {
loadMissingModsButton.isVisible = true
missingModsToLoad = ex.missingMods
postCrashHandlingRunnable {
errorLabel.setText(errorText)
errorLabel.isVisible = true
if (ex is MissingModsException) {
loadMissingModsButton.isVisible = true
missingModsToLoad = ex.missingMods
}
}
}
private fun loadMissingMods() {
loadMissingModsButton.isEnabled = false
descriptionLabel.setText("Loading...".tr())
launchCrashHandling("DownloadMods", runAsDaemon = false) {
descriptionLabel.setText(Constants.loading.tr())
launchCrashHandling(downloadMissingMods, runAsDaemon = false) {
try {
val mods = missingModsToLoad.replace(' ', '-').lowercase().splitToSequence(",-")
for (modName in mods) {
val repos = Github.tryGetGithubReposWithTopic(10, 1, modName)
?: throw UncivShowableException("Could not download mod list.".tr())
?: throw UncivShowableException("Could not download mod list.")
val repo = repos.items.firstOrNull { it.name.lowercase() == modName }
?: throw UncivShowableException("Could not find a mod named \"[$modName]\".".tr())
?: throw UncivShowableException("Could not find a mod named \"[$modName]\".")
val modFolder = Github.downloadAndExtract(
repo.html_url, repo.default_branch,
Gdx.files.local("mods")
)
?: throw Exception() // downloadAndExtract returns null for 404 errors and the like -> display something!
Github.rewriteModOptions(repo, modFolder)
val labelText = descriptionLabel.text // Surprise - a StringBuilder
labelText.appendLine()
labelText.append("[${repo.name}] Downloaded!".tr())
postCrashHandlingRunnable { descriptionLabel.setText(labelText) }
}
postCrashHandlingRunnable {
RulesetCache.loadRulesets()
missingModsToLoad = ""
loadMissingModsButton.isVisible = false
errorLabel.setText("")
errorLabel.isVisible = false
rightSideTable.pack()
ToastPopup("Missing mods are downloaded successfully.", this@LoadGameScreen)
}
} catch (ex: Exception) {
@ -182,74 +225,6 @@ class LoadGameScreen(previousScreen:BaseScreen) : PickerScreen(disableScroll = t
loadMissingModsButton.isEnabled = true
descriptionLabel.setText("")
}
}
}
private fun resetWindowState() {
updateLoadableGames(showAutosavesCheckbox.isChecked)
deleteSaveButton.disable()
copySavedGameToClipboardButton.disable()
rightSideButton.setText("Load game".tr())
rightSideButton.disable()
descriptionLabel.setText("")
}
private fun updateLoadableGames(showAutosaves: Boolean) {
saveTable.clear()
val loadImage = ImageGetter.getImage("OtherIcons/Load")
loadImage.setSize(50f, 50f) // So the origin sets correctly
loadImage.setOrigin(Align.center)
loadImage.addAction(Actions.rotateBy(360f, 2f))
saveTable.add(loadImage).size(50f)
// Apparently, even just getting the list of saves can cause ANRs -
// not sure how many saves these guys had but Google Play reports this to have happened hundreds of times
launchCrashHandling("GetSaves") {
// .toList() because otherwise the lastModified will only be checked inside the postRunnable
val saves = game.gameSaver.getSaves(autoSaves = showAutosaves).sortedByDescending { it.lastModified() }.toList()
postCrashHandlingRunnable {
saveTable.clear()
for (save in saves) {
val textButton = TextButton(save.name(), skin)
textButton.onClick { onSaveSelected(save) }
saveTable.add(textButton).pad(5f).row()
}
}
}
}
private fun onSaveSelected(save: FileHandle) {
selectedSave = save.name()
copySavedGameToClipboardButton.enable()
rightSideButton.setText("Load [${save.name()}]".tr())
rightSideButton.enable()
deleteSaveButton.enable()
deleteSaveButton.color = Color.RED
descriptionLabel.setText("Loading...".tr())
val savedAt = Date(save.lastModified())
var textToSet = save.name() + "\n${"Saved at".tr()}: " + savedAt.formatDate()
launchCrashHandling("LoadMetaData") { // Even loading the game to get its metadata can take a long time on older phones
try {
val game = game.gameSaver.loadGamePreviewFromFile(save)
val playerCivNames = game.civilizations.filter { it.isPlayerCivilization() }.joinToString { it.civName.tr() }
textToSet += "\n" + playerCivNames +
", " + game.difficulty.tr() + ", ${Fonts.turn}" + game.turns
textToSet += "\n${"Base ruleset:".tr()} " + game.gameParameters.baseRuleset
if (game.gameParameters.mods.isNotEmpty())
textToSet += "\n${"Mods:".tr()} " + game.gameParameters.mods.joinToString()
} catch (ex: Exception) {
textToSet += "\n${"Could not load game".tr()}!"
}
postCrashHandlingRunnable {
descriptionLabel.setText(textToSet)
}
}
}

View File

@ -0,0 +1,126 @@
package com.unciv.ui.saves
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.CheckBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.Constants
import com.unciv.models.translations.tr
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.utils.Fonts
import com.unciv.ui.utils.KeyCharAndCode
import com.unciv.ui.utils.UncivDateFormat.formatDate
import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip
import com.unciv.ui.utils.disable
import com.unciv.ui.utils.enable
import com.unciv.ui.utils.onChange
import com.unciv.ui.utils.onClick
import com.unciv.ui.utils.pad
import com.unciv.ui.utils.toLabel
import com.unciv.ui.utils.toTextButton
import java.util.Date
abstract class LoadOrSaveScreen(
fileListHeaderText: String? = null
) : PickerScreen(disableScroll = true) {
abstract fun onExistingSaveSelected(saveGameFile: FileHandle)
protected var selectedSave = ""
private set
private val savesScrollPane = VerticalFileListScrollPane(keyPressDispatcher)
protected val rightSideTable = Table()
protected val deleteSaveButton = "Delete save".toTextButton()
protected val showAutosavesCheckbox = CheckBox("Show autosaves".tr(), skin)
init {
savesScrollPane.onChange(::selectExistingSave)
rightSideTable.defaults().pad(5f, 10f)
showAutosavesCheckbox.isChecked = false
showAutosavesCheckbox.onChange {
updateShownSaves(showAutosavesCheckbox.isChecked)
}
val ctrlA = KeyCharAndCode.ctrl('a')
keyPressDispatcher[ctrlA] = { showAutosavesCheckbox.toggle() }
showAutosavesCheckbox.addTooltip(ctrlA)
deleteSaveButton.disable()
deleteSaveButton.onClick(::onDeleteClicked)
keyPressDispatcher[KeyCharAndCode.DEL] = ::onDeleteClicked
deleteSaveButton.addTooltip(KeyCharAndCode.DEL)
if (fileListHeaderText != null)
topTable.add(fileListHeaderText.toLabel()).pad(10f).row()
updateShownSaves(false)
topTable.add(savesScrollPane)
topTable.add(rightSideTable)
topTable.pack()
}
open fun resetWindowState() {
updateShownSaves(showAutosavesCheckbox.isChecked)
deleteSaveButton.disable()
descriptionLabel.setText("")
}
private fun onDeleteClicked() {
if (selectedSave.isEmpty()) return
val result = try {
if (game.gameSaver.deleteSave(selectedSave)) {
resetWindowState()
"[$selectedSave] deleted successfully."
} else "Failed to delete [$selectedSave]."
} catch (ex: SecurityException) {
"Insufficient permissions to delete [$selectedSave]."
} catch (ex: Throwable) {
"Failed to delete [$selectedSave]."
}
descriptionLabel.setText(result)
}
private fun updateShownSaves(showAutosaves: Boolean) {
savesScrollPane.updateSaveGames(game.gameSaver, showAutosaves)
}
private fun selectExistingSave(saveGameFile: FileHandle) {
deleteSaveButton.enable()
deleteSaveButton.color = Color.RED
selectedSave = saveGameFile.name()
showSaveInfo(saveGameFile)
onExistingSaveSelected(saveGameFile)
}
private fun showSaveInfo(saveGameFile: FileHandle) {
descriptionLabel.setText(Constants.loading.tr())
launchCrashHandling("LoadMetaData") { // Even loading the game to get its metadata can take a long time on older phones
val textToSet = try {
val savedAt = Date(saveGameFile.lastModified())
val game = game.gameSaver.loadGamePreviewFromFile(saveGameFile)
val playerCivNames = game.civilizations
.filter { it.isPlayerCivilization() }.joinToString { it.civName.tr() }
val mods = if (game.gameParameters.mods.isEmpty()) ""
else "\n{Mods:} " + game.gameParameters.mods.joinToString()
// Format result for textToSet
"${saveGameFile.name()}\n{Saved at}: ${savedAt.formatDate()}\n" +
"$playerCivNames, ${game.difficulty.tr()}, ${Fonts.turn}${game.turns}\n" +
"{Base ruleset:} ${game.gameParameters.baseRuleset}$mods"
} catch (ex: Exception) {
"\n{Could not load game}!"
}
postCrashHandlingRunnable {
descriptionLabel.setText(textToSet.tr())
}
}
}
}

View File

@ -0,0 +1,97 @@
package com.unciv.ui.saves
import com.unciv.Constants
import com.unciv.MainMenuScreen
import com.unciv.UncivGame
import com.unciv.logic.GameInfo
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.worldscreen.WorldScreen
//todo reduce code duplication
object QuickSave {
fun save(gameInfo: GameInfo, screen: WorldScreen) {
val gameSaver = UncivGame.Current.gameSaver
val toast = ToastPopup("Quicksaving...", screen)
launchCrashHandling("QuickSaveGame", runAsDaemon = false) {
gameSaver.saveGame(gameInfo, "QuickSave") {
postCrashHandlingRunnable {
toast.close()
if (it != null)
ToastPopup("Could not save game!", screen)
else
ToastPopup("Quicksave successful.", screen)
}
}
}
}
fun load(screen: WorldScreen) {
val gameSaver = UncivGame.Current.gameSaver
val toast = ToastPopup("Quickloading...", screen)
launchCrashHandling("QuickLoadGame") {
try {
val loadedGame = gameSaver.loadGameByName("QuickSave")
postCrashHandlingRunnable {
toast.close()
UncivGame.Current.loadGame(loadedGame)
ToastPopup("Quickload successful.", screen)
}
} catch (ex: Exception) {
postCrashHandlingRunnable {
toast.close()
ToastPopup("Could not load game!", screen)
}
}
}
}
fun autoLoadGame(screen: MainMenuScreen) {
val loadingPopup = Popup(screen)
loadingPopup.addGoodSizedLabel(Constants.loading)
loadingPopup.open()
launchCrashHandling("autoLoadGame") {
// Load game from file to class on separate thread to avoid ANR...
fun outOfMemory() {
postCrashHandlingRunnable {
loadingPopup.close()
ToastPopup("Not enough memory on phone to load game!", screen)
}
}
val savedGame: GameInfo
try {
savedGame = screen.game.gameSaver.loadLatestAutosave()
} catch (oom: OutOfMemoryError) {
outOfMemory()
return@launchCrashHandling
} catch (ex: Exception) {
postCrashHandlingRunnable {
loadingPopup.close()
ToastPopup("Cannot resume game!", screen)
}
return@launchCrashHandling
}
if (savedGame.gameParameters.isOnlineMultiplayer) {
try {
screen.game.onlineMultiplayer.loadGame(savedGame)
} catch (oom: OutOfMemoryError) {
outOfMemory()
}
} else {
postCrashHandlingRunnable { /// ... and load it into the screen on main thread for GL context
try {
screen.game.loadGame(savedGame)
} catch (oom: OutOfMemoryError) {
outOfMemory()
}
}
}
}
}
}

View File

@ -1,10 +1,9 @@
package com.unciv.ui.saves
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.CheckBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.UncivGame
import com.unciv.logic.GameInfo
@ -12,90 +11,99 @@ import com.unciv.logic.GameSaver
import com.unciv.models.translations.tr
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.popup.YesNoPopup
import com.unciv.ui.utils.*
import com.unciv.ui.utils.KeyCharAndCode
import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip
import com.unciv.ui.utils.disable
import com.unciv.ui.utils.enable
import com.unciv.ui.utils.onClick
import com.unciv.ui.utils.toLabel
import com.unciv.ui.utils.toTextButton
import java.util.concurrent.CancellationException
import kotlin.concurrent.thread
import com.unciv.ui.utils.AutoScrollPane as ScrollPane
class SaveGameScreen(val gameInfo: GameInfo) : PickerScreen(disableScroll = true) {
class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") {
private val gameNameTextField = TextField("", skin)
val currentSaves = Table()
init {
setDefaultCloseAction()
gameNameTextField.textFieldFilter = TextField.TextFieldFilter { _, char -> char != '\\' && char != '/' }
topTable.add("Current saves".toLabel()).pad(10f).row()
updateShownSaves(false)
topTable.add(ScrollPane(currentSaves))
val newSave = Table()
newSave.defaults().pad(5f, 10f)
val defaultSaveName = "[${gameInfo.currentPlayer}] - [${gameInfo.turns}] turns".tr()
gameNameTextField.text = defaultSaveName
newSave.add("Saved game name".toLabel()).row()
newSave.add(gameNameTextField).width(300f).row()
val copyJsonButton = "Copy to clipboard".toTextButton()
copyJsonButton.onClick {
thread(name="Copy to clipboard") { // the Gzip rarely leads to ANRs
try {
Gdx.app.clipboard.contents = GameSaver.gameInfoToString(gameInfo, forceZip = true)
} catch (OOM: OutOfMemoryError) {
// you don't get a special toast, this isn't nearly common enough, this is a total edge-case
}
}
}
newSave.add(copyJsonButton).row()
if (game.gameSaver.canLoadFromCustomSaveLocation()) {
val saveText = "Save to custom location".tr()
val saveToCustomLocation = TextButton(saveText, BaseScreen.skin)
val errorLabel = "".toLabel(Color.RED)
saveToCustomLocation.enable()
saveToCustomLocation.onClick {
errorLabel.setText("")
saveToCustomLocation.setText("Saving...".tr())
saveToCustomLocation.disable()
launchCrashHandling("SaveGame", runAsDaemon = false) {
game.gameSaver.saveGameToCustomLocation(gameInfo, gameNameTextField.text) { result ->
if (result.isError()) {
errorLabel.setText("Could not save game to custom location!".tr())
result.exception?.printStackTrace()
} else if (result.isSuccessful()) {
game.resetToWorldScreen()
}
saveToCustomLocation.enable()
saveToCustomLocation.setText(saveText)
}
}
}
newSave.add(saveToCustomLocation).row()
newSave.add(errorLabel).row()
}
val showAutosavesCheckbox = CheckBox("Show autosaves".tr(), skin)
showAutosavesCheckbox.isChecked = false
showAutosavesCheckbox.onChange {
updateShownSaves(showAutosavesCheckbox.isChecked)
}
newSave.add(showAutosavesCheckbox).row()
topTable.add(newSave)
topTable.pack()
rightSideTable.initRightSideTable()
rightSideButton.setText("Save game".tr())
rightSideButton.onClick {
val saveAction = {
if (game.gameSaver.getSave(gameNameTextField.text).exists())
YesNoPopup("Overwrite existing file?", { saveGame() }, this).open()
else saveGame()
}
rightSideButton.onClick(saveAction)
rightSideButton.enable()
keyPressDispatcher[KeyCharAndCode.RETURN] = saveAction
stage.keyboardFocus = gameNameTextField
}
private fun Table.initRightSideTable() {
addGameNameField()
val copyJsonButton = "Copy to clipboard".toTextButton()
copyJsonButton.onClick(::copyToClipboardHandler)
val ctrlC = KeyCharAndCode.ctrl('c')
keyPressDispatcher[ctrlC] = ::copyToClipboardHandler
copyJsonButton.addTooltip(ctrlC)
add(copyJsonButton).row()
addSaveToCustomLocation()
add(deleteSaveButton).row()
add(showAutosavesCheckbox).row()
}
private fun Table.addGameNameField() {
gameNameTextField.setTextFieldFilter { _, char -> char != '\\' && char != '/' }
val defaultSaveName = "[${gameInfo.currentPlayer}] - [${gameInfo.turns}] turns".tr()
gameNameTextField.text = defaultSaveName
gameNameTextField.setSelection(0, defaultSaveName.length)
add("Saved game name".toLabel()).row()
add(gameNameTextField).width(300f).row()
stage.keyboardFocus = gameNameTextField
}
private fun copyToClipboardHandler() {
launchCrashHandling("Copy game to clipboard") {
// the Gzip rarely leads to ANRs
try {
Gdx.app.clipboard.contents = GameSaver.gameInfoToString(gameInfo, forceZip = true)
} catch (ex: Throwable) {
ex.printStackTrace()
ToastPopup("Could not save game to clipboard!", this@SaveGameScreen)
}
}
}
private fun Table.addSaveToCustomLocation() {
if (!game.gameSaver.canLoadFromCustomSaveLocation()) return
val saveToCustomLocation = "Save to custom location".toTextButton()
val errorLabel = "".toLabel(Color.RED)
saveToCustomLocation.onClick {
errorLabel.setText("")
saveToCustomLocation.setText("Saving...".tr())
saveToCustomLocation.disable()
launchCrashHandling("Save to custom location", runAsDaemon = false) {
game.gameSaver.saveGameToCustomLocation(gameInfo, gameNameTextField.text) { result ->
if (result.isError()) {
errorLabel.setText("Could not save game to custom location!".tr())
result.exception?.printStackTrace()
} else if (result.isSuccessful()) {
game.resetToWorldScreen()
}
saveToCustomLocation.enable()
}
}
}
add(saveToCustomLocation).row()
add(errorLabel).row()
}
private fun saveGame() {
@ -110,17 +118,8 @@ class SaveGameScreen(val gameInfo: GameInfo) : PickerScreen(disableScroll = true
}
}
private fun updateShownSaves(showAutosaves: Boolean) {
currentSaves.clear()
val saves = game.gameSaver.getSaves(autoSaves = showAutosaves)
.sortedByDescending { it.lastModified() }
for (saveGameFile in saves) {
val textButton = saveGameFile.name().toTextButton()
textButton.onClick {
gameNameTextField.text = saveGameFile.name()
}
currentSaves.add(textButton).pad(5f).row()
}
override fun onExistingSaveSelected(saveGameFile: FileHandle) {
gameNameTextField.text = saveGameFile.name()
}
}

View File

@ -0,0 +1,151 @@
package com.unciv.ui.saves
import com.badlogic.gdx.Input
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.utils.Align
import com.unciv.logic.GameSaver
import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.utils.AutoScrollPane
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.KeyPressDispatcher
import com.unciv.ui.utils.onClick
//todo key auto-repeat for navigation keys?
/** A widget holding TextButtons vertically in a Table contained in a ScrollPane, with methods to
* hold file names and FileHandle's in those buttons. Used to display existing saves in the Load and Save game dialogs.
*
* @param keyPressDispatcher optionally pass in a [BaseScreen]'s [keyPressDispatcher][BaseScreen.keyPressDispatcher] to allow keyboard navigation.
* @param existingSavesTable exists here for coder convenience. No need to touch.
*/
class VerticalFileListScrollPane(
keyPressDispatcher: KeyPressDispatcher?,
private val existingSavesTable: Table = Table()
) : AutoScrollPane(existingSavesTable) {
private var previousSelection: TextButton? = null
private var onChangeListener: ((FileHandle) -> Unit)? = null
init {
if (keyPressDispatcher != null) {
keyPressDispatcher[Input.Keys.UP] = { onArrowKey(-1) }
keyPressDispatcher[Input.Keys.DOWN] = { onArrowKey(1) }
keyPressDispatcher[Input.Keys.PAGE_UP] = { onPageKey(-1) }
keyPressDispatcher[Input.Keys.PAGE_DOWN] = { onPageKey(1) }
keyPressDispatcher[Input.Keys.HOME] = { onHomeEndKey(0) }
keyPressDispatcher[Input.Keys.END] = { onHomeEndKey(1) }
}
}
fun onChange(action: (FileHandle) -> Unit) {
onChangeListener = action
}
/** repopulate with existing saved games */
fun updateSaveGames(gameSaver: GameSaver, showAutosaves: Boolean) {
update(gameSaver.getSaves(showAutosaves)
.sortedByDescending { it.lastModified() })
}
/** repopulate from a FileHandle Sequence - for other sources than saved games */
fun update(files: Sequence<FileHandle>) {
existingSavesTable.clear()
previousSelection = null
val loadImage = ImageGetter.getImage("OtherIcons/Load")
loadImage.setSize(50f, 50f) // So the origin sets correctly
loadImage.setOrigin(Align.center)
val loadAnimation = Actions.repeat(Int.MAX_VALUE, Actions.rotateBy(360f, 2f))
loadImage.addAction(loadAnimation)
existingSavesTable.add(loadImage).size(50f).center()
// Apparently, even just getting the list of saves can cause ANRs -
// not sure how many saves these guys had but Google Play reports this to have happened hundreds of times
launchCrashHandling("GetSaves") {
// .toList() materializes the result of the sequence
val saves = files.toList()
postCrashHandlingRunnable {
loadAnimation.reset()
existingSavesTable.clear()
for (saveGameFile in saves) {
val textButton = TextButton(saveGameFile.name(), BaseScreen.skin)
textButton.userObject = saveGameFile
textButton.onClick {
selectExistingSave(textButton)
}
existingSavesTable.add(textButton).pad(5f).row()
}
}
}
}
private fun selectExistingSave(textButton: TextButton) {
previousSelection?.color = Color.WHITE
textButton.color = Color.GREEN
previousSelection = textButton
val saveGameFile = textButton.userObject as? FileHandle ?: return
onChangeListener?.invoke(saveGameFile)
}
//region Keyboard scrolling
// Helpers to simplify Scroll positioning - ScrollPane.scrollY goes down, normal Gdx Y goes up
// These functions all operate in the scrollY 'coordinate system'
private fun Table.getVerticalSpan(button: TextButton): ClosedFloatingPointRange<Float> {
val invertedY = height - button.y
return (invertedY - button.height)..invertedY
}
private fun getVerticalSpan() = scrollY..(scrollY + height)
private fun Table.getButtonAt(y: Float) = cells[getRow(height - y)].actor as TextButton
private fun onArrowKey(direction: Int) {
if (existingSavesTable.rows == 0) return
val rowIndex = if (previousSelection == null)
if (direction == 1) -1 else 0
else existingSavesTable.getCell(previousSelection).row
val newRow = (rowIndex + direction).let {
if (it < 0) existingSavesTable.rows - 1
else if (it >= existingSavesTable.rows) 0
else it
}
val button = existingSavesTable.cells[newRow].actor as TextButton
selectExistingSave(button)
// Make ScrollPane follow the selection
val buttonSpan = existingSavesTable.getVerticalSpan(button)
val scrollSpan = getVerticalSpan()
if (buttonSpan.start < scrollSpan.start)
scrollY = buttonSpan.start
if (buttonSpan.endInclusive > scrollSpan.endInclusive)
scrollY = buttonSpan.endInclusive - height
}
private fun onPageKey(direction: Int) {
scrollY += (height - 60f) * direction // ScrollPane does the clamping to 0..maxY
val buttonHeight = previousSelection?.height ?: return
val buttonSpan = existingSavesTable.getVerticalSpan(previousSelection!!)
val scrollSpan = getVerticalSpan()
val newButtonY = if (buttonSpan.start < scrollSpan.start)
scrollSpan.start + buttonHeight
else if (buttonSpan.endInclusive > scrollSpan.endInclusive)
scrollSpan.endInclusive - buttonHeight
else return
selectExistingSave(existingSavesTable.getButtonAt(newButtonY))
}
private fun onHomeEndKey(direction: Int) {
scrollY = direction * maxY
if (existingSavesTable.rows == 0) return
val row = (existingSavesTable.rows - 1) * direction
selectExistingSave(existingSavesTable.cells[row].actor as TextButton)
}
//endregion
}

View File

@ -52,6 +52,7 @@ import com.unciv.ui.popup.ToastPopup
import com.unciv.ui.popup.YesNoPopup
import com.unciv.ui.popup.hasOpenPopups
import com.unciv.ui.saves.LoadGameScreen
import com.unciv.ui.saves.QuickSave
import com.unciv.ui.saves.SaveGameScreen
import com.unciv.ui.trade.DiplomacyScreen
import com.unciv.ui.utils.BaseScreen
@ -242,43 +243,6 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
}
private fun addKeyboardPresses() {
// Note these helpers might need unification with similar code e.g. in:
// GameSaver.autoSave, SaveGameScreen.saveGame, LoadGameScreen.rightSideButton.onClick,...
val quickSave = {
val toast = ToastPopup("Quicksaving...", this)
launchCrashHandling("SaveGame", runAsDaemon = false) {
game.gameSaver.saveGame(gameInfo, "QuickSave") {
postCrashHandlingRunnable {
toast.close()
if (it != null)
ToastPopup("Could not save game!", this@WorldScreen)
else {
ToastPopup("Quicksave successful.", this@WorldScreen)
}
}
}
}
Unit // change type of anonymous fun from ()->Thread to ()->Unit without unchecked cast
}
val quickLoad = {
val toast = ToastPopup("Quickloading...", this)
launchCrashHandling("LoadGame") {
try {
val loadedGame = game.gameSaver.loadGameByName("QuickSave")
postCrashHandlingRunnable {
toast.close()
UncivGame.Current.loadGame(loadedGame)
ToastPopup("Quickload successful.", this@WorldScreen)
}
} catch (ex: Exception) {
postCrashHandlingRunnable {
ToastPopup("Could not load game!", this@WorldScreen)
}
}
}
Unit // change type to ()->Unit
}
// Space and N are assigned in createNextTurnButton
keyPressDispatcher[Input.Keys.F1] = { game.setScreen(CivilopediaScreen(gameInfo.ruleSet, this)) }
keyPressDispatcher['E'] = { game.setScreen(EmpireOverviewScreen(selectedCiv)) } // Empire overview last used page
@ -296,8 +260,8 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
keyPressDispatcher[Input.Keys.F8] = { game.setScreen(VictoryScreen(this)) } // Victory Progress
keyPressDispatcher[Input.Keys.F9] = { game.setScreen(EmpireOverviewScreen(selectedCiv, "Stats")) } // Demographics
keyPressDispatcher[Input.Keys.F10] = { game.setScreen(EmpireOverviewScreen(selectedCiv, "Resources")) } // originally Strategic View
keyPressDispatcher[Input.Keys.F11] = quickSave // Quick Save
keyPressDispatcher[Input.Keys.F12] = quickLoad // Quick Load
keyPressDispatcher[Input.Keys.F11] = { QuickSave.save(gameInfo, this) } // Quick Save
keyPressDispatcher[Input.Keys.F12] = { QuickSave.load(this) } // Quick Load
keyPressDispatcher[Input.Keys.HOME] = { // Capital City View
val capital = gameInfo.currentPlayerCiv.getCapital()
if (capital != null && !mapHolder.setCenterPosition(capital.location))

View File

@ -25,6 +25,10 @@ internal object DesktopLauncher {
// Solves a rendering problem in specific GPUs and drivers.
// For more info see https://github.com/yairm210/Unciv/pull/3202 and https://github.com/LWJGL/lwjgl/issues/119
System.setProperty("org.lwjgl.opengl.Display.allowSoftwareOpenGL", "true")
// This setting (default 64) limits clipboard transfers. Value in kB!
// 386 is an almost-arbitrary choice from the saves I had at the moment and their GZipped size.
// There must be a reason for lwjgl3 being so stingy, which for me meant to stay conservative.
System.setProperty("org.lwjgl.system.stackSize", "384")
ImagePacker.packImages()