New game and custom/mod maps UI update and sync fixes (#11423)

* Offer custom map mod/ruleset and name as master/child SelectBoxes

* Let map Preview class (partial parse) include starting locations

* Implement button to use map-selected nations

* Show a LoadingImage while the maps are still loading

* Fix merge errors

* Usability improvements

* More out-of-sync fixes and improvements

* Template
This commit is contained in:
SomeTroglodyte 2024-04-09 22:11:46 +02:00 committed by GitHub
parent c037776674
commit b8706c1330
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 221 additions and 76 deletions

View File

@ -349,6 +349,7 @@ Four Corners =
Archipelago =
Inner Sea =
Perlin =
Select players from starting locations =
Random number of Civilizations =
Min number of Civilizations =
Max number of Civilizations =

View File

@ -40,22 +40,20 @@ object MapSaver {
private fun mapFromJson(json: String): TileMap = json().fromJson(TileMap::class.java, json)
/** Class to parse only the parameters out of a map file */
private class TileMapPreview {
val mapParameters = MapParameters()
}
fun loadMapParameters(mapFile: FileHandle): MapParameters {
return mapParametersFromSavedString(mapFile.readString())
return loadMapPreview(mapFile).mapParameters
}
@Suppress("MemberVisibilityCanBePrivate")
fun mapParametersFromSavedString(mapString: String): MapParameters {
fun loadMapPreview(mapFile: FileHandle): TileMap.Preview {
return mapPreviewFromSavedString(mapFile.readString())
}
private fun mapPreviewFromSavedString(mapString: String): TileMap.Preview {
val unzippedJson = try {
Gzip.unzip(mapString.trim())
} catch (_: Exception) {
mapString
}
return json().fromJson(TileMapPreview::class.java, unzippedJson).mapParameters
return json().fromJson(TileMap.Preview::class.java, unzippedJson)
}
}

View File

@ -106,7 +106,7 @@ class TileMap(initialCapacity: Int = 10) : IsPartOfGameInfoSerialization {
/**
* creates a hexagonal map of given radius (filled with grassland)
*
* To help you visualize how UnCiv hexagonal cooridinate system works, here's a small example:
* To help you visualize how UnCiv hexagonal coordinate system works, here's a small example:
*
* _____ _____ _____
* / \ / \ / \
@ -119,10 +119,10 @@ class TileMap(initialCapacity: Int = 10) : IsPartOfGameInfoSerialization {
* / 1 ,-2 \_____/ 0,-1 \_____/ -1,0 \_____/ -2,1 \
* \ / \ / \ / \ /
* \_____/ 0,-2 \_____/ -1,-1 \_____/ -2,0 \_____/
* / \ / \ / \ /
* / 0,-3 \_____/ -1,-2 \_____/ -2,-1 \_____/
* \ / \ / \ /
* \_____/ \_____/ \_____/
* / \ / \ / \ / \
* / 0,-3 \_____/ -1,-2 \_____/ -2,-1 \_____/ -3,0 \
* \ / \ / \ / \ /
* \_____/ \_____/ \_____/ \_____/
*
*
* The rules are simple if you think about your X and Y axis as diagonal w.r.t. a standard carthesian plane. As such:
@ -132,8 +132,9 @@ class TileMap(initialCapacity: Int = 10) : IsPartOfGameInfoSerialization {
* moving "up-right" and "down-left": moving along Y axis
* moving "up-left" and "down-right": moving along X axis
*
* Tip: you can always use the in-game map editor if you have any doubt
* */
* Tip: you can always use the in-game map editor if you have any doubt,
* and the "secret" options can turn on coordinate display on the main map.
*/
constructor(radius: Int, ruleset: Ruleset, worldWrap: Boolean = false)
: this (HexMath.getNumberOfTilesInHexagon(radius)) {
startingLocations.clear()
@ -749,4 +750,11 @@ class TileMap(initialCapacity: Int = 10) : IsPartOfGameInfoSerialization {
}
}
//endregion
/** Class to parse only the parameters and starting locations out of a map file */
class Preview {
val mapParameters = MapParameters()
private val startingLocations = arrayListOf<StartingLocation>()
fun getDeclaredNations() = startingLocations.asSequence().map { it.nation }.distinct()
}
}

View File

@ -75,6 +75,11 @@ fun <T> Iterable<T>.toGdxArray(): Array<T> {
for (it in this) arr.add(it)
return arr
}
fun <T> Sequence<T>.toGdxArray(): Array<T> {
val arr = Array<T>()
for (it in this) arr.add(it)
return arr
}
/** [yield][SequenceScope.yield]s [element] if it's not null */
suspend fun <T> SequenceScope<T>.yieldIfNotNull(element: T?) {

View File

@ -132,6 +132,8 @@ class LoadingImage(
if (animated) hideAnimated(onComplete)
else hideDelayed(onComplete)
fun isShowing() = loadingIcon.isVisible && actions.isEmpty
//region Hiding helpers
private fun hideAnimated(onComplete: (() -> Unit)?) {
actions.clear()

View File

@ -42,9 +42,9 @@ class GameOptionsTable(
private val updatePlayerPickerTable: (desiredCiv: String) -> Unit,
private val updatePlayerPickerRandomLabel: () -> Unit
) : Table(BaseScreen.skin) {
var gameParameters = previousScreen.gameSetupInfo.gameParameters
var ruleset = previousScreen.ruleset
var locked = false
private var gameParameters = previousScreen.gameSetupInfo.gameParameters
private var ruleset = previousScreen.ruleset
internal var locked = false
private var baseRulesetHash = gameParameters.baseRuleset.hashCode()
@ -56,7 +56,7 @@ class GameOptionsTable(
*
* The second reason this is public: [NewGameScreen] accesses [ModCheckboxTable.savedModcheckResult] for display.
*/
val modCheckboxes = getModCheckboxes(isPortrait = isPortrait)
internal val modCheckboxes = getModCheckboxes(isPortrait = isPortrait)
// Remember this so we can unselect it when the pool dialog returns an empty pool
private var randomNationsPoolCheckbox: CheckBox? = null
@ -74,12 +74,12 @@ class GameOptionsTable(
clear()
// Mods may have changed (e.g. custom map selection)
modCheckboxes.updateSelection()
val newBaseRulesetHash = gameParameters.baseRuleset.hashCode()
if (newBaseRulesetHash != baseRulesetHash) {
baseRulesetHash = newBaseRulesetHash
modCheckboxes.setBaseRuleset(gameParameters.baseRuleset)
}
modCheckboxes.updateSelection()
add(Table().apply {
defaults().pad(5f)
@ -442,6 +442,7 @@ class GameOptionsTable(
fun updateRuleset(ruleset: Ruleset) {
this.ruleset = ruleset
gameParameters.acceptedModCheckErrors = ""
modCheckboxes.updateSelection()
modCheckboxes.setBaseRuleset(gameParameters.baseRuleset)
}
@ -493,6 +494,11 @@ class GameOptionsTable(
updatePlayerPickerTable(desiredCiv)
}
fun changeGameParameters(newGameParameters: GameParameters) {
gameParameters = newGameParameters
modCheckboxes.changeGameParameters(newGameParameters)
}
}
private class RandomNationPickerPopup(

View File

@ -1,57 +1,98 @@
package com.unciv.ui.screens.newgamescreen
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.Container
import com.badlogic.gdx.scenes.scene2d.ui.SelectBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.UncivGame
import com.unciv.logic.civilization.PlayerType
import com.unciv.logic.files.MapSaver
import com.unciv.logic.map.MapParameters
import com.unciv.logic.map.TileMap
import com.unciv.models.metadata.Player
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.ruleset.nation.Nation
import com.unciv.models.translations.tr
import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.enable
import com.unciv.ui.components.extensions.pad
import com.unciv.ui.components.extensions.toGdxArray
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onChange
import com.unciv.ui.components.widgets.LoadingImage
import com.unciv.ui.popups.AnimatedMenuPopup
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.victoryscreen.LoadMapPreview
import com.unciv.utils.Concurrency
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import com.badlogic.gdx.utils.Array as GdxArray
class MapFileSelectTable(
private val newGameScreen: NewGameScreen,
private val mapParameters: MapParameters
) : Table() {
private val mapCategorySelectBox = SelectBox<String>(BaseScreen.skin)
private val mapFileSelectBox = SelectBox<MapWrapper>(BaseScreen.skin)
private val loadingIcon = LoadingImage(30f, LoadingImage.Style(loadingColor = Color.SCARLET))
private val useNationsFromMapButton = "Select players from starting locations".toTextButton(AnimatedMenuPopup.SmallButtonStyle())
private val useNationsButtonCell: Cell<Actor?>
private var mapNations = emptyList<Nation>()
private val miniMapWrapper = Container<Group?>()
private var mapPreviewJob: Job? = null
private var preselectedName = mapParameters.name
// The SelectBox auto displays the text a object.toString(), which on the FileHandle itself includes the folder path.
// So we wrap it in another object with a custom toString()
private class MapWrapper(val fileHandle: FileHandle, val mapParameters: MapParameters) {
override fun toString(): String = mapParameters.baseRuleset + " | " + fileHandle.name()
private class MapWrapper(val fileHandle: FileHandle, val mapPreview: TileMap.Preview) {
override fun toString(): String = fileHandle.name()
fun getCategoryName(): String = fileHandle.parent().parent().name()
.ifEmpty { mapPreview.mapParameters.baseRuleset }
}
private val mapWrappers = ArrayList<MapWrapper>()
private val columnWidth = newGameScreen.getColumnWidth()
private val collator = UncivGame.Current.settings.getCollatorFromLocale()
init {
defaults().pad(5f, 10f) // Must stay same as in MapParametersTable
val mapFileLabel = "{Map file}:".toLabel()
add(mapFileLabel).left()
add(mapFileSelectBox)
add(Table().apply {
defaults().pad(5f, 10f) // Must stay same as in MapParametersTable
val mapCategoryLabel = "{Map Mod}:".toLabel()
val mapFileLabel = "{Map file}:".toLabel()
// because SOME people gotta give the hugest names to their maps
.width(columnWidth - 40f - mapFileLabel.prefWidth)
.right().row()
val selectBoxWidth = columnWidth - 80f -
mapFileLabel.prefWidth.coerceAtLeast(mapCategoryLabel.prefWidth)
add(mapCategoryLabel).left()
add(mapCategorySelectBox).width(selectBoxWidth).right().row()
add(mapFileLabel).left()
add(mapFileSelectBox).width(selectBoxWidth).right().row()
}).growX()
add(loadingIcon).padRight(5f).padLeft(0f).row()
useNationsButtonCell = add().pad(0f)
row()
add(miniMapWrapper)
.pad(15f)
.maxWidth(columnWidth - 20f)
.colspan(2).center().row()
mapFileSelectBox.onChange { onSelectBoxChange() }
mapCategorySelectBox.onChange { onCategorySelectBoxChange() }
mapFileSelectBox.onChange { onFileSelectBoxChange() }
useNationsFromMapButton.onActivation { onUseNationsFromMap() }
addMapWrappersAsync()
}
@ -66,21 +107,58 @@ class MapFileSelectTable(
}.sortedByDescending { it.lastModified() }
private fun addMapWrappersAsync() {
val mapFilesSequence = getMapFilesSequence()
val mapFilesFlow = getMapFilesSequence().asFlow().map {
MapWrapper(it, MapSaver.loadMapPreview(it))
}
loadingIcon.show()
Concurrency.run {
for (mapFile in mapFilesSequence) {
val mapParameters = try {
MapSaver.loadMapParameters(mapFile)
} catch (_: Exception) {
continue
mapFilesFlow
.onEach {
mapWrappers.add(it)
Concurrency.runOnGLThread {
addAsyncEntryToSelectBoxes(it)
}
}
mapWrappers.add(MapWrapper(mapFile, mapParameters))
.catch {}
.collect()
Concurrency.runOnGLThread {
loadingIcon.hide {
loadingIcon.remove()
}
onCategorySelectBoxChange() // re-sort lower SelectBox
}
Concurrency.runOnGLThread { fillMapFileSelectBox() }
}
}
private fun addAsyncEntryToSelectBoxes(mapWrapper: MapWrapper) {
// Take the mod name where the map is stored, or if it's not a mod map, the base ruleset it's saved for
val categoryName = mapWrapper.getCategoryName()
if (!mapCategorySelectBox.items.contains(categoryName, false)) {
val sortToTop = newGameScreen.gameSetupInfo.gameParameters.baseRuleset
val select = if (mapCategorySelectBox.selection.isEmpty) categoryName
else mapCategorySelectBox.selected
// keep Ruleset SelectBox sorted while async is running - few entries
val newItems = (mapCategorySelectBox.items.asSequence() + categoryName)
.sortedWith(
compareBy<String?> { it != sortToTop }
.thenBy(collator) { it }
).toGdxArray()
mapCategorySelectBox.selection.setProgrammaticChangeEvents(false)
mapCategorySelectBox.items = newItems
mapCategorySelectBox.selected = select
mapCategorySelectBox.selection.setProgrammaticChangeEvents(true)
}
if (mapCategorySelectBox.selected != categoryName) return
// .. but add the maps themselves as they come
mapFileSelectBox.selection.setProgrammaticChangeEvents(false)
mapFileSelectBox.items.add(mapWrapper)
mapFileSelectBox.items = mapFileSelectBox.items // Call setItems so SelectBox sees the change
mapFileSelectBox.selection.setProgrammaticChangeEvents(true)
}
private val firstMap: FileHandle? by lazy {
getMapFilesSequence().firstOrNull {
@ -93,36 +171,42 @@ class MapFileSelectTable(
}
}
private fun FileHandle.isRecentlyModified() = lastModified() > System.currentTimeMillis() - 900000 // 900s = quarter hour
fun isNotEmpty() = firstMap != null
fun recentlySavedMapExists() = firstMap!=null && firstMap!!.lastModified() > System.currentTimeMillis() - 900000
fun recentlySavedMapExists() = firstMap != null && firstMap!!.isRecentlyModified()
private fun fillMapFileSelectBox() {
if (!mapFileSelectBox.items.isEmpty) return
fun activateCustomMaps() {
if (loadingIcon.isShowing()) return // Default map selection will be handled when background loading finishes
preselectedName = mapParameters.name
onFileSelectBoxChange()
}
val mapFiles = GdxArray<MapWrapper>()
mapWrappers
.sortedWith(compareBy(UncivGame.Current.settings.getCollatorFromLocale()) { it.toString() })
.sortedByDescending { it.mapParameters.baseRuleset == newGameScreen.gameSetupInfo.gameParameters.baseRuleset }
.forEach { mapFiles.add(it) }
private fun onCategorySelectBoxChange() {
val selectedRuleset: String? = mapCategorySelectBox.selected
val mapFiles = mapWrappers.asSequence()
.filter { it.getCategoryName() == selectedRuleset }
.sortedWith(compareBy(collator) { it.toString() })
.toGdxArray()
fun getPreselect(): MapWrapper? {
if (mapFiles.isEmpty) return null
val recent = mapFiles.asSequence()
.filter { it.fileHandle.isRecentlyModified() }
.maxByOrNull { it.fileHandle.lastModified() }
val oldestTimestamp = mapFiles.minOfOrNull { it.fileHandle.lastModified() } ?: 0L
// Do not use most recent if all maps in the category have the same time within a tenth of a second (like a mod unzip does)
if (recent != null && (recent.fileHandle.lastModified() - oldestTimestamp) > 100 || mapFiles.size == 1)
return recent
val named = mapFiles.firstOrNull { it.fileHandle.name() == preselectedName }
if (named != null)
return named
return mapFiles.first()
}
val selectedItem = getPreselect()
// Pre-select: a) map saved within last 15min or b) map named in mapParameters or c) alphabetically first
// This is a kludge - the better way would be to have a "play this map now" menu button in the editor
// (which would ideally not even require a save file - which makes implementation non-trivial)
val selectedItem =
mapFiles.maxByOrNull { it.fileHandle.lastModified() }
?.takeIf { it.fileHandle.lastModified() > System.currentTimeMillis() - 900000 }
?: mapFiles.firstOrNull { it.fileHandle.name() == mapParameters.name }
?: mapFiles.firstOrNull()
?: return
// since mapFileSelectBox.selection.setProgrammaticChangeEvents() defaults to true, this would
// kill the mapParameters.name we would like to look for when determining what to pre-select -
// so do it ***after*** getting selectedItem - but control programmaticChangeEvents anyway
// to avoid the overhead of doing a full updateRuleset + updateTables + startMapPreview
// avoid the overhead of doing a full updateRuleset + updateTables + startMapPreview
// (all expensive) for something that will be overthrown momentarily
mapFileSelectBox.selection.setProgrammaticChangeEvents(false)
mapFileSelectBox.items = mapFiles
// Now, we want this ON because the event removes map selections which are pulling mods
// that trip updateRuleset - so that code should still be active for the pre-selection
mapFileSelectBox.selection.setProgrammaticChangeEvents(true)
@ -131,17 +215,38 @@ class MapFileSelectTable(
// Do NOT try to put back the "bad" preselection!
}
fun onSelectBoxChange() {
private fun onFileSelectBoxChange() {
cancelBackgroundJobs()
if (mapFileSelectBox.selection.isEmpty) return
val mapFile = mapFileSelectBox.selected.fileHandle
mapParameters.name = mapFile.name()
newGameScreen.gameSetupInfo.mapFile = mapFile
val mapMods = mapFileSelectBox.selected.mapParameters.mods.partition { RulesetCache[it]?.modOptions?.isBaseRuleset == true }
val selection = mapFileSelectBox.selected
val mapMods = selection.mapPreview.mapParameters.mods
.partition { RulesetCache[it]?.modOptions?.isBaseRuleset == true }
newGameScreen.gameSetupInfo.gameParameters.mods = LinkedHashSet(mapMods.second)
newGameScreen.gameSetupInfo.gameParameters.baseRuleset = mapMods.first.firstOrNull()
?: mapFileSelectBox.selected.mapParameters.baseRuleset
?: selection.mapPreview.mapParameters.baseRuleset
val success = newGameScreen.tryUpdateRuleset(updateUI = true)
mapNations = if (success)
selection.mapPreview.getDeclaredNations()
.mapNotNull { newGameScreen.ruleset.nations[it] }
.filter { it.isMajorCiv }
.toList()
else emptyList()
if (mapNations.isEmpty()) {
useNationsButtonCell.setActor(null)
useNationsButtonCell.height(0f).pad(0f)
} else {
useNationsFromMapButton.enable()
useNationsButtonCell.setActor(useNationsFromMapButton)
useNationsButtonCell.height(useNationsFromMapButton.prefHeight).padLeft(5f).padTop(10f)
}
val mapFile = selection.fileHandle
mapParameters.name = mapFile.name()
newGameScreen.gameSetupInfo.mapFile = mapFile
newGameScreen.updateTables()
hideMiniMap()
if (success) {
@ -153,7 +258,7 @@ class MapFileSelectTable(
items.removeIndex(mapFileSelectBox.selectedIndex)
// Changing the array itself is not enough, SelectBox gets out of sync, need to call setItems()
mapFileSelectBox.items = items
// Note - this will have triggered a nested onSelectBoxChange()!
// Note - this will have triggered a nested onFileSelectBoxChange()!
}
}
@ -211,4 +316,19 @@ class MapFileSelectTable(
)
)
}
private fun onUseNationsFromMap() {
useNationsFromMapButton.disable()
val players = newGameScreen.playerPickerTable.gameParameters.players
players.clear()
val pickForHuman = mapNations.random().name
mapNations.asSequence()
.map { it.name to it.name.tr(hideIcons = true) } // Sort by translation but keep untranslated name
.sortedWith(
compareBy<Pair<String, String>>{ it.first != pickForHuman }
.thenBy(collator) { it.second }
).map { Player(it.first, if (it.first == pickForHuman) PlayerType.Human else PlayerType.AI) }
.toCollection(players)
newGameScreen.playerPickerTable.update()
}
}

View File

@ -38,7 +38,7 @@ class MapOptionsTable(private val newGameScreen: NewGameScreen, isReset: Boolean
MapGeneratedMainType.custom -> {
mapParameters.type = MapGeneratedMainType.custom
mapTypeSpecificTable.add(savedMapOptionsTable)
savedMapOptionsTable.onSelectBoxChange()
savedMapOptionsTable.activateCustomMaps()
newGameScreen.unlockTables()
}
MapGeneratedMainType.generated -> {

View File

@ -4,6 +4,7 @@ import com.badlogic.gdx.Gdx
import com.badlogic.gdx.scenes.scene2d.ui.CheckBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener
import com.unciv.models.metadata.GameParameters
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.ruleset.validation.ModCompatibility
@ -18,14 +19,14 @@ import com.unciv.ui.screens.basescreen.BaseScreen
* A widget containing one expander for extension mods.
* Manages compatibility checks, warns or prevents incompatibilities.
*
* @param mods In/out set of active mods, modified in place
* @param mods **Reference**: In/out set of active mods, modified in place: If this needs to change, call [changeGameParameters]
* @param initialBaseRuleset The selected base Ruleset, only for running mod checks against. Use [setBaseRuleset] to change on the fly.
* @param screen Parent screen, used only to show [ToastPopup]s
* @param isPortrait Used only for minor layout tweaks, arrangement is always vertical
* @param onUpdate Callback, parameter is the mod name, called after any checks that may prevent mod selection succeed.
*/
class ModCheckboxTable(
private val mods: LinkedHashSet<String>,
private var mods: LinkedHashSet<String>,
initialBaseRuleset: String,
private val screen: BaseScreen,
isPortrait: Boolean = false,
@ -217,4 +218,8 @@ class ModCheckboxTable(
.filter { it.widget.isChecked }
.map { it.mod }
.asIterable()
fun changeGameParameters(newGameParameters: GameParameters) {
mods = newGameParameters.mods
}
}

View File

@ -41,9 +41,9 @@ import com.unciv.ui.screens.pickerscreens.PickerScreen
import com.unciv.utils.Concurrency
import com.unciv.utils.Log
import com.unciv.utils.launchOnGLThread
import kotlinx.coroutines.coroutineScope
import java.net.URL
import java.util.UUID
import kotlinx.coroutines.coroutineScope
import kotlin.math.floor
import com.unciv.ui.components.widgets.AutoScrollPane as ScrollPane
@ -55,7 +55,7 @@ class NewGameScreen(
override var gameSetupInfo = defaultGameSetupInfo ?: GameSetupInfo.fromSettings()
override val ruleset = Ruleset() // updateRuleset will clear and add
private val newGameOptionsTable: GameOptionsTable
private val playerPickerTable: PlayerPickerTable
internal val playerPickerTable: PlayerPickerTable
private val mapOptionsTable: MapOptionsTable
init {
@ -406,7 +406,7 @@ class NewGameScreen(
fun updateTables() {
playerPickerTable.gameParameters = gameSetupInfo.gameParameters
playerPickerTable.update()
newGameOptionsTable.gameParameters = gameSetupInfo.gameParameters
newGameOptionsTable.changeGameParameters(gameSetupInfo.gameParameters)
newGameOptionsTable.update()
}