diff --git a/core/src/com/unciv/logic/GameInfo.kt b/core/src/com/unciv/logic/GameInfo.kt index 038606c66d..d7cc1e6e66 100644 --- a/core/src/com/unciv/logic/GameInfo.kt +++ b/core/src/com/unciv/logic/GameInfo.kt @@ -34,6 +34,7 @@ import com.unciv.models.ruleset.nation.Difficulty import com.unciv.models.ruleset.unique.UniqueType import com.unciv.ui.audio.MusicMood import com.unciv.ui.audio.MusicTrackChooserFlags +import com.unciv.ui.screens.worldscreen.status.NextTurnProgress import com.unciv.utils.DebugUtils import com.unciv.utils.debug import java.util.UUID @@ -277,7 +278,7 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion fun isSimulation(): Boolean = turns < DebugUtils.SIMULATE_UNTIL_TURN || turns < simulateMaxTurns && simulateUntilWin - fun nextTurn() { + fun nextTurn(progressBar: NextTurnProgress? = null) { var player = currentPlayerCiv var playerIndex = civilizations.indexOf(player) @@ -299,7 +300,7 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion // would skip a turn if an AI civ calls nextTurn // this happens when resigning a multiplayer game) if (player.isHuman()) { - TurnManager(player).endTurn() + TurnManager(player).endTurn(progressBar) setNextPlayer() } @@ -314,7 +315,7 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion { // Starting preparations - TurnManager(player).startTurn() + TurnManager(player).startTurn(progressBar) // Automation done here TurnManager(player).automateTurn() @@ -326,7 +327,7 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion } // Clean up - TurnManager(player).endTurn() + TurnManager(player).endTurn(progressBar) // To the next player setNextPlayer() @@ -341,7 +342,7 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion currentPlayerCiv = getCivilization(currentPlayer) // Starting his turn - TurnManager(player).startTurn() + TurnManager(player).startTurn(progressBar) // No popups for spectators if (currentPlayerCiv.isSpectator()) diff --git a/core/src/com/unciv/logic/civilization/managers/TurnManager.kt b/core/src/com/unciv/logic/civilization/managers/TurnManager.kt index 4dd9ee6bba..071b1352db 100644 --- a/core/src/com/unciv/logic/civilization/managers/TurnManager.kt +++ b/core/src/com/unciv/logic/civilization/managers/TurnManager.kt @@ -19,6 +19,7 @@ import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.ruleset.unique.endTurn import com.unciv.models.stats.Stats import com.unciv.ui.components.MayaCalendar +import com.unciv.ui.screens.worldscreen.status.NextTurnProgress import com.unciv.utils.Log import kotlin.math.max import kotlin.math.min @@ -27,7 +28,7 @@ import kotlin.random.Random class TurnManager(val civInfo: Civilization) { - fun startTurn() { + fun startTurn(progressBar: NextTurnProgress? = null) { if (civInfo.isSpectator()) return if (civInfo.isMajorCiv() && civInfo.isAlive()) { @@ -66,7 +67,10 @@ class TurnManager(val civInfo: Civilization) { civInfo.cache.updateCitiesConnectedToCapital() startTurnFlags() updateRevolts() - for (city in civInfo.cities) CityTurnManager(city).startTurn() // Most expensive part of startTurn + for (city in civInfo.cities) { + progressBar?.increment() + CityTurnManager(city).startTurn() // Most expensive part of startTurn + } for (unit in civInfo.units.getCivUnits()) UnitTurnManager(unit).startTurn() @@ -218,7 +222,7 @@ class TurnManager(val civInfo: Civilization) { ((4 + Random.Default.nextInt(3)) * max(civInfo.gameInfo.speed.modifier, 1f)).toInt() - fun endTurn() { + fun endTurn(progressBar: NextTurnProgress? = null) { val notificationsLog = civInfo.notificationsLog val notificationsThisTurn = Civilization.NotificationsLog(civInfo.gameInfo.turns) notificationsThisTurn.notifications.addAll(civInfo.notifications) @@ -278,8 +282,10 @@ class TurnManager(val civInfo: Civilization) { // To handle tile's owner issue (#8246), we need to run cities being razed first. // a city can be removed while iterating (if it's being razed) so we need to iterate over a copy - sorting does one - for (city in civInfo.cities.sortedByDescending { it.isBeingRazed }) + for (city in civInfo.cities.sortedByDescending { it.isBeingRazed }) { + progressBar?.increment() CityTurnManager(city).endTurn() + } civInfo.temporaryUniques.endTurn() diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt index afb37003f2..fd6c75903c 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldScreen.kt @@ -57,6 +57,7 @@ import com.unciv.ui.screens.worldscreen.bottombar.TileInfoTable import com.unciv.ui.screens.worldscreen.minimap.MinimapHolder import com.unciv.ui.screens.worldscreen.status.MultiplayerStatusButton import com.unciv.ui.screens.worldscreen.status.NextTurnButton +import com.unciv.ui.screens.worldscreen.status.NextTurnProgress import com.unciv.ui.screens.worldscreen.status.StatusButtons import com.unciv.ui.screens.worldscreen.unit.UnitTable import com.unciv.ui.screens.worldscreen.unit.actions.UnitActionsTable @@ -557,6 +558,8 @@ class WorldScreen( fun nextTurn() { isPlayersTurn = false shouldUpdate = true + val progressBar = NextTurnProgress(nextTurnButton) + progressBar.start(this) // on a separate thread so the user can explore their world while we're passing the turn nextTurnUpdateJob = Concurrency.runOnNonDaemonThreadPool("NextTurn") { @@ -566,7 +569,9 @@ class WorldScreen( val gameInfoClone = originalGameInfo.clone() gameInfoClone.setTransients() // this can get expensive on large games, not the clone itself - gameInfoClone.nextTurn() + progressBar.increment() + + gameInfoClone.nextTurn(progressBar) if (originalGameInfo.gameParameters.isOnlineMultiplayer) { try { @@ -617,6 +622,8 @@ class WorldScreen( gameInfoClone.isUpToDate = true } + progressBar.increment() + startNewScreenJob(gameInfoClone) } } @@ -713,7 +720,7 @@ class WorldScreen( displayTutorial(TutorialTrigger.LuxuryResource) { resources.any { it.resource.resourceType == ResourceType.Luxury } } displayTutorial(TutorialTrigger.StrategicResource) { resources.any { it.resource.resourceType == ResourceType.Strategic } } displayTutorial(TutorialTrigger.EnemyCity) { - viewingCiv.getKnownCivs().asSequence().filter { viewingCiv.isAtWarWith(it) } + viewingCiv.getKnownCivs().filter { viewingCiv.isAtWarWith(it) } .flatMap { it.cities.asSequence() }.any { viewingCiv.hasExplored(it.getCenterTile()) } } displayTutorial(TutorialTrigger.ApolloProgram) { viewingCiv.hasUnique(UniqueType.EnablesConstructionOfSpaceshipParts) } diff --git a/core/src/com/unciv/ui/screens/worldscreen/status/NextTurnButton.kt b/core/src/com/unciv/ui/screens/worldscreen/status/NextTurnButton.kt index 23bef68e6f..752276e3e0 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/status/NextTurnButton.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/status/NextTurnButton.kt @@ -27,7 +27,7 @@ import com.unciv.utils.Concurrency import com.unciv.utils.launchOnGLThread class NextTurnButton : IconTextButton("", null, 30) { - private var nextTurnAction = NextTurnAction("", Color.BLACK) {} + private var nextTurnAction = NextTurnAction.Default init { // label.setFontSize(30) @@ -41,29 +41,31 @@ class NextTurnButton : IconTextButton("", null, 30) { fun update(worldScreen: WorldScreen) { nextTurnAction = getNextTurnAction(worldScreen) + updateButton(nextTurnAction) + + isEnabled = !worldScreen.hasOpenPopups() && worldScreen.isPlayersTurn + && !worldScreen.waitingForAutosave && !worldScreen.isNextTurnUpdateRunning() + } + internal fun updateButton(nextTurnAction: NextTurnAction) { label.setText(nextTurnAction.text.tr()) label.color = nextTurnAction.color - if (nextTurnAction.icon != null && ImageGetter.imageExists(nextTurnAction.icon!!)) + if (nextTurnAction.icon != null && ImageGetter.imageExists(nextTurnAction.icon)) iconCell.setActor(ImageGetter.getImage(nextTurnAction.icon).apply { setSize(30f) }) else iconCell.clearActor() pack() - - isEnabled = !worldScreen.hasOpenPopups() && worldScreen.isPlayersTurn - && !worldScreen.waitingForAutosave && !worldScreen.isNextTurnUpdateRunning() } private fun getNextTurnAction(worldScreen: WorldScreen): NextTurnAction { return when { worldScreen.isNextTurnUpdateRunning() -> - NextTurnAction("Working...", Color.GRAY, "NotificationIcons/Working") {} + NextTurnAction.Working !worldScreen.isPlayersTurn && worldScreen.gameInfo.gameParameters.isOnlineMultiplayer -> NextTurnAction("Waiting for [${worldScreen.gameInfo.currentPlayerCiv}]...", Color.GRAY, "NotificationIcons/Waiting") {} !worldScreen.isPlayersTurn && !worldScreen.gameInfo.gameParameters.isOnlineMultiplayer -> - NextTurnAction("Waiting for other players...",Color.GRAY, - "NotificationIcons/Waiting") {} + NextTurnAction.Waiting worldScreen.viewingCiv.cities.any { !it.isPuppet && @@ -185,7 +187,12 @@ class NextTurnButton : IconTextButton("", null, 30) { } } } - } -class NextTurnAction(val text: String, val color: Color, val icon: String? = null, val action: () -> Unit) +class NextTurnAction(val text: String, val color: Color, val icon: String? = null, val action: () -> Unit) { + companion object Prefabs { + val Default = NextTurnAction("", Color.BLACK) {} + val Working = NextTurnAction("Working...", Color.GRAY, "NotificationIcons/Working") {} + val Waiting = NextTurnAction("Waiting for other players...",Color.GRAY, "NotificationIcons/Waiting") {} + } +} diff --git a/core/src/com/unciv/ui/screens/worldscreen/status/NextTurnProgress.kt b/core/src/com/unciv/ui/screens/worldscreen/status/NextTurnProgress.kt new file mode 100644 index 0000000000..01a141c9a8 --- /dev/null +++ b/core/src/com/unciv/ui/screens/worldscreen/status/NextTurnProgress.kt @@ -0,0 +1,120 @@ +package com.unciv.ui.screens.worldscreen.status + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.actions.Actions +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.unciv.GUI +import com.unciv.models.metadata.GameParameters +import com.unciv.ui.components.extensions.disable +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.ui.screens.worldscreen.WorldScreen +import com.unciv.utils.Concurrency +import com.unciv.utils.Log + +class NextTurnProgress( + // nullable so we can free the reference once the ProgressBar is shown + private var nextTurnButton: NextTurnButton? +) : Table() { + companion object { + /** Background tint will be color of the right (shrinking) part of the bar - UI mods can override this */ + private val defaultRightColor = Color(0x600000ff) + /** Minimum Height of the bar if the moddable background has no minHeight */ + private const val defaultBarHeight = 4f + /** Distance from bottom of NextTurnButton */ + private const val barYPos = 1f + /** Bar width is NextTurnButton.width minus background ninepatch's declared outer widths minus this */ + private const val removeHorizontalPad = 25f + /** Speed of fading the bar in when it starts being rendered */ + private const val fadeInDuration = 1f + } + + private var progress = -1 + private var progressMax = 0 + private var isDirty = false + private var barWidth = 0f + + // Since we do UI update coroutine-decoupled there's a potential race conditon where the worldScreen + // gets replaced, and a pending update comes too late. To prevent re-showing when it's outdated we + // keep a hash in lieu of a weak reference - a normal reference might keep an outdated WorldScreen from + // being garbage collected, and java.lang.ref.WeakReference.refersTo requires a language level 16 opt-in. + private var worldScreenHash = 0 + + init { + background = BaseScreen.skinStrings.getUiBackground("WorldScreen/NextTurn/ProgressBar", tintColor = defaultRightColor) + val leftColor = BaseScreen.skinStrings.getUIColor("WorldScreen/NextTurn/ProgressColor", Color.FOREST) + add(ImageGetter.getDot(leftColor)) // active bar part + add() // Empty cell for the remainder portion of the bar + } + + fun start(worldScreen: WorldScreen) { + progress = 0 + val game = worldScreen.gameInfo + worldScreenHash = worldScreen.hashCode() + + fun GameParameters.isRandomNumberOfCivs() = randomNumberOfPlayers || randomNumberOfCityStates + fun GameParameters.minNumberOfCivs() = + (if (randomNumberOfPlayers) minNumberOfPlayers else players.size) + + (if (randomNumberOfCityStates) minNumberOfCityStates else numberOfCityStates) + + progressMax = 3 + // one extra step after clone and just before new worldscreen, 1 extra so it's never 100% + when { + // Later turns = two steps per city (startTurn and endTurn) + // Note we ignore cities being founded or destroyed - after turn 0 that proportion + // should be small, so the bar may clamp at max for a short while; + // or the new WordScreen starts before it's full. Far simpler code this way. + game.turns > 0 -> game.getCities().count() * 2 + // If we shouldn't disclose how many civs there are to Mr. Eagle Eye counting steps: + game.gameParameters.isRandomNumberOfCivs() -> game.gameParameters.minNumberOfCivs() + // One step per expected city to be founded (they get an endTurn, no startTurn) + else -> game.civilizations.count { it.isMajorCiv() && it.isAI() || it.isCityState() } + } + + startUpdateProgress() + } + + fun increment() { + progress++ + startUpdateProgress() + } + + private fun startUpdateProgress() { + isDirty = true + Concurrency.runOnGLThread { + updateProgress() + } + } + + private fun updateProgress() { + if (!isDirty) return + isDirty = false + + val currentWorldScreenHash = GUI.getWorldScreenIfActive()?.hashCode() ?: -1 + if (progressMax == 0 || currentWorldScreenHash != worldScreenHash) { + remove() + return + } + + // On first update the button text is not yet updated. To stabilize geometry, do it now + if (progress == 0) nextTurnButton?.apply { + disable() + updateButton(NextTurnAction.Working) + barWidth = width - removeHorizontalPad - + (background.leftWidth + background.rightWidth) // "cut off" the rounded parts of the button + this@NextTurnProgress.setPosition((width - barWidth) / 2, barYPos) + } + + val cellWidth = barWidth * progress.coerceAtMost(progressMax) / progressMax + val cellHeight = background.minHeight.coerceAtLeast(defaultBarHeight) + cells[0].actor.setSize(cellWidth, cellHeight) + cells[1].width(barWidth - cellWidth) // Necessary - Table has a quirk so a simple fillX() won't shrink + setSize(barWidth, defaultBarHeight) + + if (parent == null) { + color.a = 0f + nextTurnButton?.addActor(this) + addAction(Actions.fadeIn(fadeInDuration)) // Also helps hide the jerkiness when many cities are founded on turn 0 + nextTurnButton = null // Release reference as early as possible + } + } +} diff --git a/desktop/src/com/unciv/app/desktop/UiElementDocsWriter.kt b/desktop/src/com/unciv/app/desktop/UiElementDocsWriter.kt index 92486d7b2a..da4ce73120 100644 --- a/desktop/src/com/unciv/app/desktop/UiElementDocsWriter.kt +++ b/desktop/src/com/unciv/app/desktop/UiElementDocsWriter.kt @@ -21,14 +21,15 @@ class UiElementDocsWriter { val elements = mutableListOf() val backgroundRegex = Regex("""getUiBackground\((\X*?)"(?.*)"[ ,\n\r]*((BaseScreen\.)?skinStrings\.(?.*)Shape)?\X*?\)""") + @Suppress("RegExpRepeatedSpace") // IDE doesn't know about commented RegExes val colorRegex = Regex(""" getUIColor\s*\(\s* # function call, whitespace around opening round bracket optional. All \s also allow line breaks! "(?[^"]*)"\s* # captures "path", anything between double-quotes, not allowing for embedded quotes (?:,\s* # group for optional default parameter (?:default\s*=\s*)? # allow for named parameter - (?:Colors\s*\(|colorFromRGB\s*\(|Color\.) # recognize only Color constructor, colorFromRGB helper, or Color.* constants as argument + (?:Color\s*\(|colorFromRGB\s*\(|Color\.) # recognize only Color constructor, colorFromRGB helper, or Color.* constants as argument (?[^)]*) # capture "default" up until a closing round bracket - )\s*\) # ends default parameter group and checks closing round bracket of the getUIColor call + )?\s*\) # ends default parameter group and checks closing round bracket of the getUIColor call """, RegexOption.COMMENTS) for (file in srcFile.walk()) { diff --git a/docs/Modders/Creating-a-UI-skin.md b/docs/Modders/Creating-a-UI-skin.md index 3e8f6d211f..d8bd8ebddf 100644 --- a/docs/Modders/Creating-a-UI-skin.md +++ b/docs/Modders/Creating-a-UI-skin.md @@ -107,6 +107,8 @@ These shapes are used all over Unciv and can be replaced to make a lot of UI ele | WorldScreen/CityButton/ | InfluenceBar | null | | | WorldScreen/Minimap/ | Background | null | | | WorldScreen/Minimap/ | Border | null | | +| WorldScreen/NextTurn/ | ProgressBar | null | | +| WorldScreen/NextTurn/ | ProgressColor | FOREST | | | WorldScreen/TopBar/ | LeftAttachment | roundedEdgeRectangle | | | WorldScreen/TopBar/ | ResourceTable | null | | | WorldScreen/TopBar/ | RightAttachment | roundedEdgeRectangle | |