diff --git a/core/src/com/unciv/logic/city/CityConstructions.kt b/core/src/com/unciv/logic/city/CityConstructions.kt index fbfc3ab376..0915863bf0 100644 --- a/core/src/com/unciv/logic/city/CityConstructions.kt +++ b/core/src/com/unciv/logic/city/CityConstructions.kt @@ -5,10 +5,13 @@ import com.unciv.logic.civilization.AlertType import com.unciv.logic.civilization.NotificationIcon import com.unciv.logic.civilization.PopupAlert import com.unciv.models.ruleset.Building +import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.UniqueMap import com.unciv.models.ruleset.unit.BaseUnit import com.unciv.models.stats.Stats import com.unciv.models.translations.tr +import com.unciv.ui.civilopedia.CivilopediaCategories +import com.unciv.ui.civilopedia.FormattedLine import com.unciv.ui.utils.Fonts import com.unciv.ui.utils.withItem import com.unciv.ui.utils.withoutItem @@ -141,6 +144,28 @@ class CityConstructions { return result } + fun getProductionMarkup(ruleset: Ruleset): FormattedLine { + val currentConstructionSnapshot = currentConstructionFromQueue + if (currentConstructionSnapshot.isEmpty()) return FormattedLine() + val category = when { + ruleset.buildings[currentConstructionSnapshot] + ?.let{ it.isWonder || it.isNationalWonder } == true -> + CivilopediaCategories.Wonder.name + currentConstructionSnapshot in ruleset.buildings -> + CivilopediaCategories.Building.name + currentConstructionSnapshot in ruleset.units -> + CivilopediaCategories.Unit.name + else -> "" + } + var label = currentConstructionSnapshot + if (!PerpetualConstruction.perpetualConstructionsMap.containsKey(currentConstructionSnapshot)) { + val turnsLeft = turnsToConstruction(currentConstructionSnapshot) + label += " - $turnsLeft${Fonts.turn}" + } + return if (category.isEmpty()) FormattedLine(label) + else FormattedLine(label, link="$category/$currentConstructionSnapshot") + } + fun getCurrentConstruction(): IConstruction = getConstruction(currentConstructionFromQueue) fun isBuilt(buildingName: String): Boolean = builtBuildings.contains(buildingName) diff --git a/core/src/com/unciv/logic/map/TileInfo.kt b/core/src/com/unciv/logic/map/TileInfo.kt index 24482d40b3..c594a28699 100644 --- a/core/src/com/unciv/logic/map/TileInfo.kt +++ b/core/src/com/unciv/logic/map/TileInfo.kt @@ -10,6 +10,7 @@ import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.tile.* import com.unciv.models.stats.Stats import com.unciv.models.translations.tr +import com.unciv.ui.civilopedia.FormattedLine import com.unciv.ui.utils.Fonts import kotlin.math.abs import kotlin.math.min @@ -144,7 +145,7 @@ open class TileInfo { else if (!ruleset.tileResources.containsKey(resource!!)) throw Exception("Resource $resource does not exist in this ruleset!") else ruleset.tileResources[resource!!]!! - fun getNaturalWonder(): Terrain = + private fun getNaturalWonder(): Terrain = if (naturalWonder == null) throw Exception("No natural wonder exists for this tile!") else ruleset.terrains[naturalWonder!!]!! @@ -179,8 +180,7 @@ open class TileInfo { fun getBaseTerrain(): Terrain = baseTerrainObject fun getOwner(): CivilizationInfo? { - val containingCity = getCity() - if (containingCity == null) return null + val containingCity = getCity() ?: return null return containingCity.civInfo } @@ -204,8 +204,7 @@ open class TileInfo { fun hasUnique(unique: String) = getAllTerrains().any { it.uniques.contains(unique) } fun getWorkingCity(): CityInfo? { - val civInfo = getOwner() - if (civInfo == null) return null + val civInfo = getOwner() ?: return null return civInfo.cities.firstOrNull { it.isWorked(this) } } @@ -260,7 +259,7 @@ open class TileInfo { stats.add(getTileResource()) // resource base if (resource.building != null && city != null && city.cityConstructions.isBuilt(resource.building!!)) { val resourceBuilding = tileMap.gameInfo.ruleSet.buildings[resource.building!!] - if (resourceBuilding != null && resourceBuilding.resourceBonusStats != null) + if (resourceBuilding?.resourceBonusStats != null) stats.add(resourceBuilding.resourceBonusStats!!) // resource-specific building (eg forge, stable) bonus } } @@ -337,7 +336,7 @@ open class TileInfo { improvement.hasUnique("Can be built outside your borders") // citadel can be built only next to or within own borders || improvement.hasUnique("Can be built just outside your borders") - && neighbors.any { it.getOwner() == civInfo } && !civInfo.cities.isEmpty() + && neighbors.any { it.getOwner() == civInfo } && civInfo.cities.isNotEmpty() ) -> false improvement.uniqueObjects.any { it.placeholderText == "Obsolete with []" && civInfo.tech.isResearched(it.params[0]) @@ -346,10 +345,10 @@ open class TileInfo { } } - /** Without regards to what civinfo it is, a lot of the checks are just for the improvement on the tile. + /** Without regards to what CivInfo it is, a lot of the checks are just for the improvement on the tile. * Doubles as a check for the map editor. */ - fun canImprovementBeBuiltHere(improvement: TileImprovement, resourceIsVisible: Boolean = resource != null): Boolean { + private fun canImprovementBeBuiltHere(improvement: TileImprovement, resourceIsVisible: Boolean = resource != null): Boolean { val topTerrain = getLastTerrain() return when { @@ -358,7 +357,7 @@ open class TileInfo { "Cannot be built on bonus resource" in improvement.uniques && resource != null && getTileResource().resourceType == ResourceType.Bonus -> false - // Road improvements can change on tiles withh irremovable improvements - nothing else can, though. + // Road improvements can change on tiles with irremovable improvements - nothing else can, though. improvement.name != RoadStatus.Railroad.name && improvement.name != RoadStatus.Railroad.name && improvement.name != "Remove Road" && improvement.name != "Remove Railroad" && getTileImprovement().let { it != null && it.hasUnique("Irremovable") } -> false @@ -371,7 +370,7 @@ open class TileInfo { improvement.uniqueObjects.filter { it.placeholderText == "Must be next to []" }.any { val filter = it.params[0] if (filter == "River") return@any !isAdjacentToRiver() - else return@any !neighbors.any { it.matchesUniqueFilter(filter) } + else return@any !neighbors.any { neighbor -> neighbor.matchesUniqueFilter(filter) } } -> false improvement.name == "Road" && roadStatus == RoadStatus.None && !isWater -> true improvement.name == "Railroad" && this.roadStatus != RoadStatus.Railroad && !isWater -> true @@ -448,8 +447,20 @@ open class TileInfo { return min(distance, wrappedDistance).toInt() } - override fun toString(): String { // for debugging, it helps to see what you're doing - return toString(null) + /** Shows important properties of this tile for debugging _only_, it helps to see what you're doing */ + override fun toString(): String { + val lineList = arrayListOf("TileInfo @($position)") + if (isCityCenter()) lineList += getCity()!!.name + lineList += baseTerrain + for (terrainFeature in terrainFeatures) lineList += terrainFeature + if (resource != null ) lineList += resource!! + if (naturalWonder != null) lineList += naturalWonder!! + if (roadStatus !== RoadStatus.None && !isCityCenter()) lineList += roadStatus.name + if (improvement != null) lineList += improvement!! + if (civilianUnit != null) lineList += civilianUnit!!.name + " - " + civilianUnit!!.civInfo.civName + if (militaryUnit != null) lineList += militaryUnit!!.name + " - " + militaryUnit!!.civInfo.civName + if (isImpassible()) lineList += Constants.impassable + return lineList.joinToString() } /** The two tiles have a river between them */ @@ -481,8 +492,8 @@ open class TileInfo { return true } - fun toString(viewingCiv: CivilizationInfo?): String { - val lineList = ArrayList() // more readable than StringBuilder, with same performance for our use-case + fun toMarkup(viewingCiv: CivilizationInfo?): ArrayList { + val lineList = ArrayList() // more readable than StringBuilder, with same performance for our use-case val isViewableToPlayer = viewingCiv == null || UncivGame.Current.viewEntireMapForDebug || viewingCiv.viewableTiles.contains(this) @@ -490,42 +501,46 @@ open class TileInfo { val city = getCity()!! var cityString = city.name.tr() if (isViewableToPlayer) cityString += " (" + city.health + ")" - lineList += cityString + lineList += FormattedLine(cityString) if (UncivGame.Current.viewEntireMapForDebug || city.civInfo == viewingCiv) - lineList += city.cityConstructions.getProductionForTileInfo() + lineList += city.cityConstructions.getProductionMarkup(ruleset) } - lineList += baseTerrain.tr() - for (terrainFeature in terrainFeatures) lineList += terrainFeature.tr() - if (resource != null && (viewingCiv == null || hasViewableResource(viewingCiv))) lineList += resource!!.tr() - if (naturalWonder != null) lineList += naturalWonder!!.tr() - if (roadStatus !== RoadStatus.None && !isCityCenter()) lineList += roadStatus.name.tr() - if (improvement != null) lineList += improvement!!.tr() + lineList += FormattedLine(baseTerrain, link="Terrain/$baseTerrain") + for (terrainFeature in terrainFeatures) + lineList += FormattedLine(terrainFeature, link="Terrain/$terrainFeature") + if (resource != null && (viewingCiv == null || hasViewableResource(viewingCiv))) + lineList += FormattedLine(resource!!, link="Resource/$resource") + if (naturalWonder != null) + lineList += FormattedLine(naturalWonder!!, link="Terrain/$naturalWonder") + if (roadStatus !== RoadStatus.None && !isCityCenter()) + lineList += FormattedLine(roadStatus.name, link="Improvement/${roadStatus.name}") + if (improvement != null) + lineList += FormattedLine(improvement!!, link="Improvement/$improvement") if (improvementInProgress != null && isViewableToPlayer) { - var line = "{$improvementInProgress}" - if (turnsToImprovement > 0) line += " - $turnsToImprovement${Fonts.turn}" - else line += " ({Under construction})" - lineList += line.tr() + val line = "{$improvementInProgress}" + + if (turnsToImprovement > 0) " - $turnsToImprovement${Fonts.turn}" else " ({Under construction})" + lineList += FormattedLine(line, link="Improvement/$improvementInProgress") } if (civilianUnit != null && isViewableToPlayer) - lineList += civilianUnit!!.name.tr() + " - " + civilianUnit!!.civInfo.civName.tr() + lineList += FormattedLine(civilianUnit!!.name.tr() + " - " + civilianUnit!!.civInfo.civName.tr(), + link="Unit/${civilianUnit!!.name}") if (militaryUnit != null && isViewableToPlayer) { - var milUnitString = militaryUnit!!.name.tr() - if (militaryUnit!!.health < 100) milUnitString += "(" + militaryUnit!!.health + ")" - milUnitString += " - " + militaryUnit!!.civInfo.civName.tr() - lineList += milUnitString + val milUnitString = militaryUnit!!.name.tr() + + (if (militaryUnit!!.health < 100) "(" + militaryUnit!!.health + ")" else "") + + " - " + militaryUnit!!.civInfo.civName.tr() + lineList += FormattedLine(milUnitString, link="Unit/${militaryUnit!!.name}") } val defenceBonus = getDefensiveBonus() if (defenceBonus != 0f) { var defencePercentString = (defenceBonus * 100).toInt().toString() + "%" if (!defencePercentString.startsWith("-")) defencePercentString = "+$defencePercentString" - lineList += "[$defencePercentString] to unit defence".tr() + lineList += FormattedLine("[$defencePercentString] to unit defence") } - if (isImpassible()) lineList += Constants.impassable.tr() + if (isImpassible()) lineList += FormattedLine(Constants.impassable) - return lineList.joinToString("\n") + return lineList } - fun hasEnemyInvisibleUnit(viewingCiv: CivilizationInfo): Boolean { val unitsInTile = getUnits() if (unitsInTile.none()) return false @@ -649,7 +664,7 @@ open class TileInfo { private fun normalizeTileImprovement(ruleset: Ruleset) { - if (improvement!!.startsWith("StartingLocation") == true) { + if (improvement!!.startsWith("StartingLocation")) { if (!isLand || getLastTerrain().impassable) improvement = null return } diff --git a/core/src/com/unciv/ui/cityscreen/CityScreenTileTable.kt b/core/src/com/unciv/ui/cityscreen/CityScreenTileTable.kt index 31911355b1..78905781d3 100644 --- a/core/src/com/unciv/ui/cityscreen/CityScreenTileTable.kt +++ b/core/src/com/unciv/ui/cityscreen/CityScreenTileTable.kt @@ -7,6 +7,8 @@ import com.unciv.logic.map.TileInfo import com.unciv.models.UncivSound import com.unciv.models.stats.Stats import com.unciv.models.translations.tr +import com.unciv.ui.civilopedia.CivilopediaScreen +import com.unciv.ui.civilopedia.MarkupRenderer import com.unciv.ui.utils.* import kotlin.math.roundToInt @@ -32,7 +34,10 @@ class CityScreenTileTable(private val cityScreen: CityScreen): Table() { val stats = selectedTile.getTileStats(city, city.civInfo) innerTable.pad(5f) - innerTable.add(selectedTile.toString(city.civInfo).toLabel()).colspan(2) + innerTable.add( MarkupRenderer.render(selectedTile.toMarkup(city.civInfo)) { + // Sorry, this will leave the city screen + UncivGame.Current.setScreen(CivilopediaScreen(city.civInfo.gameInfo.ruleSet, link = it)) + } ).colspan(2) innerTable.row() innerTable.add(getTileStatsTable(stats)).row() diff --git a/core/src/com/unciv/ui/civilopedia/CivilopediaCategories.kt b/core/src/com/unciv/ui/civilopedia/CivilopediaCategories.kt index b6ba28b3f4..9babae818a 100644 --- a/core/src/com/unciv/ui/civilopedia/CivilopediaCategories.kt +++ b/core/src/com/unciv/ui/civilopedia/CivilopediaCategories.kt @@ -28,7 +28,11 @@ object CivilopediaImageGetters { } TerrainType.TerrainFeature -> { tileInfo.terrainFeatures.add(terrain.name) - tileInfo.baseTerrain = terrain.occursOn.lastOrNull() ?: Constants.grassland + tileInfo.baseTerrain = + if (terrain.occursOn.isEmpty() || terrain.occursOn.contains(Constants.grassland)) + Constants.grassland + else + terrain.occursOn.lastOrNull()!! } else -> tileInfo.baseTerrain = terrain.name diff --git a/core/src/com/unciv/ui/civilopedia/CivilopediaText.kt b/core/src/com/unciv/ui/civilopedia/CivilopediaText.kt new file mode 100644 index 0000000000..ca879bcb1d --- /dev/null +++ b/core/src/com/unciv/ui/civilopedia/CivilopediaText.kt @@ -0,0 +1,112 @@ +package com.unciv.ui.civilopedia + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.utils.Align +import com.unciv.ui.utils.* + + +/** Represents a text line with optional linking capability. + * Special cases: + * - Automatic external links (no [text] but [link] begins with a URL protocol) + * + * @param text Text to display. + * @param link Create link: Line gets a 'Link' icon and is linked to either + * an Unciv object (format `category/entryname`) or an external URL. + */ +class FormattedLine ( + val text: String = "", + val link: String = "", +) { + // Note: This gets directly deserialized by Json - please keep all attributes meant to be read + // from json in the primary constructor parameters above. Everything else should be a fun(), + // have no backing field, be `by lazy` or use @Transient, Thank you. + + /** Link types that can be used for [FormattedLine.link] */ + enum class LinkType { + None, + /** Link points to a Civilopedia entry in the form `category/item` **/ + Internal, + /** Link opens as URL in external App - begins with `https://`, `http://` or `mailto:` **/ + External + } + + /** The type of the [link]'s destination */ + val linkType: LinkType by lazy { + when { + link.hasProtocol() -> LinkType.External + link.isNotEmpty() -> LinkType.Internal + else -> LinkType.None + } + } + + private val textToDisplay: String by lazy { + if (text.isEmpty() && linkType == LinkType.External) link else text + } + + /** Returns true if this formatted line will not display anything */ + fun isEmpty(): Boolean = text.isEmpty() && link.isEmpty() + + /** Extension: determines if a [String] looks like a link understood by the OS */ + private fun String.hasProtocol() = startsWith("http://") || startsWith("https://") || startsWith("mailto:") + + /** + * Renders the formatted line as a scene2d [Actor] (currently always a [Table]) + * @param labelWidth Total width to render into, needed to support wrap on Labels. + */ + fun render(labelWidth: Float): Actor { + val table = Table(CameraStageBaseScreen.skin) + if (textToDisplay.isNotEmpty()) { + val label = textToDisplay.toLabel() + label.wrap = labelWidth > 0f + if (labelWidth == 0f) + table.add(label) + else + table.add(label).width(labelWidth) + } + return table + } +} + +/** Makes [renderer][render] available outside [ICivilopediaText] */ +object MarkupRenderer { + private const val emptyLineHeight = 10f + private const val defaultPadding = 2.5f + + /** + * Build a Gdx [Table] showing [formatted][FormattedLine] [content][lines]. + * + * @param lines The formatted content to render. + * @param labelWidth Available width needed for wrapping labels and [centered][FormattedLine.centered] attribute. + * @param linkAction Delegate to call for internal links. Leave null to suppress linking. + */ + fun render( + lines: Collection, + labelWidth: Float = 0f, + linkAction: ((id: String) -> Unit)? = null + ): Table { + val skin = CameraStageBaseScreen.skin + val table = Table(skin).apply { defaults().pad(defaultPadding).align(Align.left) } + for (line in lines) { + if (line.isEmpty()) { + table.add().padTop(emptyLineHeight).row() + continue + } + val actor = line.render(labelWidth) + if (line.linkType == FormattedLine.LinkType.Internal && linkAction != null) + actor.onClick { + linkAction(line.link) + } + else if (line.linkType == FormattedLine.LinkType.External) + actor.onClick { + Gdx.net.openURI(line.link) + } + if (labelWidth == 0f) + table.add(actor).row() + else + table.add(actor).width(labelWidth).row() + } + return table.apply { pack() } + } +} diff --git a/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt b/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt index a17487da48..d5a9367c9b 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt @@ -471,8 +471,15 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap } var blinkAction: Action? = null - fun setCenterPosition(vector: Vector2, immediately: Boolean = false, selectUnit: Boolean = true) { - val tileGroup = allWorldTileGroups.firstOrNull { it.tileInfo.position == vector } ?: return + + /** Scrolls the world map to specified coordinates. + * @param vector Position to center on + * @param immediately Do so without animation + * @param selectUnit Select a unit at the destination + * @return `true` if scroll position was changed, `false` otherwise + */ + fun setCenterPosition(vector: Vector2, immediately: Boolean = false, selectUnit: Boolean = true): Boolean { + val tileGroup = allWorldTileGroups.firstOrNull { it.tileInfo.position == vector } ?: return false selectedTile = tileGroup.tileInfo if (selectUnit) worldScreen.bottomUnitTable.tileSelected(selectedTile!!) @@ -487,6 +494,8 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap // Here it's the same, only the Y axis is inverted - when at 0 we're at the top, not bottom - so we invert it back. val finalScrollY = maxY - (tileGroup.y + tileGroup.width / 2 - height / 2) + if (finalScrollX == originalScrollX && finalScrollY == originalScrollY) return false + if (immediately) { scrollX = finalScrollX scrollY = finalScrollY @@ -513,6 +522,7 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap addAction(blinkAction) // Don't set it on the group because it's an actionlss group worldScreen.shouldUpdate = true + return true } override fun zoom(zoomScale: Float) { diff --git a/core/src/com/unciv/ui/worldscreen/bottombar/TileInfoTable.kt b/core/src/com/unciv/ui/worldscreen/bottombar/TileInfoTable.kt index a49b9b6a2d..1ce5da0843 100644 --- a/core/src/com/unciv/ui/worldscreen/bottombar/TileInfoTable.kt +++ b/core/src/com/unciv/ui/worldscreen/bottombar/TileInfoTable.kt @@ -6,6 +6,8 @@ import com.badlogic.gdx.utils.Align import com.unciv.UncivGame import com.unciv.logic.civilization.CivilizationInfo import com.unciv.logic.map.TileInfo +import com.unciv.ui.civilopedia.CivilopediaScreen +import com.unciv.ui.civilopedia.MarkupRenderer import com.unciv.ui.utils.CameraStageBaseScreen import com.unciv.ui.utils.ImageGetter import com.unciv.ui.utils.toLabel @@ -20,7 +22,9 @@ class TileInfoTable(private val viewingCiv :CivilizationInfo) : Table(CameraStag if (tile != null && (UncivGame.Current.viewEntireMapForDebug || viewingCiv.exploredTiles.contains(tile.position)) ) { add(getStatsTable(tile)) - add(tile.toString(viewingCiv).toLabel()).colspan(2).pad(10f) + add( MarkupRenderer.render(tile.toMarkup(viewingCiv) ) { + UncivGame.Current.setScreen(CivilopediaScreen(viewingCiv.gameInfo.ruleSet, link = it)) + } ).pad(10f) // For debug only! // add(tile.position.toString().toLabel()).colspan(2).pad(10f) } @@ -40,4 +44,4 @@ class TileInfoTable(private val viewingCiv :CivilizationInfo) : Table(CameraStag } return table } -} \ No newline at end of file +} diff --git a/core/src/com/unciv/ui/worldscreen/unit/UnitTable.kt b/core/src/com/unciv/ui/worldscreen/unit/UnitTable.kt index 278bc4f264..04e2af0617 100644 --- a/core/src/com/unciv/ui/worldscreen/unit/UnitTable.kt +++ b/core/src/com/unciv/ui/worldscreen/unit/UnitTable.kt @@ -13,6 +13,8 @@ import com.unciv.logic.city.CityInfo import com.unciv.logic.map.MapUnit import com.unciv.logic.map.TileInfo import com.unciv.models.translations.tr +import com.unciv.ui.civilopedia.CivilopediaCategories +import com.unciv.ui.civilopedia.CivilopediaScreen import com.unciv.ui.pickerscreens.PromotionPickerScreen import com.unciv.ui.utils.* import com.unciv.ui.worldscreen.WorldScreen @@ -78,7 +80,9 @@ class UnitTable(val worldScreen: WorldScreen) : Table(){ touchable = Touchable.enabled onClick { selectedUnit?.currentTile?.position?.let { - worldScreen.mapHolder.setCenterPosition(it, false, false) + if ( !worldScreen.mapHolder.setCenterPosition(it, false, false) && selectedUnit != null ) { + worldScreen.game.setScreen(CivilopediaScreen(worldScreen.gameInfo.ruleSet, CivilopediaCategories.Unit, selectedUnit!!.name)) + } } } }).expand()