Fix OutOfMemory error when loading game state after already having a game loaded (#7145)

* Fix OutOfMemory error when loading game state after already having a game loaded

* Fix screen resize not being handled correctly

* Add withContext shortcut functions

* Add more logging

* Fix multiplayer games sometimes being loaded twice

* Make the loading screen nicer

* Make the loading screen hide previous popups for making the screenshot

* Don't do custom rendering & dispose the texture

Sometimes it makes sense to understand the library you're using...

* Fix missing GL context

* Refactor: increase readability of loadGame function
This commit is contained in:
Timo T
2022-06-14 20:09:09 +02:00
committed by GitHub
parent b6a98e5540
commit a5f9623dbe
15 changed files with 304 additions and 153 deletions

View File

@ -202,9 +202,9 @@ class MainMenuScreen: BaseScreen() {
if (curWorldScreen != null) { if (curWorldScreen != null) {
game.resetToWorldScreen() game.resetToWorldScreen()
curWorldScreen.popups.filterIsInstance(WorldScreenMenuPopup::class.java).forEach(Popup::close) curWorldScreen.popups.filterIsInstance(WorldScreenMenuPopup::class.java).forEach(Popup::close)
return } else {
QuickSave.autoLoadGame(this)
} }
QuickSave.autoLoadGame(this)
} }
private fun quickstartNewGame() { private fun quickstartNewGame() {
@ -221,12 +221,14 @@ class MainMenuScreen: BaseScreen() {
} }
// ...or when loading the game // ...or when loading the game
launchOnGLThread { try {
try { game.loadGame(newGame)
game.loadGame(newGame) } catch (outOfMemory: OutOfMemoryError) {
} catch (outOfMemory: OutOfMemoryError) { launchOnGLThread {
ToastPopup("Not enough memory on phone to load game!", this@MainMenuScreen) ToastPopup("Not enough memory on phone to load game!", this@MainMenuScreen)
} catch (ex: Exception) { }
} catch (ex: Exception) {
launchOnGLThread {
ToastPopup(errorText, this@MainMenuScreen) ToastPopup(errorText, this@MainMenuScreen)
} }
} }

View File

@ -16,6 +16,7 @@ import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.tilesets.TileSetCache import com.unciv.models.tilesets.TileSetCache
import com.unciv.models.translations.Translations import com.unciv.models.translations.Translations
import com.unciv.ui.LanguagePickerScreen import com.unciv.ui.LanguagePickerScreen
import com.unciv.ui.LoadingScreen
import com.unciv.ui.audio.GameSounds import com.unciv.ui.audio.GameSounds
import com.unciv.ui.audio.MusicController import com.unciv.ui.audio.MusicController
import com.unciv.ui.audio.MusicMood import com.unciv.ui.audio.MusicMood
@ -23,7 +24,6 @@ import com.unciv.ui.audio.SoundPlayer
import com.unciv.ui.crashhandling.CrashScreen import com.unciv.ui.crashhandling.CrashScreen
import com.unciv.ui.crashhandling.wrapCrashHandlingUnit import com.unciv.ui.crashhandling.wrapCrashHandlingUnit
import com.unciv.ui.images.ImageGetter import com.unciv.ui.images.ImageGetter
import com.unciv.ui.multiplayer.LoadDeepLinkScreen
import com.unciv.ui.multiplayer.MultiplayerHelpers import com.unciv.ui.multiplayer.MultiplayerHelpers
import com.unciv.ui.popup.Popup import com.unciv.ui.popup.Popup
import com.unciv.ui.utils.BaseScreen import com.unciv.ui.utils.BaseScreen
@ -33,6 +33,8 @@ import com.unciv.ui.worldscreen.WorldScreen
import com.unciv.utils.Log import com.unciv.utils.Log
import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.Concurrency
import com.unciv.utils.concurrency.launchOnGLThread import com.unciv.utils.concurrency.launchOnGLThread
import com.unciv.utils.concurrency.withGLContext
import com.unciv.utils.concurrency.withThreadPoolContext
import com.unciv.utils.debug import com.unciv.utils.debug
import java.util.* import java.util.*
@ -51,6 +53,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
var deepLinkedMultiplayerGame: String? = null var deepLinkedMultiplayerGame: String? = null
var gameInfo: GameInfo? = null var gameInfo: GameInfo? = null
private set
lateinit var settings: GameSettings lateinit var settings: GameSettings
lateinit var musicController: MusicController lateinit var musicController: MusicController
lateinit var onlineMultiplayer: OnlineMultiplayer lateinit var onlineMultiplayer: OnlineMultiplayer
@ -104,7 +107,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
* - Font (hence Fonts.resetFont() inside setSkin()) * - Font (hence Fonts.resetFont() inside setSkin())
*/ */
settings = gameSaver.getGeneralSettings() // needed for the screen settings = gameSaver.getGeneralSettings() // needed for the screen
setScreen(LoadingScreen()) // NOT dependent on any atlas or skin setScreen(GameStartScreen()) // NOT dependent on any atlas or skin
GameSounds.init() GameSounds.init()
musicController = MusicController() // early, but at this point does only copy volume from settings musicController = MusicController() // early, but at this point does only copy volume from settings
audioExceptionHelper?.installHooks( audioExceptionHelper?.installHooks(
@ -151,21 +154,66 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
} }
} }
fun loadGame(gameInfo: GameInfo): WorldScreen { /** Loads a game, initializing the state of all important modules. Automatically runs on the appropriate thread. */
this.gameInfo = gameInfo suspend fun loadGame(newGameInfo: GameInfo): WorldScreen = withThreadPoolContext toplevel@{
ImageGetter.setNewRuleset(gameInfo.ruleSet) val prevGameInfo = gameInfo
// Clone the mod list and add the base ruleset to it gameInfo = newGameInfo
val fullModList = gameInfo.gameParameters.getModsAndBaseRuleset()
musicController.setModList(fullModList) initializeResources(prevGameInfo, newGameInfo)
Gdx.input.inputProcessor = null // Since we will set the world screen when we're ready,
val worldScreen = WorldScreen(gameInfo, gameInfo.getPlayerToViewAs()) val isLoadingSameGame = worldScreen != null && prevGameInfo != null && prevGameInfo.gameId == newGameInfo.gameId
val newScreen = if (gameInfo.civilizations.count { it.playerType == PlayerType.Human } > 1 && !gameInfo.gameParameters.isOnlineMultiplayer) val worldScreenRestoreState = if (isLoadingSameGame) worldScreen!!.getRestoreState() else null
PlayerReadyScreen(worldScreen)
else { withGLContext { setScreen(LoadingScreen(getScreen())) }
worldScreen
worldScreen?.dispose()
worldScreen = null // This allows the GC to collect our old WorldScreen, otherwise we keep two WorldScreens in memory.
return@toplevel withGLContext {
val worldScreen = WorldScreen(newGameInfo, newGameInfo.getPlayerToViewAs(), worldScreenRestoreState)
val moreThanOnePlayer = newGameInfo.civilizations.count { it.playerType == PlayerType.Human } > 1
val isSingleplayer = !newGameInfo.gameParameters.isOnlineMultiplayer
val screenToShow = if (moreThanOnePlayer && isSingleplayer) {
PlayerReadyScreen(worldScreen)
} else {
worldScreen
}
setScreen(screenToShow)
return@withGLContext worldScreen
} }
setScreen(newScreen) }
return worldScreen
/** The new game info may have different mods or rulesets, which may use different resources that need to be loaded. */
private suspend fun initializeResources(prevGameInfo: GameInfo?, newGameInfo: GameInfo) {
if (prevGameInfo == null || prevGameInfo.ruleSet != newGameInfo.ruleSet) {
withGLContext {
ImageGetter.setNewRuleset(newGameInfo.ruleSet)
}
}
if (prevGameInfo == null ||
prevGameInfo.gameParameters.baseRuleset != newGameInfo.gameParameters.baseRuleset ||
prevGameInfo.gameParameters.mods != newGameInfo.gameParameters.mods
) {
val fullModList = newGameInfo.gameParameters.getModsAndBaseRuleset()
musicController.setModList(fullModList)
}
}
/** Re-creates the current [worldScreen], if there is any. */
fun reloadWorldscreen() {
val curWorldScreen = worldScreen
val curGameInfo = gameInfo
if (curWorldScreen == null || curGameInfo == null) return
Concurrency.run { loadGame(curGameInfo) }
}
private data class NewScreens(val screenToShow: BaseScreen, val worldScreen: WorldScreen) {
constructor(worldScreen: WorldScreen) : this(worldScreen, worldScreen)
} }
/** /**
@ -217,7 +265,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
if (deepLinkedMultiplayerGame == null) return@run if (deepLinkedMultiplayerGame == null) return@run
launchOnGLThread { launchOnGLThread {
setScreen(LoadDeepLinkScreen()) setScreen(LoadingScreen(getScreen()!!))
} }
try { try {
onlineMultiplayer.loadGame(deepLinkedMultiplayerGame!!) onlineMultiplayer.loadGame(deepLinkedMultiplayerGame!!)
@ -311,6 +359,21 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
return if (screen == worldScreen) worldScreen else null return if (screen == worldScreen) worldScreen else null
} }
fun goToMainMenu(): MainMenuScreen {
val curGameInfo = gameInfo
if (curGameInfo != null) {
gameSaver.requestAutoSaveUnCloned(curGameInfo) // Can save gameInfo directly because the user can't modify it on the MainMenuScreen
}
val mainMenuScreen = MainMenuScreen()
setScreen(mainMenuScreen)
return mainMenuScreen
}
/** Sets a simulated [GameInfo] object this game should run on */
fun startSimulation(simulatedGameInfo: GameInfo) {
gameInfo = simulatedGameInfo
}
companion object { companion object {
lateinit var Current: UncivGame lateinit var Current: UncivGame
fun isCurrentInitialized() = this::Current.isInitialized fun isCurrentInitialized() = this::Current.isInitialized
@ -319,7 +382,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
} }
} }
private class LoadingScreen : BaseScreen() { private class GameStartScreen : BaseScreen() {
init { init {
val happinessImage = ImageGetter.getExternalImage("LoadScreen.png") val happinessImage = ImageGetter.getExternalImage("LoadScreen.png")
happinessImage.center(stage) happinessImage.center(stage)

View File

@ -12,8 +12,9 @@ import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver
import com.unciv.ui.utils.extensions.isLargerThan import com.unciv.ui.utils.extensions.isLargerThan
import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.Concurrency
import com.unciv.utils.concurrency.Dispatcher import com.unciv.utils.concurrency.Dispatcher
import com.unciv.utils.concurrency.launchOnGLThread
import com.unciv.utils.concurrency.launchOnThreadPool import com.unciv.utils.concurrency.launchOnThreadPool
import com.unciv.utils.concurrency.withGLContext
import com.unciv.utils.debug
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -98,7 +99,7 @@ class OnlineMultiplayer {
} }
} }
private fun updateSavesFromFiles() { private suspend fun updateSavesFromFiles() {
val saves = gameSaver.getMultiplayerSaves() val saves = gameSaver.getMultiplayerSaves()
val removedSaves = savedGames.keys - saves.toSet() val removedSaves = savedGames.keys - saves.toSet()
@ -143,20 +144,23 @@ class OnlineMultiplayer {
addGame(gamePreview, saveFileName) addGame(gamePreview, saveFileName)
} }
private fun addGame(newGame: GameInfo) { private suspend fun addGame(newGame: GameInfo) {
val newGamePreview = newGame.asPreview() val newGamePreview = newGame.asPreview()
addGame(newGamePreview, newGamePreview.gameId) addGame(newGamePreview, newGamePreview.gameId)
} }
private fun addGame(preview: GameInfoPreview, saveFileName: String) { private suspend fun addGame(preview: GameInfoPreview, saveFileName: String) {
val fileHandle = gameSaver.saveGame(preview, saveFileName) val fileHandle = gameSaver.saveGame(preview, saveFileName)
return addGame(fileHandle, preview) return addGame(fileHandle, preview)
} }
private fun addGame(fileHandle: FileHandle, preview: GameInfoPreview = gameSaver.loadGamePreviewFromFile(fileHandle)) { private suspend fun addGame(fileHandle: FileHandle, preview: GameInfoPreview = gameSaver.loadGamePreviewFromFile(fileHandle)) {
debug("Adding game %s", preview.gameId)
val game = OnlineMultiplayerGame(fileHandle, preview, Instant.now()) val game = OnlineMultiplayerGame(fileHandle, preview, Instant.now())
savedGames[fileHandle] = game savedGames[fileHandle] = game
Concurrency.runOnGLThread { EventBus.send(MultiplayerGameAdded(game.name)) } withGLContext {
EventBus.send(MultiplayerGameAdded(game.name))
}
} }
fun getGameByName(name: String): OnlineMultiplayerGame? { fun getGameByName(name: String): OnlineMultiplayerGame? {
@ -230,7 +234,7 @@ class OnlineMultiplayer {
} else if (onlinePreview != null && hasNewerGameState(preview, onlinePreview)){ } else if (onlinePreview != null && hasNewerGameState(preview, onlinePreview)){
onlineGame.doManualUpdate(preview) onlineGame.doManualUpdate(preview)
} }
launchOnGLThread { UncivGame.Current.loadGame(gameInfo) } UncivGame.Current.loadGame(gameInfo)
} }
/** /**
@ -241,7 +245,7 @@ class OnlineMultiplayer {
val preview = onlineGameSaver.tryDownloadGamePreview(gameId) val preview = onlineGameSaver.tryDownloadGamePreview(gameId)
if (hasLatestGameState(gameInfo, preview)) { if (hasLatestGameState(gameInfo, preview)) {
gameInfo.isUpToDate = true gameInfo.isUpToDate = true
launchOnGLThread { UncivGame.Current.loadGame(gameInfo) } UncivGame.Current.loadGame(gameInfo)
} else { } else {
loadGame(gameId) loadGame(gameId)
} }
@ -272,6 +276,7 @@ class OnlineMultiplayer {
val game = savedGames[fileHandle] val game = savedGames[fileHandle]
if (game == null) return if (game == null) return
debug("Deleting game %s with id %s", fileHandle.name(), game.preview?.gameId)
savedGames.remove(game.fileHandle) savedGames.remove(game.fileHandle)
Concurrency.runOnGLThread { EventBus.send(MultiplayerGameDeleted(game.name)) } Concurrency.runOnGLThread { EventBus.send(MultiplayerGameDeleted(game.name)) }
} }
@ -280,6 +285,7 @@ class OnlineMultiplayer {
* Fires [MultiplayerGameNameChanged] * Fires [MultiplayerGameNameChanged]
*/ */
fun changeGameName(game: OnlineMultiplayerGame, newName: String) { fun changeGameName(game: OnlineMultiplayerGame, newName: String) {
debug("Changing name of game %s to", game.name, newName)
val oldPreview = game.preview ?: throw game.error!! val oldPreview = game.preview ?: throw game.error!!
val oldLastUpdate = game.lastUpdate val oldLastUpdate = game.lastUpdate
val oldName = game.name val oldName = game.name
@ -298,8 +304,10 @@ class OnlineMultiplayer {
* @throws FileNotFoundException if the file can't be found * @throws FileNotFoundException if the file can't be found
*/ */
suspend fun updateGame(gameInfo: GameInfo) { suspend fun updateGame(gameInfo: GameInfo) {
debug("Updating remote game %s", gameInfo.gameId)
onlineGameSaver.tryUploadGame(gameInfo, withPreview = true) onlineGameSaver.tryUploadGame(gameInfo, withPreview = true)
val game = getGameByGameId(gameInfo.gameId) val game = getGameByGameId(gameInfo.gameId)
debug("Existing OnlineMultiplayerGame: %s", game)
if (game == null) { if (game == null) {
addGame(gameInfo) addGame(gameInfo)
} else { } else {

View File

@ -8,6 +8,10 @@ import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver
import com.unciv.ui.utils.extensions.isLargerThan import com.unciv.ui.utils.extensions.isLargerThan
import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.Concurrency
import com.unciv.utils.concurrency.launchOnGLThread
import com.unciv.utils.concurrency.withGLContext
import com.unciv.utils.debug
import kotlinx.coroutines.coroutineScope
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@ -62,13 +66,14 @@ class OnlineMultiplayerGame(
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
* @throws FileNotFoundException if the file can't be found * @throws FileNotFoundException if the file can't be found
*/ */
suspend fun requestUpdate(forceUpdate: Boolean = false) { suspend fun requestUpdate(forceUpdate: Boolean = false) = coroutineScope {
val onUnchanged = { GameUpdateResult.UNCHANGED } val onUnchanged = { GameUpdateResult.UNCHANGED }
val onError = { e: Exception -> val onError = { e: Exception ->
error = e error = e
GameUpdateResult.FAILURE GameUpdateResult.FAILURE
} }
Concurrency.runOnGLThread { debug("Starting multiplayer game update for %s with id %s", name, preview?.gameId)
launchOnGLThread {
EventBus.send(MultiplayerGameUpdateStarted(name)) EventBus.send(MultiplayerGameUpdateStarted(name))
} }
val throttleInterval = if (forceUpdate) Duration.ZERO else getUpdateThrottleInterval() val throttleInterval = if (forceUpdate) Duration.ZERO else getUpdateThrottleInterval()
@ -77,16 +82,24 @@ class OnlineMultiplayerGame(
} else { } else {
throttle(lastOnlineUpdate, throttleInterval, onUnchanged, onError, ::update) throttle(lastOnlineUpdate, throttleInterval, onUnchanged, onError, ::update)
} }
when (updateResult) {
GameUpdateResult.UNCHANGED, GameUpdateResult.CHANGED -> error = null
else -> {}
}
val updateEvent = when (updateResult) { val updateEvent = when (updateResult) {
GameUpdateResult.CHANGED -> MultiplayerGameUpdated(name, preview!!) GameUpdateResult.CHANGED -> {
GameUpdateResult.FAILURE -> MultiplayerGameUpdateFailed(name, error!!) debug("Game update for %s with id %s had remote change", name, preview?.gameId)
GameUpdateResult.UNCHANGED -> MultiplayerGameUpdateUnchanged(name, preview!!) MultiplayerGameUpdated(name, preview!!)
}
GameUpdateResult.FAILURE -> {
debug("Game update for %s with id %s failed: %s", name, preview?.gameId, error)
MultiplayerGameUpdateFailed(name, error!!)
}
GameUpdateResult.UNCHANGED -> {
debug("Game update for %s with id %s had no changes", name, preview?.gameId)
error = null
MultiplayerGameUpdateUnchanged(name, preview!!)
}
}
launchOnGLThread {
EventBus.send(updateEvent)
} }
Concurrency.runOnGLThread { EventBus.send(updateEvent) }
} }
private suspend fun update(): GameUpdateResult { private suspend fun update(): GameUpdateResult {
@ -98,11 +111,14 @@ class OnlineMultiplayerGame(
return GameUpdateResult.CHANGED return GameUpdateResult.CHANGED
} }
fun doManualUpdate(gameInfo: GameInfoPreview) { suspend fun doManualUpdate(gameInfo: GameInfoPreview) {
debug("Doing manual update of game %s", gameInfo.gameId)
lastOnlineUpdate.set(Instant.now()) lastOnlineUpdate.set(Instant.now())
error = null error = null
preview = gameInfo preview = gameInfo
Concurrency.runOnGLThread { EventBus.send(MultiplayerGameUpdated(name, gameInfo)) } withGLContext {
EventBus.send(MultiplayerGameUpdated(name, gameInfo))
}
} }
override fun equals(other: Any?): Boolean = other is OnlineMultiplayerGame && fileHandle == other.fileHandle override fun equals(other: Any?): Boolean = other is OnlineMultiplayerGame && fileHandle == other.fileHandle

View File

@ -0,0 +1,51 @@
package com.unciv.ui
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.graphics.Pixmap
import com.badlogic.gdx.graphics.Texture
import com.badlogic.gdx.graphics.g2d.SpriteBatch
import com.badlogic.gdx.graphics.g2d.TextureRegion
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable
import com.unciv.Constants
import com.unciv.ui.images.ImageWithCustomSize
import com.unciv.ui.popup.Popup
import com.unciv.ui.popup.closeAllPopups
import com.unciv.ui.popup.popups
import com.unciv.ui.utils.BaseScreen
import com.unciv.ui.utils.extensions.toLabel
/** A loading screen that creates a screenshot of the current screen and adds a "Loading..." popup on top of that */
class LoadingScreen(
previousScreen: BaseScreen? = null
) : BaseScreen() {
val screenshot: Texture
init {
screenshot = takeScreenshot(previousScreen)
val image = ImageWithCustomSize(TextureRegion(screenshot, 0, screenshot.height, screenshot.width, -screenshot.height))
image.width = stage.width
image.height= stage.height
stage.addActor(image)
val popup = Popup(stage)
popup.add(Constants.loading.toLabel())
popup.open()
}
private fun takeScreenshot(previousScreen: BaseScreen?): Texture {
if (previousScreen != null) {
for (popup in previousScreen.popups) popup.isVisible = false
previousScreen.render(Gdx.graphics.getDeltaTime())
}
val screenshot = Texture(Pixmap.createFromFrameBuffer(0, 0, Gdx.graphics.backBufferWidth, Gdx.graphics.backBufferHeight))
if (previousScreen != null) {
for (popup in previousScreen.popups) popup.isVisible = true
}
return screenshot
}
override fun dispose() {
screenshot.dispose()
super.dispose()
}
}

View File

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

View File

@ -286,13 +286,14 @@ class NewGameScreen(
} }
} }
launchOnGLThread { val worldScreen = game.loadGame(newGame)
val worldScreen = game.loadGame(newGame)
if (newGame.gameParameters.isOnlineMultiplayer) { if (newGame.gameParameters.isOnlineMultiplayer) {
// Save gameId to clipboard because you have to do it anyway. launchOnGLThread {
Gdx.app.clipboard.contents = newGame.gameId // Save gameId to clipboard because you have to do it anyway.
// Popup to notify the User that the gameID got copied to the clipboard Gdx.app.clipboard.contents = newGame.gameId
ToastPopup("Game ID copied to clipboard!".tr(), worldScreen, 2500) // Popup to notify the User that the gameID got copied to the clipboard
ToastPopup("Game ID copied to clipboard!".tr(), worldScreen, 2500)
} }
} }
} }

View File

@ -134,12 +134,7 @@ class OptionsPopup(
/** Reload this Popup after major changes (resolution, tileset, language, font) */ /** Reload this Popup after major changes (resolution, tileset, language, font) */
private fun reloadWorldAndOptions() { private fun reloadWorldAndOptions() {
settings.save() settings.save()
val worldScreen = UncivGame.Current.getWorldScreenIfActive() UncivGame.Current.reloadWorldscreen()
if (worldScreen != null) {
val newWorldScreen = WorldScreen(worldScreen.gameInfo, worldScreen.viewingCiv)
worldScreen.game.setScreen(newWorldScreen)
newWorldScreen.openOptionsPopup(tabs.activePage)
}
} }
fun addCheckbox(table: Table, text: String, initialState: Boolean, updateWorld: Boolean = false, action: ((Boolean) -> Unit)) { fun addCheckbox(table: Table, text: String, initialState: Boolean, updateWorld: Boolean = false, action: ((Boolean) -> Unit)) {

View File

@ -81,7 +81,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() {
try { try {
// This is what can lead to ANRs - reading the file and setting the transients, that's why this is in another thread // 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) val loadedGame = game.gameSaver.loadGameByName(selectedSave)
launchOnGLThread { game.loadGame(loadedGame) } game.loadGame(loadedGame)
} catch (ex: Exception) { } catch (ex: Exception) {
launchOnGLThread { launchOnGLThread {
loadingPopup.close() loadingPopup.close()
@ -118,7 +118,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() {
try { try {
val clipboardContentsString = Gdx.app.clipboard.contents.trim() val clipboardContentsString = Gdx.app.clipboard.contents.trim()
val loadedGame = GameSaver.gameInfoFromString(clipboardContentsString) val loadedGame = GameSaver.gameInfoFromString(clipboardContentsString)
launchOnGLThread { game.loadGame(loadedGame) } game.loadGame(loadedGame)
} catch (ex: Exception) { } catch (ex: Exception) {
launchOnGLThread { handleLoadGameException("Could not load game from clipboard!", ex) } launchOnGLThread { handleLoadGameException("Could not load game from clipboard!", ex) }
} }
@ -143,7 +143,9 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() {
if (result.isError()) { if (result.isError()) {
handleLoadGameException("Could not load game from custom location!", result.exception) handleLoadGameException("Could not load game from custom location!", result.exception)
} else if (result.isSuccessful()) { } else if (result.isSuccessful()) {
game.loadGame(result.gameData!!) Concurrency.run {
game.loadGame(result.gameData!!)
}
} }
} }
} }

View File

@ -94,12 +94,12 @@ object QuickSave {
} }
} }
} else { } else {
launchOnGLThread { /// ... and load it into the screen on main thread for GL context try {
try { screen.game.loadGame(savedGame)
screen.game.loadGame(savedGame) } catch (oom: OutOfMemoryError) {
} catch (oom: OutOfMemoryError) { outOfMemory()
outOfMemory() } catch (ex: Exception) {
} catch (ex: Exception) { launchOnGLThread {
Log.error("Could not autoload game", ex) Log.error("Could not autoload game", ex)
loadingPopup.close() loadingPopup.close()
ToastPopup("Cannot resume game!", screen) ToastPopup("Cannot resume game!", screen)

View File

@ -78,38 +78,54 @@ import com.unciv.ui.worldscreen.unit.UnitTable
import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.Concurrency
import com.unciv.utils.concurrency.launchOnGLThread import com.unciv.utils.concurrency.launchOnGLThread
import com.unciv.utils.concurrency.launchOnThreadPool import com.unciv.utils.concurrency.launchOnThreadPool
import com.unciv.utils.concurrency.withGLContext
import com.unciv.utils.debug import com.unciv.utils.debug
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
/** /**
* Unciv's world screen * Do not create this screen without seriously thinking about the implications: this is the single most memory-intensive class in the application.
* There really should ever be only one in memory at the same time, likely managed by [UncivGame].
*
* @param gameInfo The game state the screen should represent * @param gameInfo The game state the screen should represent
* @param viewingCiv The currently active [civilization][CivilizationInfo] * @param viewingCiv The currently active [civilization][CivilizationInfo]
* @property shouldUpdate When set, causes the screen to update in the next [render][BaseScreen.render] event * @param restoreState
* @property isPlayersTurn (readonly) Indicates it's the player's ([viewingCiv]) turn
* @property selectedCiv Selected civilization, used in spectator and replay mode, equals viewingCiv in ordinary games
* @property canChangeState (readonly) `true` when it's the player's turn unless he is a spectator
* @property mapHolder A [WorldMapHolder] instance
* @property bottomUnitTable Bottom left widget holding information about a selected unit or city
*/ */
class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : BaseScreen() { class WorldScreen(
val gameInfo: GameInfo,
val viewingCiv: CivilizationInfo,
restoreState: RestoreState? = null
) : BaseScreen() {
/** When set, causes the screen to update in the next [render][BaseScreen.render] event */
var shouldUpdate = false
/** Indicates it's the player's ([viewingCiv]) turn */
var isPlayersTurn = viewingCiv == gameInfo.currentPlayerCiv var isPlayersTurn = viewingCiv == gameInfo.currentPlayerCiv
private set // only this class is allowed to make changes private set // only this class is allowed to make changes
/** Selected civilization, used in spectator and replay mode, equals viewingCiv in ordinary games */
var selectedCiv = viewingCiv var selectedCiv = viewingCiv
var fogOfWar = true var fogOfWar = true
private set private set
/** `true` when it's the player's turn unless he is a spectator*/
val canChangeState val canChangeState
get() = isPlayersTurn && !viewingCiv.isSpectator() get() = isPlayersTurn && !viewingCiv.isSpectator()
val mapHolder = WorldMapHolder(this, gameInfo.tileMap)
/** Bottom left widget holding information about a selected unit or city */
val bottomUnitTable = UnitTable(this)
private var waitingForAutosave = false private var waitingForAutosave = false
private val mapVisualization = MapVisualization(gameInfo, viewingCiv) private val mapVisualization = MapVisualization(gameInfo, viewingCiv)
val mapHolder = WorldMapHolder(this, gameInfo.tileMap)
private val minimapWrapper = MinimapHolder(mapHolder) private val minimapWrapper = MinimapHolder(mapHolder)
private val topBar = WorldScreenTopBar(this) private val topBar = WorldScreenTopBar(this)
val bottomUnitTable = UnitTable(this)
private val bottomTileInfoTable = TileInfoTable(viewingCiv) private val bottomTileInfoTable = TileInfoTable(viewingCiv)
private val battleTable = BattleTable(this) private val battleTable = BattleTable(this)
private val unitActionsTable = UnitActionsTable(this) private val unitActionsTable = UnitActionsTable(this)
@ -124,8 +140,6 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
ImageGetter.getBlue().darken(0.5f)) } ImageGetter.getBlue().darken(0.5f)) }
private val notificationsScroll = NotificationsScroll(this) private val notificationsScroll = NotificationsScroll(this)
var shouldUpdate = false
private val zoomController = ZoomButtonPair(mapHolder) private val zoomController = ZoomButtonPair(mapHolder)
private var nextTurnUpdateJob: Job? = null private var nextTurnUpdateJob: Job? = null
@ -139,12 +153,8 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
minimapWrapper.x = stage.width - minimapWrapper.width minimapWrapper.x = stage.width - minimapWrapper.width
try { // Most memory errors occur here, so this is a sort of catch-all // This is the most memory-intensive operation we have currently, most OutOfMemory errors will occur here
mapHolder.addTiles() mapHolder.addTiles()
} catch (outOfMemoryError: OutOfMemoryError) {
mapHolder.clear() // hopefully enough memory will be freed to be able to display the toast popup
ToastPopup("Not enough memory on phone to load game!", this)
}
// resume music (in case choices from the menu lead to instantiation of a new WorldScreen) // resume music (in case choices from the menu lead to instantiation of a new WorldScreen)
UncivGame.Current.musicController.resume() UncivGame.Current.musicController.resume()
@ -228,6 +238,8 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
} }
} }
if (restoreState != null) restore(restoreState)
// don't run update() directly, because the UncivGame.worldScreen should be set so that the city buttons and tile groups // don't run update() directly, because the UncivGame.worldScreen should be set so that the city buttons and tile groups
// know what the viewing civ is. // know what the viewing civ is.
shouldUpdate = true shouldUpdate = true
@ -349,10 +361,8 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
} }
launchOnGLThread { launchOnGLThread {
loadingGamePopup.close() loadingGamePopup.close()
if (game.gameInfo!!.gameId == gameInfo.gameId) { // game could've been changed during download
game.setScreen(createNewWorldScreen(latestGame))
}
} }
startNewScreenJob(latestGame)
} catch (ex: Throwable) { } catch (ex: Throwable) {
launchOnGLThread { launchOnGLThread {
val message = MultiplayerHelpers.getLoadExceptionMessage(ex) val message = MultiplayerHelpers.getLoadExceptionMessage(ex)
@ -425,12 +435,8 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
// it doesn't update the explored tiles of the civ... need to think about that harder // it doesn't update the explored tiles of the civ... need to think about that harder
// it causes a bug when we move a unit to an unexplored tile (for instance a cavalry unit which can move far) // it causes a bug when we move a unit to an unexplored tile (for instance a cavalry unit which can move far)
try { // Most memory errors occur here, so this is a sort of catch-all if (fogOfWar) mapHolder.updateTiles(selectedCiv)
if (fogOfWar) mapHolder.updateTiles(selectedCiv) else mapHolder.updateTiles(viewingCiv)
else mapHolder.updateTiles(viewingCiv)
} catch (outOfMemoryError: OutOfMemoryError) {
ToastPopup("Not enough memory on phone to load game!", this)
}
topBar.update(selectedCiv) topBar.update(selectedCiv)
@ -606,26 +612,32 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
} }
private fun createNewWorldScreen(gameInfo: GameInfo, resize:Boolean=false): WorldScreen { class RestoreState(
mapHolder: WorldMapHolder,
val selectedCivName: String,
val viewingCivName: String,
val fogOfWar: Boolean
) {
val zoom = mapHolder.scaleX
val scrollX = mapHolder.scrollX
val scrollY = mapHolder.scrollY
}
fun getRestoreState(): RestoreState {
return RestoreState(mapHolder, selectedCiv.civName, viewingCiv.civName, fogOfWar)
}
game.gameInfo = gameInfo private fun restore(restoreState: RestoreState) {
val newWorldScreen = WorldScreen(gameInfo, gameInfo.getPlayerToViewAs())
// This is not the case if you have a multiplayer game where you play as 2 civs // This is not the case if you have a multiplayer game where you play as 2 civs
if (!resize && newWorldScreen.viewingCiv.civName == viewingCiv.civName) { if (viewingCiv.civName == restoreState.viewingCivName) {
newWorldScreen.mapHolder.width = mapHolder.width mapHolder.zoom(restoreState.zoom)
newWorldScreen.mapHolder.height = mapHolder.height mapHolder.scrollX = restoreState.scrollX
newWorldScreen.mapHolder.scaleX = mapHolder.scaleX mapHolder.scrollY = restoreState.scrollY
newWorldScreen.mapHolder.scaleY = mapHolder.scaleY mapHolder.updateVisualScroll()
newWorldScreen.mapHolder.scrollX = mapHolder.scrollX
newWorldScreen.mapHolder.scrollY = mapHolder.scrollY
newWorldScreen.mapHolder.updateVisualScroll()
} }
newWorldScreen.selectedCiv = gameInfo.getCivilization(selectedCiv.civName) selectedCiv = gameInfo.getCivilization(restoreState.selectedCivName)
newWorldScreen.fogOfWar = fogOfWar fogOfWar = restoreState.fogOfWar
return newWorldScreen
} }
fun nextTurn() { fun nextTurn() {
@ -665,32 +677,9 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
if (game.gameInfo != originalGameInfo) // while this was turning we loaded another game if (game.gameInfo != originalGameInfo) // while this was turning we loaded another game
return@runOnNonDaemonThreadPool return@runOnNonDaemonThreadPool
this@WorldScreen.game.gameInfo = gameInfoClone
debug("Next turn took %sms", System.currentTimeMillis() - startTime) debug("Next turn took %sms", System.currentTimeMillis() - startTime)
val shouldAutoSave = gameInfoClone.turns % game.settings.turnsBetweenAutosaves == 0 startNewScreenJob(gameInfoClone)
// create a new WorldScreen to show the new stuff we've changed, and switch out the current screen.
// do this on main thread - it's the only one that has a GL context to create images from
launchOnGLThread {
val newWorldScreen = createNewWorldScreen(gameInfoClone)
if (gameInfoClone.currentPlayerCiv.civName != viewingCiv.civName
&& !gameInfoClone.gameParameters.isOnlineMultiplayer) {
game.setScreen(PlayerReadyScreen(newWorldScreen))
} else {
game.setScreen(newWorldScreen)
}
if (shouldAutoSave) {
newWorldScreen.waitingForAutosave = true
newWorldScreen.shouldUpdate = true
game.gameSaver.requestAutoSave(gameInfoClone).invokeOnCompletion {
// only enable the user to next turn once we've saved the current one
newWorldScreen.waitingForAutosave = false
newWorldScreen.shouldUpdate = true
}
}
}
} }
} }
@ -833,8 +822,9 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
} }
override fun resize(width: Int, height: Int) { override fun resize(width: Int, height: Int) {
if (stage.viewport.screenWidth != width || stage.viewport.screenHeight != height) if (stage.viewport.screenWidth != width || stage.viewport.screenHeight != height) {
createNewWorldScreen(gameInfo, resize=true) // start over startNewScreenJob(gameInfo) // start over
}
} }
@ -899,4 +889,34 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
ExitGamePopup(this, true) ExitGamePopup(this, true)
} }
fun autoSave() {
waitingForAutosave = true
shouldUpdate = true
UncivGame.Current.gameSaver.requestAutoSave(gameInfo).invokeOnCompletion {
// only enable the user to next turn once we've saved the current one
waitingForAutosave = false
shouldUpdate = true
}
}
}
/** This exists so that no reference to the current world screen remains, so the old world screen can get garbage collected during [UncivGame.loadGame]. */
private fun startNewScreenJob(gameInfo: GameInfo) {
Concurrency.run {
val newWorldScreen = try {
UncivGame.Current.loadGame(gameInfo)
} catch (oom: OutOfMemoryError) {
withGLContext {
val mainMenu = UncivGame.Current.goToMainMenu()
ToastPopup("Not enough memory on phone to load game!", mainMenu)
}
return@run
}
val shouldAutoSave = gameInfo.turns % UncivGame.Current.settings.turnsBetweenAutosaves == 0
if (shouldAutoSave) {
newWorldScreen.autoSave()
}
}
} }

