Files
Unciv/core/src/com/unciv/UncivGame.kt
Yair Morgenstern 4b8bef5def Version rollout (#10633)
* Bump version and create initial changelog entry

* Update Spanish.properties (#10627)

* Update Brazilian_Portuguese.properties (#10616)

* Update French.properties (#10615)

* Update French.properties

* Update French.properties

- Improve translation of leader lines

* Update Italian.properties (#10612)

---------

Co-authored-by: yairm210 <yairm210@users.noreply.github.com>
Co-authored-by: Caballero Arepa <78449553+Caballero-Arepa@users.noreply.github.com>
Co-authored-by: Vitor Gabriel <59321138+Ranbut@users.noreply.github.com>
Co-authored-by: Ouaz <Ouaz@users.noreply.github.com>
Co-authored-by: Giuseppe D'Addio <41149920+Smashfanful@users.noreply.github.com>
2023-11-30 19:44:30 +02:00

496 lines
20 KiB
Kotlin

package com.unciv
import com.badlogic.gdx.Application
import com.badlogic.gdx.Game
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Input
import com.badlogic.gdx.Screen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.utils.Align
import com.unciv.logic.GameInfo
import com.unciv.logic.IsPartOfGameInfoSerialization
import com.unciv.logic.UncivShowableException
import com.unciv.logic.civilization.PlayerType
import com.unciv.logic.files.UncivFiles
import com.unciv.logic.multiplayer.OnlineMultiplayer
import com.unciv.models.metadata.GameSettings
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.skins.SkinCache
import com.unciv.models.tilesets.TileSetCache
import com.unciv.models.translations.Translations
import com.unciv.ui.audio.GameSounds
import com.unciv.ui.audio.MusicController
import com.unciv.ui.audio.MusicMood
import com.unciv.ui.audio.MusicTrackChooserFlags
import com.unciv.ui.audio.SoundPlayer
import com.unciv.ui.components.extensions.center
import com.unciv.ui.components.fonts.Fonts
import com.unciv.ui.crashhandling.CrashScreen
import com.unciv.ui.crashhandling.wrapCrashHandlingUnit
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popups.ConfirmPopup
import com.unciv.ui.popups.Popup
import com.unciv.ui.screens.LanguagePickerScreen
import com.unciv.ui.screens.LoadingScreen
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen
import com.unciv.ui.screens.savescreens.LoadGameScreen
import com.unciv.ui.screens.worldscreen.PlayerReadyScreen
import com.unciv.ui.screens.worldscreen.WorldScreen
import com.unciv.utils.Concurrency
import com.unciv.utils.DebugUtils
import com.unciv.utils.Display
import com.unciv.utils.Log
import com.unciv.utils.PlatformSpecific
import com.unciv.utils.debug
import com.unciv.utils.launchOnGLThread
import com.unciv.utils.withGLContext
import com.unciv.utils.withThreadPoolContext
import java.io.PrintWriter
import java.util.EnumSet
import java.util.UUID
import kotlinx.coroutines.CancellationException
open class UncivGame(val isConsoleMode: Boolean = false) : Game(), PlatformSpecific {
var deepLinkedMultiplayerGame: String? = null
var gameInfo: GameInfo? = null
lateinit var settings: GameSettings
lateinit var musicController: MusicController
lateinit var onlineMultiplayer: OnlineMultiplayer
lateinit var files: UncivFiles
var isTutorialTaskCollapsed = false
var worldScreen: WorldScreen? = null
private set
/** Flag used only during initialization until the end of [create] */
protected var isInitialized = false
private set
/** A wrapped render() method that crashes to [CrashScreen] on a unhandled exception or error. */
private val wrappedCrashHandlingRender = { super.render() }.wrapCrashHandlingUnit()
// Stored here because I imagine that might be slightly faster than allocating for a new lambda every time, and the render loop is possibly one of the only places where that could have a significant impact.
val translations = Translations()
val screenStack = ArrayDeque<BaseScreen>()
override fun create() {
isInitialized = false // this could be on reload, therefore we need to keep setting this to false
Gdx.input.setCatchKey(Input.Keys.BACK, true)
if (Gdx.app.type != Application.ApplicationType.Desktop) {
DebugUtils.VISIBLE_MAP = false
}
Current = this
files = UncivFiles(Gdx.files)
// If this takes too long players, especially with older phones, get ANR problems.
// Whatever needs graphics needs to be done on the main thread,
// So it's basically a long set of deferred actions.
/** When we recreate the GL context for whatever reason (say - we moved to a split screen on Android),
* ALL objects that were related to the old context - need to be recreated.
* So far we have:
* - All textures (hence the texture atlas)
* - SpriteBatch (hence BaseScreen uses a new SpriteBatch for each screen)
* - Skin (hence BaseScreen.setSkin())
* - Font (hence Fonts.resetFont() inside setSkin())
*/
settings = files.getGeneralSettings() // needed for the screen
Display.setScreenMode(settings.screenMode, settings)
setAsRootScreen(GameStartScreen()) // NOT dependent on any atlas or skin
GameSounds.init()
musicController = MusicController() // early, but at this point does only copy volume from settings
installAudioHooks()
onlineMultiplayer = OnlineMultiplayer()
Concurrency.run {
// Check if the server is available in case the feature set has changed
try {
onlineMultiplayer.multiplayerServer.checkServerStatus()
} catch (ex: Exception) {
debug("Couldn't connect to server: " + ex.message)
}
}
ImageGetter.resetAtlases()
ImageGetter.reloadImages() // This needs to come after the settings, since we may have default visual mods
val imageGetterTilesets = ImageGetter.getAvailableTilesets()
val availableTileSets = TileSetCache.getAvailableTilesets(imageGetterTilesets)
if (settings.tileSet !in availableTileSets) { // If the configured tileset is no longer available, default back
settings.tileSet = Constants.defaultTileset
}
Gdx.graphics.isContinuousRendering = settings.continuousRendering
Concurrency.run("LoadJSON") {
RulesetCache.loadRulesets()
translations.tryReadTranslationForCurrentLanguage()
translations.loadPercentageCompleteOfLanguages()
TileSetCache.loadTileSetConfigs()
SkinCache.loadSkinConfigs()
val vanillaRuleset = RulesetCache.getVanillaRuleset()
if (settings.multiplayer.userId.isEmpty()) { // assign permanent user id
settings.multiplayer.userId = UUID.randomUUID().toString()
settings.save()
}
// Loading available fonts can take a long time on Android phones.
// Therefore we initialize the lazy parameters in the font implementation, while we're in another thread, to avoid ANRs on main thread
Fonts.fontImplementation.setFontFamily(settings.fontFamilyData, settings.getFontSize())
// This stuff needs to run on the main thread because it needs the GL context
launchOnGLThread {
BaseScreen.setSkin() // needs to come AFTER the Texture reset, since the buttons depend on it and after loadSkinConfigs to be able to use the SkinConfig
musicController.chooseTrack(suffixes = listOf(MusicMood.Menu, MusicMood.Ambient),
flags = EnumSet.of(MusicTrackChooserFlags.SuffixMustMatch))
ImageGetter.ruleset = vanillaRuleset // so that we can enter the map editor without having to load a game first
when {
settings.isFreshlyCreated -> setAsRootScreen(LanguagePickerScreen())
deepLinkedMultiplayerGame == null -> setAsRootScreen(MainMenuScreen())
else -> tryLoadDeepLinkedGame()
}
isInitialized = true
}
}
}
/**
* Loads a game, [disposing][BaseScreen.dispose] all screens.
*
* Initializes the state of all important modules.
*
* Automatically runs on the appropriate thread.
*
* Sets the returned `WorldScreen` as the only active screen.
*/
suspend fun loadGame(newGameInfo: GameInfo, callFromLoadScreen: Boolean = false): WorldScreen = withThreadPoolContext toplevel@{
val prevGameInfo = gameInfo
gameInfo = newGameInfo
if (gameInfo?.gameParameters?.isOnlineMultiplayer == true
&& gameInfo?.gameParameters?.anyoneCanSpectate == false
&& gameInfo!!.civilizations.none { it.playerId == settings.multiplayer.userId }) {
throw UncivShowableException("You are not allowed to spectate!")
}
initializeResources(prevGameInfo, newGameInfo)
val isLoadingSameGame = worldScreen != null && prevGameInfo != null && prevGameInfo.gameId == newGameInfo.gameId
val worldScreenRestoreState = if (!callFromLoadScreen && isLoadingSameGame) worldScreen!!.getRestoreState() else null
lateinit var loadingScreen: LoadingScreen
withGLContext {
// this is not merged with the below GL context block so that our loading screen gets a chance to show - otherwise
// we do it all in one swoop on the same thread and the application just "freezes" without loading screen for the duration.
loadingScreen = LoadingScreen(getScreen())
setScreen(loadingScreen)
}
return@toplevel withGLContext {
for (screen in screenStack) screen.dispose()
screenStack.clear()
worldScreen = null // This allows the GC to collect our old WorldScreen, otherwise we keep two WorldScreens in memory.
val newWorldScreen = WorldScreen(newGameInfo, newGameInfo.getPlayerToViewAs(), worldScreenRestoreState)
worldScreen = newWorldScreen
val moreThanOnePlayer = newGameInfo.civilizations.count { it.playerType == PlayerType.Human } > 1
val isSingleplayer = !newGameInfo.gameParameters.isOnlineMultiplayer
val screenToShow = if (moreThanOnePlayer && isSingleplayer) {
PlayerReadyScreen(newWorldScreen)
} else {
newWorldScreen
}
screenStack.addLast(screenToShow)
setScreen(screenToShow)
loadingScreen.dispose()
return@withGLContext newWorldScreen
}
}
/** 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.gameParameters.baseRuleset != newGameInfo.gameParameters.baseRuleset
|| prevGameInfo.gameParameters.mods != newGameInfo.gameParameters.mods) {
withGLContext {
ImageGetter.setNewRuleset(newGameInfo.ruleset)
}
val fullModList = newGameInfo.gameParameters.getModsAndBaseRuleset()
musicController.setModList(fullModList)
}
}
/** Re-creates the current [worldScreen], if there is any. */
suspend fun reloadWorldscreen() {
val curWorldScreen = worldScreen
val curGameInfo = gameInfo
if (curWorldScreen == null || curGameInfo == null) return
loadGame(curGameInfo)
}
/**
* @throws UnsupportedOperationException Use pushScreen or replaceCurrentScreen instead
*/
@Deprecated("Never use this, it's only here because it's part of the gdx.Game interface.", ReplaceWith("pushScreen"))
override fun setScreen(screen: Screen) {
throw UnsupportedOperationException("Use pushScreen or replaceCurrentScreen instead")
}
override fun getScreen(): BaseScreen? = super.getScreen() as? BaseScreen
private fun setScreen(newScreen: BaseScreen) {
debug("Setting new screen: %s, screenStack: %s", newScreen, screenStack)
Gdx.input.inputProcessor = newScreen.stage
super.setScreen(newScreen) // This can set the screen to the policy picker or tech picker screen, so the input processor must be set before
if (newScreen is WorldScreen) {
newScreen.shouldUpdate = true
}
Gdx.graphics.requestRendering()
}
/** Removes & [disposes][BaseScreen.dispose] all currently active screens in the [screenStack] and sets the given screen as the only screen. */
private fun setAsRootScreen(root: BaseScreen) {
for (screen in screenStack) screen.dispose()
screenStack.clear()
screenStack.addLast(root)
setScreen(root)
}
/** Adds a screen to be displayed instead of the current screen, with an option to go back to the previous screen by calling [popScreen] */
fun pushScreen(newScreen: BaseScreen) {
screenStack.addLast(newScreen)
setScreen(newScreen)
}
/**
* Pops the currently displayed screen off the screen stack and shows the previous screen.
*
* If there is no other screen than the current, will ask the user to quit the game and return null.
*
* Automatically [disposes][BaseScreen.dispose] the old screen.
*
* @return the new screen
*/
fun popScreen(): BaseScreen? {
if (screenStack.size == 1) {
musicController.pause()
UncivGame.Current.settings.autoPlay.stopAutoPlay()
ConfirmPopup(
screen = screenStack.last(),
question = "Do you want to exit the game?",
confirmText = "Exit",
restoreDefault = { musicController.resume() },
action = { Gdx.app.exit() }
).open(force = true)
return null
}
val oldScreen = screenStack.removeLast()
val newScreen = screenStack.last()
setScreen(newScreen)
newScreen.resume()
oldScreen.dispose()
return newScreen
}
/** Replaces the current screen with a new one. Automatically [disposes][BaseScreen.dispose] the old screen. */
fun replaceCurrentScreen(newScreen: BaseScreen) {
val oldScreen = screenStack.removeLast()
screenStack.addLast(newScreen)
setScreen(newScreen)
oldScreen.dispose()
}
/** Resets the game to the stored world screen and automatically [disposes][Screen.dispose] all other screens. */
fun resetToWorldScreen(): WorldScreen {
for (screen in screenStack.filter { it !is WorldScreen }) screen.dispose()
screenStack.removeAll { it !is WorldScreen }
val worldScreen= screenStack.last() as WorldScreen
// Re-initialize translations, images etc. that may have been 'lost' when we were playing around in NewGameScreen
val ruleset = worldScreen.gameInfo.ruleset
translations.translationActiveMods = ruleset.mods
ImageGetter.setNewRuleset(ruleset)
setScreen(worldScreen)
return worldScreen
}
private fun tryLoadDeepLinkedGame() = Concurrency.run("LoadDeepLinkedGame") {
if (deepLinkedMultiplayerGame == null) return@run
launchOnGLThread {
if (screenStack.isEmpty() || screenStack[0] !is GameStartScreen) {
setAsRootScreen(LoadingScreen(getScreen()!!))
}
}
try {
onlineMultiplayer.loadGame(deepLinkedMultiplayerGame!!)
} catch (ex: Exception) {
launchOnGLThread {
val mainMenu = MainMenuScreen()
replaceCurrentScreen(mainMenu)
val popup = Popup(mainMenu)
val (message) = LoadGameScreen.getLoadExceptionMessage(ex)
popup.addGoodSizedLabel(message)
popup.row()
popup.addCloseButton()
popup.open()
}
} finally {
deepLinkedMultiplayerGame = null
}
}
// This is ALWAYS called after create() on Android - google "Android life cycle"
override fun resume() {
super.resume()
if (!isInitialized) return // The stuff from Create() is still happening, so the main screen will load eventually
musicController.resume()
// This is also needed in resume to open links and notifications
// correctly when the app was already running. The handling in onCreate
// does not seem to be enough
tryLoadDeepLinkedGame()
}
override fun pause() {
// Needs to go ASAP - on Android, there's a tiny race condition: The OS will stop our playback forcibly, it likely
// already has, but if we do _our_ pause before the MusicController timer notices, it will at least remember the current track.
if (::musicController.isInitialized) musicController.pause()
val curGameInfo = gameInfo
if (curGameInfo != null) files.requestAutoSave(curGameInfo)
super.pause()
}
override fun resize(width: Int, height: Int) {
screen.resize(width, height)
}
override fun render() = wrappedCrashHandlingRender()
override fun dispose() {
Gdx.input.inputProcessor = null // don't allow ANRs when shutting down, that's silly
SoundPlayer.clearCache()
if (::musicController.isInitialized) musicController.gracefulShutdown() // Do allow fade-out
// We stop the *in-game* multiplayer update, so that it doesn't keep working and A. we'll have errors and B. we'll have multiple updaters active
if (::onlineMultiplayer.isInitialized) onlineMultiplayer.multiplayerGameUpdater.cancel()
val curGameInfo = gameInfo
if (curGameInfo != null) {
val autoSaveJob = files.autoSaveJob
if (autoSaveJob != null && autoSaveJob.isActive) {
// auto save is already in progress (e.g. started by onPause() event)
// let's allow it to finish and do not try to autosave second time
Concurrency.runBlocking {
autoSaveJob.join()
}
} else {
files.autoSave(curGameInfo) // NO new thread
}
}
settings.save()
Concurrency.stopThreadPools()
// On desktop this should only be this one and "DestroyJavaVM"
logRunningThreads()
// DO NOT `exitProcess(0)` - bypasses all Gdx and GLFW cleanup
}
private fun logRunningThreads() {
val numThreads = Thread.activeCount()
val threadList = Array(numThreads) { Thread() }
Thread.enumerate(threadList)
threadList.filter { it !== Thread.currentThread() && it.name != "DestroyJavaVM" }.forEach {
debug("Thread %s still running in UncivGame.dispose().", it.name)
}
}
/** Handles an uncaught exception or error. First attempts a platform-specific handler, and if that didn't handle the exception or error, brings the game to a [CrashScreen]. */
fun handleUncaughtThrowable(ex: Throwable) {
if (ex is CancellationException) {
return // kotlin coroutines use this for control flow... so we can just ignore them.
}
Log.error("Uncaught throwable", ex)
try {
PrintWriter(files.fileWriter("lasterror.txt")).use {
ex.printStackTrace(it)
}
} catch (_: Exception) {
// ignore
}
Gdx.app.postRunnable {
setAsRootScreen(CrashScreen(ex))
}
}
/** Returns the [worldScreen] if it is the currently active screen of the game */
fun getWorldScreenIfActive(): WorldScreen? {
return if (screen == worldScreen) worldScreen else null
}
fun goToMainMenu(): MainMenuScreen {
val curGameInfo = gameInfo
if (curGameInfo != null) {
files.requestAutoSaveUnCloned(curGameInfo) // Can save gameInfo directly because the user can't modify it on the MainMenuScreen
}
val mainMenuScreen = MainMenuScreen()
pushScreen(mainMenuScreen)
return mainMenuScreen
}
/** Sets a simulated [GameInfo] object this game should run on */
fun startSimulation(simulatedGameInfo: GameInfo) {
gameInfo = simulatedGameInfo
}
companion object {
//region AUTOMATICALLY GENERATED VERSION DATA - DO NOT CHANGE THIS REGION, INCLUDING THIS COMMENT
val VERSION = Version("4.9.4", 939)
//endregion
lateinit var Current: UncivGame
fun isCurrentInitialized() = this::Current.isInitialized
fun isCurrentGame(gameId: String): Boolean = isCurrentInitialized() && Current.gameInfo != null && Current.gameInfo!!.gameId == gameId
fun isDeepLinkedGameLoading() = isCurrentInitialized() && Current.deepLinkedMultiplayerGame != null
}
data class Version(
val text: String,
val number: Int
) : IsPartOfGameInfoSerialization {
@Suppress("unused") // used by json serialization
constructor() : this("", -1)
fun toNiceString() = "$text (Build $number)"
}
}
private class GameStartScreen : BaseScreen() {
init {
val logoImage = ImageGetter.getExternalImage("banner.png")
logoImage.center(stage)
logoImage.setOrigin(Align.center)
logoImage.color = Color.WHITE.cpy().apply { a = 0f }
logoImage.addAction(Actions.alpha(1f, 0.3f))
stage.addActor(logoImage)
}
}