Show a preview of custom maps on new game screen (#9234)

* Show a preview of custom maps on new game screen

* Show a preview of custom maps on new game screen - step 2

* Show a preview of custom maps on new game screen V2
This commit is contained in:
SomeTroglodyte 2023-05-04 08:31:43 +02:00 committed by GitHub
parent 43b044740c
commit b0876935f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 302 additions and 124 deletions

View File

@ -0,0 +1,175 @@
package com.unciv.ui.screens.newgamescreen
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.actions.Actions
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.badlogic.gdx.utils.Array as GdxArray
import com.unciv.UncivGame
import com.unciv.logic.UncivShowableException
import com.unciv.logic.files.MapSaver
import com.unciv.logic.map.MapParameters
import com.unciv.models.ruleset.RulesetCache
import com.unciv.ui.components.extensions.onChange
import com.unciv.ui.components.extensions.pad
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.popups.Popup
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.victoryscreen.LoadMapPreview
import com.unciv.utils.concurrency.Concurrency
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
class MapFileSelectTable(
private val newGameScreen: NewGameScreen,
private val mapParameters: MapParameters
) : Table() {
private val mapFileSelectBox = SelectBox<FileHandleWrapper>(BaseScreen.skin)
private val miniMapWrapper = Container<Group?>()
private var mapPreviewJob: Job? = null
private val mapFilesSequence = sequence<FileHandle> {
yieldAll(MapSaver.getMaps().asSequence())
for (modFolder in RulesetCache.values.mapNotNull { it.folderLocation }) {
val mapsFolder = modFolder.child(MapSaver.mapsFolder)
if (mapsFolder.exists())
yieldAll(mapsFolder.list().asSequence())
}
}.map { FileHandleWrapper(it) }
private val columnWidth = newGameScreen.getColumnWidth()
init {
defaults().pad(5f, 10f) // Must stay same as in MapParametersTable
val mapFileLabel = "{Map file}:".toLabel()
add(mapFileLabel).left()
add(mapFileSelectBox)
// because SOME people gotta give the hugest names to their maps
.maxWidth((columnWidth - mapFileLabel.prefWidth).coerceAtLeast(120f))
.right().row()
add(miniMapWrapper)
.pad(15f)
.colspan(2).center().row()
mapFileSelectBox.onChange { onSelectBoxChange() }
}
// 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 FileHandleWrapper(val fileHandle: FileHandle) {
override fun toString(): String = fileHandle.name()
}
fun isNotEmpty() = mapFilesSequence.any()
fun recentlySavedMapExists() = mapFilesSequence.any {
it.fileHandle.lastModified() > System.currentTimeMillis() - 900000
}
fun fillMapFileSelectBox() {
if (!mapFileSelectBox.items.isEmpty) return
val mapFiles = GdxArray<FileHandleWrapper>()
mapFilesSequence
.sortedWith(compareBy(UncivGame.Current.settings.getCollatorFromLocale()) { it.toString() })
.forEach { mapFiles.add(it) }
mapFileSelectBox.items = mapFiles
// 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
mapFileSelectBox.selected = selectedItem
mapParameters.name = selectedItem.toString()
newGameScreen.gameSetupInfo.mapFile = selectedItem.fileHandle
}
private fun onSelectBoxChange() {
cancelBackgroundJobs()
val mapFile = mapFileSelectBox.selected.fileHandle
val mapParams = try {
MapSaver.loadMapParameters(mapFile)
} catch (ex:Exception){
ex.printStackTrace()
Popup(newGameScreen).apply {
addGoodSizedLabel("Could not load map!").row()
if (ex is UncivShowableException)
addGoodSizedLabel(ex.message).row()
addCloseButton()
open()
}
return
}
mapParameters.name = mapFile.name()
newGameScreen.gameSetupInfo.mapFile = mapFile
val mapMods = mapParams.mods.partition { RulesetCache[it]?.modOptions?.isBaseRuleset == true }
newGameScreen.gameSetupInfo.gameParameters.mods = LinkedHashSet(mapMods.second)
newGameScreen.gameSetupInfo.gameParameters.baseRuleset = mapMods.first.firstOrNull() ?: mapParams.baseRuleset
newGameScreen.updateRuleset()
newGameScreen.updateTables()
hideMiniMap()
startMapPreview(mapFile)
}
private fun startMapPreview(mapFile: FileHandle) {
mapPreviewJob = Concurrency.run {
try {
val map = MapSaver.loadMap(mapFile)
if (!isActive) return@run
map.setTransients(newGameScreen.ruleset, false)
if (!isActive) return@run
// ReplyMap 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)
if (!isActive) return@run
Concurrency.runOnGLThread {
showMinimap(miniMap)
}
} catch (_: Throwable) {}
}.apply {
invokeOnCompletion {
mapPreviewJob = null
}
}
}
internal fun cancelBackgroundJobs() {
mapPreviewJob?.cancel()
mapPreviewJob = null
miniMapWrapper.clearActions()
}
private fun showMinimap(miniMap: LoadMapPreview) {
if (miniMapWrapper.actor == miniMap) return
miniMapWrapper.clearActions()
miniMapWrapper.color.a = 0f
miniMapWrapper.actor = miniMap
miniMapWrapper.invalidateHierarchy()
miniMapWrapper.addAction(Actions.fadeIn(0.2f))
}
private fun hideMiniMap() {
if (miniMapWrapper.actor !is LoadMapPreview) return
miniMapWrapper.clearActions()
miniMapWrapper.addAction(
Actions.sequence(
Actions.fadeOut(0.4f),
Actions.run {
// in portrait, simply removing the map preview will cause the layout to "jump".
// with a dummy holding the empty space, it jumps later and not as far.
val dummy = Group().apply {
setSize(miniMapWrapper.actor!!.width, miniMapWrapper.actor!!.height)
}
miniMapWrapper.actor = dummy
}
)
)
}
}

