diff --git a/core/src/com/unciv/MainMenuScreen.kt b/core/src/com/unciv/MainMenuScreen.kt index ed5eace3ac..8a6db28146 100644 --- a/core/src/com/unciv/MainMenuScreen.kt +++ b/core/src/com/unciv/MainMenuScreen.kt @@ -202,9 +202,9 @@ class MainMenuScreen: BaseScreen() { if (curWorldScreen != null) { game.resetToWorldScreen() curWorldScreen.popups.filterIsInstance(WorldScreenMenuPopup::class.java).forEach(Popup::close) - return + } else { + QuickSave.autoLoadGame(this) } - QuickSave.autoLoadGame(this) } private fun quickstartNewGame() { @@ -221,12 +221,14 @@ class MainMenuScreen: BaseScreen() { } // ...or when loading the game - launchOnGLThread { - try { - game.loadGame(newGame) - } catch (outOfMemory: OutOfMemoryError) { + try { + game.loadGame(newGame) + } catch (outOfMemory: OutOfMemoryError) { + launchOnGLThread { ToastPopup("Not enough memory on phone to load game!", this@MainMenuScreen) - } catch (ex: Exception) { + } + } catch (ex: Exception) { + launchOnGLThread { ToastPopup(errorText, this@MainMenuScreen) } } diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index fd5d2c7872..b8c85b3d6a 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -16,6 +16,7 @@ import com.unciv.models.ruleset.RulesetCache import com.unciv.models.tilesets.TileSetCache import com.unciv.models.translations.Translations import com.unciv.ui.LanguagePickerScreen +import com.unciv.ui.LoadingScreen import com.unciv.ui.audio.GameSounds import com.unciv.ui.audio.MusicController 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.wrapCrashHandlingUnit import com.unciv.ui.images.ImageGetter -import com.unciv.ui.multiplayer.LoadDeepLinkScreen import com.unciv.ui.multiplayer.MultiplayerHelpers import com.unciv.ui.popup.Popup 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.concurrency.Concurrency import com.unciv.utils.concurrency.launchOnGLThread +import com.unciv.utils.concurrency.withGLContext +import com.unciv.utils.concurrency.withThreadPoolContext import com.unciv.utils.debug import java.util.* @@ -51,6 +53,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { var deepLinkedMultiplayerGame: String? = null var gameInfo: GameInfo? = null + private set lateinit var settings: GameSettings lateinit var musicController: MusicController lateinit var onlineMultiplayer: OnlineMultiplayer @@ -104,7 +107,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { * - Font (hence Fonts.resetFont() inside setSkin()) */ 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() musicController = MusicController() // early, but at this point does only copy volume from settings audioExceptionHelper?.installHooks( @@ -151,21 +154,66 @@ class UncivGame(parameters: UncivGameParameters) : Game() { } } - fun loadGame(gameInfo: GameInfo): WorldScreen { - this.gameInfo = gameInfo - ImageGetter.setNewRuleset(gameInfo.ruleSet) - // Clone the mod list and add the base ruleset to it - val fullModList = gameInfo.gameParameters.getModsAndBaseRuleset() - musicController.setModList(fullModList) - Gdx.input.inputProcessor = null // Since we will set the world screen when we're ready, - val worldScreen = WorldScreen(gameInfo, gameInfo.getPlayerToViewAs()) - val newScreen = if (gameInfo.civilizations.count { it.playerType == PlayerType.Human } > 1 && !gameInfo.gameParameters.isOnlineMultiplayer) - PlayerReadyScreen(worldScreen) - else { - worldScreen + /** Loads a game, initializing the state of all important modules. Automatically runs on the appropriate thread. */ + suspend fun loadGame(newGameInfo: GameInfo): WorldScreen = withThreadPoolContext toplevel@{ + val prevGameInfo = gameInfo + gameInfo = newGameInfo + + initializeResources(prevGameInfo, newGameInfo) + + val isLoadingSameGame = worldScreen != null && prevGameInfo != null && prevGameInfo.gameId == newGameInfo.gameId + val worldScreenRestoreState = if (isLoadingSameGame) worldScreen!!.getRestoreState() else null + + withGLContext { setScreen(LoadingScreen(getScreen())) } + + 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 launchOnGLThread { - setScreen(LoadDeepLinkScreen()) + setScreen(LoadingScreen(getScreen()!!)) } try { onlineMultiplayer.loadGame(deepLinkedMultiplayerGame!!) @@ -311,6 +359,21 @@ class UncivGame(parameters: UncivGameParameters) : Game() { 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 { lateinit var Current: UncivGame fun isCurrentInitialized() = this::Current.isInitialized @@ -319,7 +382,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { } } -private class LoadingScreen : BaseScreen() { +private class GameStartScreen : BaseScreen() { init { val happinessImage = ImageGetter.getExternalImage("LoadScreen.png") happinessImage.center(stage) diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index c4e6f30729..c2387240b4 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -12,8 +12,9 @@ import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver import com.unciv.ui.utils.extensions.isLargerThan import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.Dispatcher -import com.unciv.utils.concurrency.launchOnGLThread 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.delay @@ -98,7 +99,7 @@ class OnlineMultiplayer { } } - private fun updateSavesFromFiles() { + private suspend fun updateSavesFromFiles() { val saves = gameSaver.getMultiplayerSaves() val removedSaves = savedGames.keys - saves.toSet() @@ -143,20 +144,23 @@ class OnlineMultiplayer { addGame(gamePreview, saveFileName) } - private fun addGame(newGame: GameInfo) { + private suspend fun addGame(newGame: GameInfo) { val newGamePreview = newGame.asPreview() 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) 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()) savedGames[fileHandle] = game - Concurrency.runOnGLThread { EventBus.send(MultiplayerGameAdded(game.name)) } + withGLContext { + EventBus.send(MultiplayerGameAdded(game.name)) + } } fun getGameByName(name: String): OnlineMultiplayerGame? { @@ -230,7 +234,7 @@ class OnlineMultiplayer { } else if (onlinePreview != null && hasNewerGameState(preview, onlinePreview)){ onlineGame.doManualUpdate(preview) } - launchOnGLThread { UncivGame.Current.loadGame(gameInfo) } + UncivGame.Current.loadGame(gameInfo) } /** @@ -241,7 +245,7 @@ class OnlineMultiplayer { val preview = onlineGameSaver.tryDownloadGamePreview(gameId) if (hasLatestGameState(gameInfo, preview)) { gameInfo.isUpToDate = true - launchOnGLThread { UncivGame.Current.loadGame(gameInfo) } + UncivGame.Current.loadGame(gameInfo) } else { loadGame(gameId) } @@ -272,6 +276,7 @@ class OnlineMultiplayer { val game = savedGames[fileHandle] if (game == null) return + debug("Deleting game %s with id %s", fileHandle.name(), game.preview?.gameId) savedGames.remove(game.fileHandle) Concurrency.runOnGLThread { EventBus.send(MultiplayerGameDeleted(game.name)) } } @@ -280,6 +285,7 @@ class OnlineMultiplayer { * Fires [MultiplayerGameNameChanged] */ fun changeGameName(game: OnlineMultiplayerGame, newName: String) { + debug("Changing name of game %s to", game.name, newName) val oldPreview = game.preview ?: throw game.error!! val oldLastUpdate = game.lastUpdate val oldName = game.name @@ -298,8 +304,10 @@ class OnlineMultiplayer { * @throws FileNotFoundException if the file can't be found */ suspend fun updateGame(gameInfo: GameInfo) { + debug("Updating remote game %s", gameInfo.gameId) onlineGameSaver.tryUploadGame(gameInfo, withPreview = true) val game = getGameByGameId(gameInfo.gameId) + debug("Existing OnlineMultiplayerGame: %s", game) if (game == null) { addGame(gameInfo) } else { diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt index ccfcc60d6b..53b3cd2f8b 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt @@ -8,6 +8,10 @@ import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver import com.unciv.ui.utils.extensions.isLargerThan 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.time.Duration 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 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 onError = { e: Exception -> error = e GameUpdateResult.FAILURE } - Concurrency.runOnGLThread { + debug("Starting multiplayer game update for %s with id %s", name, preview?.gameId) + launchOnGLThread { EventBus.send(MultiplayerGameUpdateStarted(name)) } val throttleInterval = if (forceUpdate) Duration.ZERO else getUpdateThrottleInterval() @@ -77,16 +82,24 @@ class OnlineMultiplayerGame( } else { throttle(lastOnlineUpdate, throttleInterval, onUnchanged, onError, ::update) } - when (updateResult) { - GameUpdateResult.UNCHANGED, GameUpdateResult.CHANGED -> error = null - else -> {} - } val updateEvent = when (updateResult) { - GameUpdateResult.CHANGED -> MultiplayerGameUpdated(name, preview!!) - GameUpdateResult.FAILURE -> MultiplayerGameUpdateFailed(name, error!!) - GameUpdateResult.UNCHANGED -> MultiplayerGameUpdateUnchanged(name, preview!!) + GameUpdateResult.CHANGED -> { + debug("Game update for %s with id %s had remote change", name, preview?.gameId) + 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 { @@ -98,11 +111,14 @@ class OnlineMultiplayerGame( 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()) error = null 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 diff --git a/core/src/com/unciv/ui/LoadingScreen.kt b/core/src/com/unciv/ui/LoadingScreen.kt new file mode 100644 index 0000000000..585c893a93 --- /dev/null +++ b/core/src/com/unciv/ui/LoadingScreen.kt @@ -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() + } +} diff --git a/core/src/com/unciv/ui/multiplayer/LoadDeepLinkScreen.kt b/core/src/com/unciv/ui/multiplayer/LoadDeepLinkScreen.kt deleted file mode 100644 index af0f2f2bb7..0000000000 --- a/core/src/com/unciv/ui/multiplayer/LoadDeepLinkScreen.kt +++ /dev/null @@ -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) - } -} diff --git a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt index 60e60a7f04..f3fbf9aa84 100644 --- a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt @@ -286,13 +286,14 @@ class NewGameScreen( } } - launchOnGLThread { - val worldScreen = game.loadGame(newGame) - if (newGame.gameParameters.isOnlineMultiplayer) { - // Save gameId to clipboard because you have to do it anyway. - Gdx.app.clipboard.contents = newGame.gameId - // Popup to notify the User that the gameID got copied to the clipboard - ToastPopup("Game ID copied to clipboard!".tr(), worldScreen, 2500) + val worldScreen = game.loadGame(newGame) + + if (newGame.gameParameters.isOnlineMultiplayer) { + launchOnGLThread { + // Save gameId to clipboard because you have to do it anyway. + Gdx.app.clipboard.contents = newGame.gameId + // Popup to notify the User that the gameID got copied to the clipboard + ToastPopup("Game ID copied to clipboard!".tr(), worldScreen, 2500) } } } diff --git a/core/src/com/unciv/ui/options/OptionsPopup.kt b/core/src/com/unciv/ui/options/OptionsPopup.kt index 565ded5ef4..949e8fa0ea 100644 --- a/core/src/com/unciv/ui/options/OptionsPopup.kt +++ b/core/src/com/unciv/ui/options/OptionsPopup.kt @@ -134,12 +134,7 @@ class OptionsPopup( /** Reload this Popup after major changes (resolution, tileset, language, font) */ private fun reloadWorldAndOptions() { settings.save() - val worldScreen = UncivGame.Current.getWorldScreenIfActive() - if (worldScreen != null) { - val newWorldScreen = WorldScreen(worldScreen.gameInfo, worldScreen.viewingCiv) - worldScreen.game.setScreen(newWorldScreen) - newWorldScreen.openOptionsPopup(tabs.activePage) - } + UncivGame.Current.reloadWorldscreen() } fun addCheckbox(table: Table, text: String, initialState: Boolean, updateWorld: Boolean = false, action: ((Boolean) -> Unit)) { diff --git a/core/src/com/unciv/ui/saves/LoadGameScreen.kt b/core/src/com/unciv/ui/saves/LoadGameScreen.kt index 5b7d4e6e7b..1053239a8d 100644 --- a/core/src/com/unciv/ui/saves/LoadGameScreen.kt +++ b/core/src/com/unciv/ui/saves/LoadGameScreen.kt @@ -81,7 +81,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() { 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) - launchOnGLThread { game.loadGame(loadedGame) } + game.loadGame(loadedGame) } catch (ex: Exception) { launchOnGLThread { loadingPopup.close() @@ -118,7 +118,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() { try { val clipboardContentsString = Gdx.app.clipboard.contents.trim() val loadedGame = GameSaver.gameInfoFromString(clipboardContentsString) - launchOnGLThread { game.loadGame(loadedGame) } + game.loadGame(loadedGame) } catch (ex: Exception) { launchOnGLThread { handleLoadGameException("Could not load game from clipboard!", ex) } } @@ -143,7 +143,9 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() { if (result.isError()) { handleLoadGameException("Could not load game from custom location!", result.exception) } else if (result.isSuccessful()) { - game.loadGame(result.gameData!!) + Concurrency.run { + game.loadGame(result.gameData!!) + } } } } diff --git a/core/src/com/unciv/ui/saves/QuickSave.kt b/core/src/com/unciv/ui/saves/QuickSave.kt index 89d8643cbc..56ad376041 100644 --- a/core/src/com/unciv/ui/saves/QuickSave.kt +++ b/core/src/com/unciv/ui/saves/QuickSave.kt @@ -94,12 +94,12 @@ object QuickSave { } } } else { - launchOnGLThread { /// ... and load it into the screen on main thread for GL context - try { - screen.game.loadGame(savedGame) - } catch (oom: OutOfMemoryError) { - outOfMemory() - } catch (ex: Exception) { + try { + screen.game.loadGame(savedGame) + } catch (oom: OutOfMemoryError) { + outOfMemory() + } catch (ex: Exception) { + launchOnGLThread { Log.error("Could not autoload game", ex) loadingPopup.close() ToastPopup("Cannot resume game!", screen) diff --git a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt index c63b44ce54..6ac629a79e 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt @@ -78,38 +78,54 @@ import com.unciv.ui.worldscreen.unit.UnitTable import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.launchOnGLThread import com.unciv.utils.concurrency.launchOnThreadPool +import com.unciv.utils.concurrency.withGLContext import com.unciv.utils.debug import kotlinx.coroutines.Job 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 viewingCiv The currently active [civilization][CivilizationInfo] - * @property shouldUpdate When set, causes the screen to update in the next [render][BaseScreen.render] event - * @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 + * @param restoreState */ -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 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 fogOfWar = true private set + + /** `true` when it's the player's turn unless he is a spectator*/ val canChangeState 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 val mapVisualization = MapVisualization(gameInfo, viewingCiv) - val mapHolder = WorldMapHolder(this, gameInfo.tileMap) private val minimapWrapper = MinimapHolder(mapHolder) private val topBar = WorldScreenTopBar(this) - val bottomUnitTable = UnitTable(this) + + private val bottomTileInfoTable = TileInfoTable(viewingCiv) private val battleTable = BattleTable(this) private val unitActionsTable = UnitActionsTable(this) @@ -124,8 +140,6 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas ImageGetter.getBlue().darken(0.5f)) } private val notificationsScroll = NotificationsScroll(this) - var shouldUpdate = false - private val zoomController = ZoomButtonPair(mapHolder) private var nextTurnUpdateJob: Job? = null @@ -139,12 +153,8 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas minimapWrapper.x = stage.width - minimapWrapper.width - try { // Most memory errors occur here, so this is a sort of catch-all - 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) - } + // This is the most memory-intensive operation we have currently, most OutOfMemory errors will occur here + mapHolder.addTiles() // resume music (in case choices from the menu lead to instantiation of a new WorldScreen) 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 // know what the viewing civ is. shouldUpdate = true @@ -349,10 +361,8 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } launchOnGLThread { loadingGamePopup.close() - if (game.gameInfo!!.gameId == gameInfo.gameId) { // game could've been changed during download - game.setScreen(createNewWorldScreen(latestGame)) - } } + startNewScreenJob(latestGame) } catch (ex: Throwable) { launchOnGLThread { 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 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) - else mapHolder.updateTiles(viewingCiv) - } catch (outOfMemoryError: OutOfMemoryError) { - ToastPopup("Not enough memory on phone to load game!", this) - } + if (fogOfWar) mapHolder.updateTiles(selectedCiv) + else mapHolder.updateTiles(viewingCiv) 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 - val newWorldScreen = WorldScreen(gameInfo, gameInfo.getPlayerToViewAs()) + private fun restore(restoreState: RestoreState) { // This is not the case if you have a multiplayer game where you play as 2 civs - if (!resize && newWorldScreen.viewingCiv.civName == viewingCiv.civName) { - newWorldScreen.mapHolder.width = mapHolder.width - newWorldScreen.mapHolder.height = mapHolder.height - newWorldScreen.mapHolder.scaleX = mapHolder.scaleX - newWorldScreen.mapHolder.scaleY = mapHolder.scaleY - newWorldScreen.mapHolder.scrollX = mapHolder.scrollX - newWorldScreen.mapHolder.scrollY = mapHolder.scrollY - newWorldScreen.mapHolder.updateVisualScroll() + if (viewingCiv.civName == restoreState.viewingCivName) { + mapHolder.zoom(restoreState.zoom) + mapHolder.scrollX = restoreState.scrollX + mapHolder.scrollY = restoreState.scrollY + mapHolder.updateVisualScroll() } - newWorldScreen.selectedCiv = gameInfo.getCivilization(selectedCiv.civName) - newWorldScreen.fogOfWar = fogOfWar - - return newWorldScreen + selectedCiv = gameInfo.getCivilization(restoreState.selectedCivName) + fogOfWar = restoreState.fogOfWar } 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 return@runOnNonDaemonThreadPool - this@WorldScreen.game.gameInfo = gameInfoClone debug("Next turn took %sms", System.currentTimeMillis() - startTime) - val shouldAutoSave = gameInfoClone.turns % game.settings.turnsBetweenAutosaves == 0 - - // 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 - } - } - } + startNewScreenJob(gameInfoClone) } } @@ -833,8 +822,9 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } override fun resize(width: Int, height: Int) { - if (stage.viewport.screenWidth != width || stage.viewport.screenHeight != height) - createNewWorldScreen(gameInfo, resize=true) // start over + if (stage.viewport.screenWidth != width || stage.viewport.screenHeight != height) { + startNewScreenJob(gameInfo) // start over + } } @@ -899,4 +889,34 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas 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() + } + } } diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt index 5e6839ef31..d608c02f22 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt @@ -16,8 +16,7 @@ class WorldScreenMenuPopup(val worldScreen: WorldScreen) : Popup(worldScreen) { defaults().fillX() 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.setScreen(MainMenuScreen()) + worldScreen.game.goToMainMenu() } addButton("Civilopedia") { close() diff --git a/core/src/com/unciv/utils/concurrency/Concurrency.kt b/core/src/com/unciv/utils/concurrency/Concurrency.kt index b924235bc3..8685dcf714 100644 --- a/core/src/com/unciv/utils/concurrency/Concurrency.kt +++ b/core/src/com/unciv/utils/concurrency/Concurrency.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.Runnable import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.concurrent.CancellationException import java.util.concurrent.ExecutorService 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. */ 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 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 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 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. diff --git a/desktop/src/com/unciv/app/desktop/ConsoleLauncher.kt b/desktop/src/com/unciv/app/desktop/ConsoleLauncher.kt index 6efef61e4f..c96a0bb26d 100644 --- a/desktop/src/com/unciv/app/desktop/ConsoleLauncher.kt +++ b/desktop/src/com/unciv/app/desktop/ConsoleLauncher.kt @@ -48,7 +48,7 @@ internal object ConsoleLauncher { val mapParameters = getMapParameters() val gameSetupInfo = GameSetupInfo(gameParameters, mapParameters) val newGame = GameStarter.startNewGame(gameSetupInfo) - UncivGame.Current.gameInfo = newGame + UncivGame.Current.startSimulation(newGame) val simulation = Simulation(newGame,10,4) diff --git a/tests/src/com/unciv/testing/SerializationTests.kt b/tests/src/com/unciv/testing/SerializationTests.kt index b9b7ceb3cc..7c3910ad87 100644 --- a/tests/src/com/unciv/testing/SerializationTests.kt +++ b/tests/src/com/unciv/testing/SerializationTests.kt @@ -68,7 +68,7 @@ class SerializationTests { UncivGame.Current.settings = GameSettings() 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 val civ = game.getCurrentPlayerCivilization()