View File

@ -16,8 +16,7 @@ class WorldScreenMenuPopup(val worldScreen: WorldScreen) : Popup(worldScreen) {
defaults().fillX() defaults().fillX()
addButton("Main menu") { addButton("Main menu") {
worldScreen.game.gameSaver.requestAutoSaveUnCloned(worldScreen.gameInfo) // Can save gameInfo directly because the user can't modify it on the MainMenuScreen worldScreen.game.goToMainMenu()
worldScreen.game.setScreen(MainMenuScreen())
} }
addButton("Civilopedia") { addButton("Civilopedia") {
close() close()

View File

@ -12,6 +12,7 @@ import kotlinx.coroutines.Runnable
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
@ -93,6 +94,13 @@ fun CoroutineScope.launchOnNonDaemonThreadPool(name: String? = null, block: susp
/** See [launch]. Runs on the GDX GL thread. Use this for all code that manipulates the GDX UI classes. */ /** See [launch]. Runs on the GDX GL thread. Use this for all code that manipulates the GDX UI classes. */
fun CoroutineScope.launchOnGLThread(name: String? = null, block: suspend CoroutineScope.() -> Unit) = launchCrashHandling(Dispatcher.GL, name, block) fun CoroutineScope.launchOnGLThread(name: String? = null, block: suspend CoroutineScope.() -> Unit) = launchCrashHandling(Dispatcher.GL, name, block)
/** See [withContext]. Runs on a daemon thread pool. Use this for code that does not necessarily need to finish executing. */
suspend fun <T> withThreadPoolContext(block: suspend CoroutineScope.() -> T): T = withContext(Dispatcher.DAEMON, block)
/** See [withContext]. Runs on a non-daemon thread pool. Use this if you do something that should always finish if possible, like saving the game. */
suspend fun <T> withNonDaemonThreadPoolContext(block: suspend CoroutineScope.() -> T): T = withContext(Dispatcher.NON_DAEMON, block)
/** See [withContext]. Runs on the GDX GL thread. Use this for all code that manipulates the GDX UI classes. */
suspend fun <T> withGLContext(block: suspend CoroutineScope.() -> T): T = withContext(Dispatcher.GL, block)
/** /**
* All dispatchers here bring the main game loop to a [com.unciv.CrashScreen] if an exception happens. * All dispatchers here bring the main game loop to a [com.unciv.CrashScreen] if an exception happens.

View File

@ -48,7 +48,7 @@ internal object ConsoleLauncher {
val mapParameters = getMapParameters() val mapParameters = getMapParameters()
val gameSetupInfo = GameSetupInfo(gameParameters, mapParameters) val gameSetupInfo = GameSetupInfo(gameParameters, mapParameters)
val newGame = GameStarter.startNewGame(gameSetupInfo) val newGame = GameStarter.startNewGame(gameSetupInfo)
UncivGame.Current.gameInfo = newGame UncivGame.Current.startSimulation(newGame)
val simulation = Simulation(newGame,10,4) val simulation = Simulation(newGame,10,4)

View File

@ -68,7 +68,7 @@ class SerializationTests {
UncivGame.Current.settings = GameSettings() UncivGame.Current.settings = GameSettings()
game = GameStarter.startNewGame(setup) game = GameStarter.startNewGame(setup)
UncivGame.Current.gameInfo = game UncivGame.Current.startSimulation(game)
// Found a city otherwise too many classes have no instance and are not tested // Found a city otherwise too many classes have no instance and are not tested
val civ = game.getCurrentPlayerCivilization() val civ = game.getCurrentPlayerCivilization()