View File

@ -1,66 +1,35 @@
package com.unciv.ui.screens.newgamescreen
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.scenes.scene2d.ui.SelectBox
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Array
import com.unciv.UncivGame
import com.unciv.logic.files.MapSaver
import com.unciv.logic.UncivShowableException
import com.unciv.logic.map.MapGeneratedMainType
import com.unciv.models.ruleset.RulesetCache
import com.unciv.ui.popups.Popup
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.components.extensions.onChange
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.screens.basescreen.BaseScreen
class MapOptionsTable(private val newGameScreen: NewGameScreen): Table() {
private val mapParameters = newGameScreen.gameSetupInfo.mapParameters
private var mapTypeSpecificTable = Table()
val generatedMapOptionsTable = MapParametersTable(newGameScreen, mapParameters, MapGeneratedMainType.generated)
internal val generatedMapOptionsTable = MapParametersTable(newGameScreen, mapParameters, MapGeneratedMainType.generated)
private val randomMapOptionsTable = MapParametersTable(newGameScreen, mapParameters, MapGeneratedMainType.randomGenerated)
private val savedMapOptionsTable = Table()
lateinit var mapTypeSelectBox: TranslatedSelectBox
private val mapFileSelectBox = createMapFileSelectBox()
private val mapFilesSequence = sequence<FileHandle> {
yieldAll(MapSaver.getMaps().asSequence())
for (modFolder in RulesetCache.values.mapNotNull { it.folderLocation }) {
val mapsFolder = modFolder.child(MapSaver.mapsFolder)
if (mapsFolder.exists())
yieldAll(mapsFolder.list().asSequence())
}
}.map { FileHandleWrapper(it) }
private val savedMapOptionsTable = MapFileSelectTable(newGameScreen, mapParameters)
internal val mapTypeSelectBox: TranslatedSelectBox
init {
//defaults().pad(5f) - each nested table having the same can give 'stairs' effects,
// better control directly. Besides, the first Labels/Buttons should have 10f to look nice
addMapTypeSelection()
background = BaseScreen.skinStrings.getUiBackground("NewGameScreen/MapOptionsTable", tintColor = BaseScreen.skinStrings.skinConfig.clearColor)
}
private fun addMapTypeSelection() {
val mapTypes = arrayListOf(MapGeneratedMainType.generated, MapGeneratedMainType.randomGenerated)
if (mapFilesSequence.any()) mapTypes.add(MapGeneratedMainType.custom)
if (savedMapOptionsTable.isNotEmpty()) mapTypes.add(MapGeneratedMainType.custom)
mapTypeSelectBox = TranslatedSelectBox(mapTypes, "Generated", BaseScreen.skin)
savedMapOptionsTable.defaults().pad(5f)
savedMapOptionsTable.add("{Map file}:".toLabel()).left()
// because SOME people gotta give the hugest names to their maps
val columnWidth = newGameScreen.stage.width / (if (newGameScreen.isNarrowerThan4to3()) 1 else 3)
savedMapOptionsTable.add(mapFileSelectBox)
.maxWidth((columnWidth - 120f).coerceAtLeast(120f))
.right().row()
fun updateOnMapTypeChange() {
mapTypeSpecificTable.clear()
when (mapTypeSelectBox.selected.value) {
MapGeneratedMainType.custom -> {
fillMapFileSelectBox()
savedMapOptionsTable.fillMapFileSelectBox()
mapParameters.type = MapGeneratedMainType.custom
mapParameters.name = mapFileSelectBox.selected.toString()
mapTypeSpecificTable.add(savedMapOptionsTable)
newGameScreen.unlockTables()
}
@ -82,7 +51,7 @@ class MapOptionsTable(private val newGameScreen: NewGameScreen): Table() {
}
// Pre-select custom if any map saved within last 15 minutes
if (mapFilesSequence.any { it.fileHandle.lastModified() > System.currentTimeMillis() - 900000 })
if (savedMapOptionsTable.recentlySavedMapExists())
mapTypeSelectBox.selected =
TranslatedSelectBox.TranslatedString(MapGeneratedMainType.custom)
@ -98,59 +67,5 @@ class MapOptionsTable(private val newGameScreen: NewGameScreen): Table() {
add(mapTypeSpecificTable).row()
}
private fun createMapFileSelectBox(): SelectBox<FileHandleWrapper> {
val mapFileSelectBox = SelectBox<FileHandleWrapper>(BaseScreen.skin)
mapFileSelectBox.onChange {
val mapFile = mapFileSelectBox.selected.fileHandle
val mapParams = try {
MapSaver.loadMapParameters(mapFile)
} catch (ex:Exception){
ex.printStackTrace()
Popup(newGameScreen).apply {
addGoodSizedLabel("Could not load map!").row()
if (ex is UncivShowableException)
addGoodSizedLabel(ex.message).row()
addCloseButton()
open()
}
return@onChange
}
mapParameters.name = mapFile.name()
newGameScreen.gameSetupInfo.mapFile = mapFile
val mapMods = mapParams.mods.partition { RulesetCache[it]?.modOptions?.isBaseRuleset == true }
newGameScreen.gameSetupInfo.gameParameters.mods = LinkedHashSet(mapMods.second)
newGameScreen.gameSetupInfo.gameParameters.baseRuleset = mapMods.first.firstOrNull() ?: mapParams.baseRuleset
newGameScreen.updateRuleset()
newGameScreen.updateTables()
}
return mapFileSelectBox
}
private fun fillMapFileSelectBox() {
if (!mapFileSelectBox.items.isEmpty) return
val mapFiles = Array<FileHandleWrapper>()
mapFilesSequence
.sortedWith(compareBy(UncivGame.Current.settings.getCollatorFromLocale()) { it.toString() })
.forEach { mapFiles.add(it) }
mapFileSelectBox.items = mapFiles
// 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
mapFileSelectBox.selected = selectedItem
newGameScreen.gameSetupInfo.mapFile = selectedItem.fileHandle
}
// 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()
class FileHandleWrapper(val fileHandle: FileHandle) {
override fun toString(): String = fileHandle.name()
}
internal fun cancelBackgroundJobs() = savedMapOptionsTable.cancelBackgroundJobs()
}

View File

@ -80,7 +80,12 @@ class MapParametersTable(
skin = BaseScreen.skin
defaults().pad(5f, 10f)
if (mapGeneratedMainType == MapGeneratedMainType.randomGenerated) {
add("{Which options should be available to the random selection?}".toLabel()).colspan(2).grow().row()
val prompt = "Which options should be available to the random selection?"
val width = (previousScreen as? NewGameScreen)?.getColumnWidth() ?: 200f
val label = WrappableLabel(prompt, width - 20f) // 20 is the defaults() padding
label.setAlignment(Align.center)
label.wrap = true
add(label).colspan(2).grow().row()
}
addMapShapeSelectBox()
addMapTypeSelectBox()

View File

@ -20,10 +20,13 @@ import com.unciv.models.metadata.GameSetupInfo
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.translations.tr
import com.unciv.ui.components.ExpanderTab
import com.unciv.ui.components.KeyCharAndCode
import com.unciv.ui.components.extensions.addSeparator
import com.unciv.ui.components.extensions.addSeparatorVertical
import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.enable
import com.unciv.ui.components.extensions.keyShortcuts
import com.unciv.ui.components.extensions.onActivation
import com.unciv.ui.components.extensions.onClick
import com.unciv.ui.components.extensions.pad
import com.unciv.ui.components.extensions.toLabel
@ -76,7 +79,11 @@ class NewGameScreen(
updatePlayerPickerRandomLabel = { playerPickerTable.updateRandomNumberLabel() }
)
mapOptionsTable = MapOptionsTable(this)
setDefaultCloseAction()
pickerPane.closeButton.onActivation {
mapOptionsTable.cancelBackgroundJobs()
game.popScreen()
}
pickerPane.closeButton.keyShortcuts.add(KeyCharAndCode.BACK)
if (isPortrait) initPortrait()
else initLandscape()
@ -104,6 +111,7 @@ class NewGameScreen(
}
private fun onStartGameClicked() {
mapOptionsTable.cancelBackgroundJobs()
if (gameSetupInfo.gameParameters.isOnlineMultiplayer) {
if (!checkConnectionToMultiplayerServer()) {
val noInternetConnectionPopup = Popup(this)
@ -206,6 +214,10 @@ class NewGameScreen(
}
}
/** Subtables may need an upper limit to their width - they can ask this function. */
// In sync with isPortrait in init, here so UI details need not know about 3-column vs 1-column layout
internal fun getColumnWidth() = stage.width / (if (isNarrowerThan4to3()) 1 else 3)
private fun initLandscape() {
scrollPane.setScrollingDisabled(true,true)

View File

@ -2,55 +2,126 @@ package com.unciv.ui.screens.victoryscreen
import com.badlogic.gdx.scenes.scene2d.Group
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.map.MapShape
import com.unciv.logic.map.TileMap
import com.unciv.logic.map.tile.Tile
import com.unciv.ui.screens.worldscreen.minimap.MinimapTile
import com.unciv.ui.screens.worldscreen.minimap.MinimapTileUtil
import kotlin.math.min
import kotlin.math.sqrt
// Mostly copied from MiniMap
class ReplayMap(
val tileMap: TileMap,
val viewingCiv: Civilization,
private val replayMapWidth: Float,
private val replayMapHeight: Float
@Suppress("LeakingThis")
/**
* Base for a MiniMap not intertwined with a WorldScreen.
* For a _minimal_ implementation see [LoadMapPreview]
*
* TODO: Analyze why MiniMap needs the tight WorldScreen integration and clean up / merge
*/
abstract class IndependentMiniMap(
val tileMap: TileMap
) : Group() {
private val tileLayer = Group()
private val minimapTiles: List<MinimapTile>
protected lateinit var minimapTiles: List<MinimapTile>
init {
val tileSize = calcTileSize()
/** Call this in the init of derived classes.
*
* Needs to be deferred only to allow [calcTileSize] or [includeTileFilter] to use class parameters added in the derived class. */
protected open fun deferredInit(maxWidth: Float, maxHeight: Float) {
val tileSize = calcTileSize(maxWidth, maxHeight)
minimapTiles = createReplayMap(tileSize)
val tileExtension = MinimapTileUtil.spreadOutMinimapTiles(tileLayer, minimapTiles, tileSize)
val tileExtension = MinimapTileUtil.spreadOutMinimapTiles(this, minimapTiles, tileSize)
for (group in tileLayer.children) {
for (group in children) {
group.moveBy(-tileExtension.x, -tileExtension.y)
}
// there are tiles "below the zero",
// so we zero out the starting position of the whole board so they will be displayed as well
tileLayer.setSize(tileExtension.width, tileExtension.height)
setSize(tileLayer.width, tileLayer.height)
addActor(tileLayer)
setSize(tileExtension.width, tileExtension.height)
}
private fun calcTileSize(): Float {
/** Calculate a tile radius in screen coordinates so that the resulting map, after distributimg
* the tiles using spreadOutMinimapTiles, will not exceed the bounds ([maxWidth],[maxHeight]) */
protected abstract fun calcTileSize(maxWidth: Float, maxHeight: Float): Float
/** Controls which tiles are included */
protected open fun includeTileFilter(tile: Tile): Boolean = true
private fun createReplayMap(tileSize: Float): List<MinimapTile> {
val doNothing = fun(){}
val tiles = ArrayList<MinimapTile>(tileMap.values.size)
for (tile in tileMap.values.filter(::includeTileFilter) ) {
val minimapTile = MinimapTile(tile, tileSize, doNothing)
minimapTile.updateColor(false, null)
tiles.add(minimapTile)
}
tiles.trimToSize()
return tiles
}
}
/**
* A minimap with no WorldScreen dependencies, always shows the entire map.
*
* @param tileMap Map to display minimap-style
* @param maxWidth Resulting Group will not exceed this width
* @param maxHeight Resulting Group will not exceed this height
*/
class LoadMapPreview(
tileMap: TileMap,
maxWidth: Float,
maxHeight: Float
) : IndependentMiniMap(tileMap) {
init {
deferredInit(maxWidth, maxHeight)
}
override fun calcTileSize(maxWidth: Float, maxHeight: Float): Float {
val height: Float
val width: Float
val mapSize = tileMap.mapParameters.mapSize
if (tileMap.mapParameters.shape != MapShape.rectangular) {
height = mapSize.radius * 2 + 1f
width = height
} else {
height = mapSize.height.toFloat()
width = mapSize.width.toFloat()
}
// See HexMath.worldFromLatLong, the 0.6 is empiric to avoid rounding to cause the map to spill over
return min(
maxWidth / (width + 0.6f) / 1.5f * 2f,
maxHeight / (height + 0.6f) / sqrt(3f) * 2f,
)
}
}
/**
* A minimap with no WorldScreen dependencies, with the ability to show historical states.
*
* @param tileMap Map to display minimap-style
* @param viewingCiv used to determine tile visibility and explored area
* @param maxWidth Resulting Group should not exceed this width
* @param maxHeight Resulting Group should not exceed this height
*/
class ReplayMap(
tileMap: TileMap,
val viewingCiv: Civilization,
maxWidth: Float,
maxHeight: Float
) : IndependentMiniMap(tileMap) {
init {
deferredInit(maxWidth, maxHeight)
}
override fun calcTileSize(maxWidth: Float, maxHeight: Float): Float {
val height = viewingCiv.exploredRegion.getHeight().toFloat()
val width = viewingCiv.exploredRegion.getWidth().toFloat()
return min(
replayMapHeight / (height + 1.5f) / sqrt(3f) * 4f, // 1.5 - padding, hex height = sqrt(3) / 2 * d / 2 -> d = height / sqrt(3) * 2 * 2
replayMapWidth / (width + 0.5f) / 0.75f // 0.5 - padding, hex width = 0.75 * d -> d = width / 0.75
return min (
maxHeight / (height + 1.5f) / sqrt(3f) * 4f, // 1.5 - padding, hex height = sqrt(3) / 2 * d / 2 -> d = height / sqrt(3) * 2 * 2
maxWidth / (width + 0.5f) / 0.75f // 0.5 - padding, hex width = 0.75 * d -> d = width / 0.75
)
}
private fun createReplayMap(tileSize: Float): List<MinimapTile> {
val tiles = ArrayList<MinimapTile>()
for (tile in tileMap.values.filter { it.isExplored(viewingCiv) }) {
val minimapTile = MinimapTile(tile, tileSize) {}
tiles.add(minimapTile)
}
return tiles
}
override fun includeTileFilter(tile: Tile) = tile.isExplored(viewingCiv)
fun update(turn: Int) {
val viewingCivIsDefeated = viewingCiv.gameInfo.victoryData != null || !viewingCiv.isAlive()

View File

@ -51,7 +51,7 @@ class VictoryScreenReplay(
gameInfo.tileMap,
worldScreen.viewingCiv,
worldScreen.stage.width - 50,
worldScreen.stage.height - 250
worldScreen.stage.height - 250 // Empiric: `stage.height - pager.contentScroll_field.height` after init is 244.
)
playImage.setSize(24f)