Two extension features for custom maps (#11443)

* Fix apparent bug removing starting locations

* Remove StartingLocation legacy support (when they were TileImprovements)

* Starting locations get a "usage" controlling new game "select players"

* Maps get a description map creators can use to pass info to their users

* Fix bad fix from last PR preventing then map preview of the initial selection to show in some cases

* Wrap all those setProgrammaticChangeEvents calls

* Fix None/Normal discrepancy
This commit is contained in:
SomeTroglodyte
2024-04-11 22:33:10 +02:00
committed by GitHub
parent a072f452b6
commit f590d8d561
7 changed files with 141 additions and 68 deletions

View File

@ -544,6 +544,8 @@ An overlay image is incompatible with world wrap and was deactivated. =
Choose a Wesnoth map file =
That map is invalid! =
("[code]" does not conform to TerrainCodesWML) =
Use for new game "Select players" button: =
Enter a description for the users of this map =
## Map/Tool names
My new map =
@ -571,6 +573,7 @@ Spawn river from/to =
Bottom left river =
Bottom right river =
Bottom river =
Player =
# Multiplayer

View File

@ -29,18 +29,6 @@ import kotlin.math.abs
* @param initialCapacity Passed to constructor of [tileList]
*/
class TileMap(initialCapacity: Int = 10) : IsPartOfGameInfoSerialization {
companion object {
/** Legacy way to store starting locations - now this is used only in [translateStartingLocationsFromMap] */
const val startingLocationPrefix = "StartingLocation "
/**
* To be backwards compatible, a json without a startingLocations element will be recognized by an entry with this marker
* New saved maps will never have this marker and will always have a serialized startingLocations list even if empty.
* New saved maps will also never have "StartingLocation" improvements, these are converted on load in [setTransients].
*/
private const val legacyMarker = " Legacy "
}
//region Fields, Serialized
var mapParameters = MapParameters()
@ -51,8 +39,29 @@ class TileMap(initialCapacity: Int = 10) : IsPartOfGameInfoSerialization {
* @param position [Vector2] of the location
* @param nation Name of the nation
*/
private data class StartingLocation(val position: Vector2 = Vector2.Zero, val nation: String = "") : IsPartOfGameInfoSerialization
private val startingLocations = arrayListOf(StartingLocation(Vector2.Zero, legacyMarker))
data class StartingLocation(
val position: Vector2 = Vector2.Zero,
val nation: String = "",
val usage: Usage = Usage.default // default for maps saved pior to this feature
) : IsPartOfGameInfoSerialization {
/** How a starting location may be used when the map is loaded for a new game */
enum class Usage(val label: String) {
/** Starting location only */
Normal("None"),
/** Use for "Select players from starting locations" */
Player("Player"),
/** Use as first Human player */
Human("Human")
;
companion object {
val default get() = Player
}
}
}
val startingLocations = arrayListOf<StartingLocation>()
/** Optional freeform text a mod map creator can set for their "customers" */
var description = ""
//endregion
//region Fields, Transient
@ -179,6 +188,8 @@ class TileMap(initialCapacity: Int = 10) : IsPartOfGameInfoSerialization {
toReturn.startingLocations.clear()
toReturn.startingLocations.ensureCapacity(startingLocations.size)
toReturn.startingLocations.addAll(startingLocations)
toReturn.description = description
toReturn.tileUniqueMapCache = tileUniqueMapCache
return toReturn
@ -635,45 +646,33 @@ class TileMap(initialCapacity: Int = 10) : IsPartOfGameInfoSerialization {
}
/**
* Initialize startingLocations transients, including legacy support (maps saved with placeholder improvements)
* Initialize startingLocations transients
*/
fun setStartingLocationsTransients() {
if (startingLocations.size == 1 && startingLocations[0].nation == legacyMarker)
return translateStartingLocationsFromMap()
startingLocationsByNation.clear()
for ((position, nationName) in startingLocations) {
startingLocationsByNation.addToMapOfSets(nationName, get(position))
}
}
/**
* Scan and remove placeholder improvements from map and build startingLocations from them
*/
private fun translateStartingLocationsFromMap() {
startingLocations.clear()
tileList.asSequence()
.filter { it.improvement?.startsWith(startingLocationPrefix) == true }
.map { it to StartingLocation(it.position, it.improvement!!.removePrefix(startingLocationPrefix)) }
.sortedBy { it.second.nation } // vanity, or to make diffs between un-gzipped map files easier
.forEach { (tile, startingLocation) ->
tile.removeImprovement()
startingLocations.add(startingLocation)
}
setStartingLocationsTransients()
}
/** Adds a starting position, maintaining the transients
*
* Note: Will not replace an existing StartingLocation to update its [usage]
* @return true if the starting position was not already stored as per [Collection]'s add */
fun addStartingLocation(nationName: String, tile: Tile): Boolean {
fun addStartingLocation(
nationName: String,
tile: Tile,
usage: StartingLocation.Usage = StartingLocation.Usage.Player
): Boolean {
if (startingLocationsByNation.contains(nationName, tile)) return false
startingLocations.add(StartingLocation(tile.position, nationName))
startingLocations.add(StartingLocation(tile.position, nationName, usage))
return startingLocationsByNation.addToMapOfSets(nationName, tile)
}
/** Removes a starting position, maintaining the transients
* @return true if the starting position was removed as per [Collection]'s remove */
fun removeStartingLocation(nationName: String, tile: Tile): Boolean {
if (startingLocationsByNation.contains(nationName, tile)) return false
if (!startingLocationsByNation.contains(nationName, tile)) return false
startingLocations.remove(StartingLocation(tile.position, nationName))
return startingLocationsByNation[nationName]!!.remove(tile)
// we do not clean up an empty startingLocationsByNation[nationName] set - not worth it
@ -755,6 +754,13 @@ class TileMap(initialCapacity: Int = 10) : IsPartOfGameInfoSerialization {
class Preview {
val mapParameters = MapParameters()
private val startingLocations = arrayListOf<StartingLocation>()
fun getDeclaredNations() = startingLocations.asSequence().map { it.nation }.distinct()
fun getDeclaredNations() = startingLocations.asSequence()
.filter { it.usage != StartingLocation.Usage.Normal }
.map { it.nation }
.distinct()
fun getNationsForHumanPlayer() = startingLocations.asSequence()
.filter { it.usage == StartingLocation.Usage.Human }
.map { it.nation }
.distinct()
}
}

View File

@ -21,9 +21,11 @@ import com.unciv.models.metadata.BaseRuleset
import com.unciv.models.metadata.GameSetupInfo
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache
import com.unciv.ui.components.UncivTextField
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.KeyShortcutDispatcherVeto
import com.unciv.ui.components.input.KeyboardPanningListener
import com.unciv.ui.components.input.onChange
import com.unciv.ui.components.tilegroups.TileGroup
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.images.ImageWithCustomSize
@ -83,6 +85,7 @@ class MapEditorScreen(map: TileMap? = null) : BaseScreen(), RecreateOnResize {
val tabs: MapEditorMainTabs
var tileClickHandler: ((tile: Tile)->Unit)? = null
private var zoomController: ZoomButtonPair? = null
val descriptionTextField = UncivTextField.create("Enter a description for the users of this map")
private val highlightedTileGroups = mutableListOf<TileGroup>()
@ -98,10 +101,12 @@ class MapEditorScreen(map: TileMap? = null) : BaseScreen(), RecreateOnResize {
} else {
ruleset = map.ruleset ?: RulesetCache.getComplexRuleset(map.mapParameters)
tileMap = map
descriptionTextField.text = map.description
}
mapHolder = newMapHolder() // will set up ImageGetter and translations, and all dirty flags
isDirty = false
descriptionTextField.onChange { isDirty = true }
tabs = MapEditorMainTabs(this)
MapEditorToolsDrawer(tabs, stage, mapHolder)
@ -212,6 +217,7 @@ class MapEditorScreen(map: TileMap? = null) : BaseScreen(), RecreateOnResize {
clearOverlayImages()
mapHolder.remove()
tileMap = map
descriptionTextField.text = map.description
ruleset = newRuleset ?: RulesetCache.getComplexRuleset(map.mapParameters)
mapHolder = newMapHolder()
isDirty = false

View File

@ -2,9 +2,12 @@ package com.unciv.ui.screens.mapeditorscreen.tabs
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.ui.ButtonGroup
import com.badlogic.gdx.scenes.scene2d.ui.CheckBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.map.TileMap
import com.unciv.logic.map.tile.RoadStatus
import com.unciv.logic.map.tile.Tile
import com.unciv.models.ruleset.Ruleset
@ -269,6 +272,7 @@ class MapEditorEditStartsTab(
private val ruleset: Ruleset
): Table(BaseScreen.skin), IMapEditorEditSubTabs {
private val collator = UncivGame.Current.settings.getCollatorFromLocale()
private val usageOptionGroup = ButtonGroup<CheckBox>()
init {
top()
@ -287,6 +291,8 @@ class MapEditorEditStartsTab(
}
} }).padBottom(0f).row()
addUsage()
// Create the nation list with the spectator nation included, and shown/interpreted as "Any Civ" starting location.
// We use Nation/Spectator because it hasn't been used yet and we need an icon within the Nation.
add(
@ -297,10 +303,13 @@ class MapEditorEditStartsTab(
UncivGame.Current.musicController.chooseTrack(it, MusicMood.Theme, MusicTrackChooserFlags.setSpecific)
val icon = "Nation/$it"
val pediaLink = if (it == Constants.spectator) "" else icon
val isMajorCiv = ruleset.nations[it]?.isMajorCiv ?: false
val selectedUsage = if (isMajorCiv) TileMap.StartingLocation.Usage.values()[usageOptionGroup.checkedIndex]
else TileMap.StartingLocation.Usage.Normal
editTab.setBrush(BrushHandlerType.Direct, it.spectatorToAnyCiv(), icon, pediaLink) { tile ->
// toggle the starting location here, note this allows
// both multiple locations per nation and multiple nations per tile
if (!tile.tileMap.addStartingLocation(it, tile))
if (!tile.tileMap.addStartingLocation(it, tile, selectedUsage))
tile.tileMap.removeStartingLocation(it, tile)
}
}
@ -318,6 +327,20 @@ class MapEditorEditStartsTab(
FormattedLine("[${it.name.spectatorToAnyCiv()}] starting location", link = it.name, icon = "Nation/${it.name}", size = 24)
}.asIterable()
private fun addUsage() {
val table = Table()
table.defaults().pad(5f)
table.add("Use for new game \"Select players\" button:".toLabel()).colspan(3).row()
val defaultUsage = TileMap.StartingLocation.Usage.default
for (usage in TileMap.StartingLocation.Usage.values()) {
val checkBox = CheckBox(usage.label.tr(), skin)
table.add(checkBox)
usageOptionGroup.add(checkBox)
checkBox.isChecked = usage == defaultUsage
}
add(table).row()
}
override fun isDisabled() = allowedNations().none()
}

View File

@ -9,8 +9,6 @@ import com.unciv.logic.files.MapSaver
import com.unciv.logic.map.MapGeneratedMainType
import com.unciv.logic.map.TileMap
import com.unciv.models.translations.tr
import com.unciv.ui.components.widgets.AutoScrollPane
import com.unciv.ui.components.widgets.TabbedPager
import com.unciv.ui.components.UncivTextField
import com.unciv.ui.components.extensions.isEnabled
import com.unciv.ui.components.extensions.toTextButton
@ -19,6 +17,8 @@ import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onChange
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.widgets.AutoScrollPane
import com.unciv.ui.components.widgets.TabbedPager
import com.unciv.ui.popups.ConfirmPopup
import com.unciv.ui.popups.Popup
import com.unciv.ui.popups.ToastPopup
@ -89,6 +89,7 @@ class MapEditorSaveTab(
if (mapNameTextField.text.isBlank()) return
editorScreen.tileMap.mapParameters.name = mapNameTextField.text
editorScreen.tileMap.mapParameters.type = MapGeneratedMainType.custom
editorScreen.tileMap.description = editorScreen.descriptionTextField.text
setSaveButton(false)
editorScreen.startBackgroundJob("MapSaver", false) { saverThread() }
}

View File

@ -101,6 +101,8 @@ class MapEditorViewTab(
val statsLabel = WrappableLabel(statsText, labelWidth)
add(statsLabel.apply { wrap = true }).row()
add(editorScreen.descriptionTextField).growX().row()
// Map editor must not touch tileMap.naturalWonders as it is a by lazy immutable list,
// and we wouldn't be able to fix it when the natural wonders change
if (editorScreen.naturalWondersNeedRefresh) {
@ -191,8 +193,7 @@ class MapEditorViewTab(
lines += FormattedLine(stats.toString())
}
val nations = tile.tileMap.getTileStartingLocations(tile)
.joinToString { it.name.tr() }
val nations = tile.tileMap.getTileStartingLocationSummary(tile)
if (nations.isNotEmpty()) {
lines += FormattedLine()
lines += FormattedLine("Starting location(s): [$nations]")
@ -257,11 +258,12 @@ class MapEditorViewTab(
tileClickHandler(tile)
}
private fun TileMap.getTileStartingLocations(tile: Tile?) =
startingLocationsByNation.asSequence()
.filter { tile == null || tile in it.value }
.mapNotNull { ruleset!!.nations[it.key] }
.sortedWith(compareBy<Nation>{ it.isCityState }.thenBy(collator) { it.name.tr(hideIcons = true) })
private fun TileMap.getTileStartingLocationSummary(tile: Tile) =
startingLocations.asSequence()
.filter { it.position == tile.position }
.mapNotNull { if (it.nation in ruleset!!.nations) ruleset!!.nations[it.nation]!! to it.usage else null }
.sortedWith(compareBy<Pair<Nation,TileMap.StartingLocation.Usage>>{ it.first.isCityState }.thenBy(collator) { it.first.name.tr(hideIcons = true) })
.joinToString { "{${it.first.name}} ({${it.second.label}})".tr() }
private fun TileMap.getStartingLocationSummary() =
startingLocationsByNation.asSequence()

View File

@ -7,6 +7,7 @@ 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.Label
import com.badlogic.gdx.scenes.scene2d.ui.SelectBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.UncivGame
@ -39,6 +40,12 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
private inline fun <T> SelectBox<T>.withoutChangeEvents(block: SelectBox<T>.() -> Unit) {
selection.setProgrammaticChangeEvents(false)
block()
selection.setProgrammaticChangeEvents(true)
}
class MapFileSelectTable(
private val newGameScreen: NewGameScreen,
private val mapParameters: MapParameters
@ -49,9 +56,12 @@ class MapFileSelectTable(
private val useNationsFromMapButton = "Select players from starting locations".toTextButton(AnimatedMenuPopup.SmallButtonStyle())
private val useNationsButtonCell: Cell<Actor?>
private var mapNations = emptyList<Nation>()
private var mapHumanPick: String? = null
private val miniMapWrapper = Container<Group?>()
private var mapPreviewJob: Job? = null
private var preselectedName = mapParameters.name
private val descriptionLabel = "".toLabel()
private val descriptionLabelCell: Cell<Label>
// 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()
@ -85,6 +95,9 @@ class MapFileSelectTable(
useNationsButtonCell = add().pad(0f)
row()
descriptionLabelCell = add(descriptionLabel)
descriptionLabelCell.height(0f).row()
add(miniMapWrapper)
.pad(15f)
.maxWidth(columnWidth - 20f)
@ -126,7 +139,7 @@ class MapFileSelectTable(
loadingIcon.hide {
loadingIcon.remove()
}
onCategorySelectBoxChange() // re-sort lower SelectBox
onCategorySelectBoxChange() // re-sort lower SelectBox, and trigger map selection (mod select, description and preview)
}
}
}
@ -145,19 +158,19 @@ class MapFileSelectTable(
compareBy<String?> { it != sortToTop }
.thenBy(collator) { it }
).toGdxArray()
mapCategorySelectBox.selection.setProgrammaticChangeEvents(false)
mapCategorySelectBox.items = newItems
mapCategorySelectBox.selected = select
mapCategorySelectBox.selection.setProgrammaticChangeEvents(true)
mapCategorySelectBox.withoutChangeEvents {
items = newItems
selected = select
}
}
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)
mapFileSelectBox.withoutChangeEvents {
items.add(mapWrapper)
setItems(items) // Call setItems so SelectBox sees the change
}
}
private val firstMap: FileHandle? by lazy {
@ -205,12 +218,17 @@ class MapFileSelectTable(
// 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)
mapFileSelectBox.selected = selectedItem
mapFileSelectBox.withoutChangeEvents {
items = mapFiles
selected = selectedItem
}
// Now, we want this to *always* run even when setting mapFileSelectBox.selected would
// not have fired the event (which it won't if the selection is already as asked).
// Reasons:
// * Mods that trip validation in updateRuleset
// * Update description and minimap preview
onFileSelectBoxChange()
// In the event described above, we now have: mapFileSelectBox.selected != selectedItem
// Do NOT try to put back the "bad" preselection!
}
@ -227,12 +245,18 @@ class MapFileSelectTable(
?: selection.mapPreview.mapParameters.baseRuleset
val success = newGameScreen.tryUpdateRuleset(updateUI = true)
mapNations = if (success)
selection.mapPreview.getDeclaredNations()
if (success) {
mapNations = selection.mapPreview.getDeclaredNations()
.mapNotNull { newGameScreen.ruleset.nations[it] }
.filter { it.isMajorCiv }
.toList()
else emptyList()
mapHumanPick = selection.mapPreview.getNationsForHumanPlayer()
.filter { newGameScreen.ruleset.nations[it]?.isMajorCiv == true }
.toList().randomOrNull()
} else {
mapNations = emptyList()
mapHumanPick = null
}
if (mapNations.isEmpty()) {
useNationsButtonCell.setActor(null)
@ -272,8 +296,10 @@ class MapFileSelectTable(
// ReplayMap still paints outside its bounds - so we subtract padding and a little extra
val size = (columnWidth - 40f).coerceAtMost(500f)
val miniMap = LoadMapPreview(map, size, size)
val description = map.description
if (!isActive) return@run
Concurrency.runOnGLThread {
showDescription(description)
showMinimap(miniMap)
}
} catch (_: Throwable) {}
@ -299,6 +325,13 @@ class MapFileSelectTable(
miniMapWrapper.addAction(Actions.fadeIn(0.2f))
}
private fun showDescription(text: String) {
descriptionLabel.setText(text)
descriptionLabelCell
.height(if (text.isEmpty()) 0f else descriptionLabel.prefHeight)
.padTop(if (text.isEmpty()) 0f else 10f)
}
private fun hideMiniMap() {
if (miniMapWrapper.actor !is LoadMapPreview) return
miniMapWrapper.clearActions()
@ -321,13 +354,12 @@ class MapFileSelectTable(
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 }
compareBy<Pair<String, String>>{ it.first != mapHumanPick }
.thenBy(collator) { it.second }
).map { Player(it.first, if (it.first == pickForHuman) PlayerType.Human else PlayerType.AI) }
).map { Player(it.first, if (it.first == mapHumanPick) PlayerType.Human else PlayerType.AI) }
.toCollection(players)
newGameScreen.playerPickerTable.update()
}