Sortable unit overview (#11521)

* SortableGrid architecture changes: defaultSort instead of defaultDescending

* SortableGrid architecture changes: Header cell actor management

* SortableGrid architecture changes: General Services and reusable defaults

* Fix EmpireOverviewScreen sometimes forgetting the last active tab

* Reimplement Unit Overview using SortableGrid (but dropping unit supply)

* Get UnitSupplyTable back into Unit Overview

* Fix unit overview does not know PromotionPickerScreen changed the name

* Simplify update after rename

* Fix "Ooops" wrong value in ranged strength column
This commit is contained in:
SomeTroglodyte 2024-06-04 17:00:04 +02:00 committed by GitHub
parent 9d8484cf75
commit 4b2f5e468d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 672 additions and 403 deletions

View File

@ -1,15 +1,23 @@
package com.unciv.ui.components
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame
import com.unciv.logic.GameInfo
import com.unciv.ui.components.extensions.surroundWithCircle
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.widgets.SortableGrid
import com.unciv.ui.images.ImageGetter
/**
* This defines all behaviour of a sortable Grid per column through overridable parts:
* - [isVisible] can hide a column
* - [align], [fillX], [expandX], [equalizeHeight] control geometry
* - [getComparator] or [getEntryValue] control sorting, [defaultDescending] the initial order
* - [getHeaderIcon], [headerTip] and [headerTipHideIcons] define how the header row looks
* - [getComparator] or [getEntryValue] control sorting, [defaultSort] the initial order
* - [getHeaderActor], [headerTip] and [headerTipHideIcons] define how the header row looks
* - [getEntryValue] or [getEntryActor] define what the cells display
* - [getEntryValue] or [getTotalsActor] define what the totals row displays
* @param IT The item type - what defines the row
@ -34,9 +42,9 @@ interface ISortableGridContentProvider<IT, ACT> {
/** When overridden `true`, the entry cells of this column will be equalized to their max height */
val equalizeHeight: Boolean
/** When `true` the column will be sorted descending when the user switches sort to it. */
/** Default sort direction when a column is first sorted - can be None to disable sorting entirely for this column. */
// Relevant for visuals (simply inverting the comparator would leave the displayed arrow not matching)
val defaultDescending: Boolean
val defaultSort: SortableGrid.SortDirection
/** @return whether the column should be rendered */
fun isVisible(gameInfo: GameInfo): Boolean = true
@ -47,8 +55,10 @@ interface ISortableGridContentProvider<IT, ACT> {
*/
fun getComparator(): Comparator<IT> = compareBy { item: IT -> getEntryValue(item) }
/** Factory for the header cell [Actor] */
fun getHeaderIcon(iconSize: Float): Actor?
/** Factory for the header cell [Actor]
* @param iconSize Suggestion for icon size passed down from [SortableGrid] constructor, intended to scale the grid header. If the actor is not an icon, treat as height.
*/
fun getHeaderActor(iconSize: Float): Actor?
/** A getter for the numeric value to display in a cell */
fun getEntryValue(item: IT): Int
@ -57,7 +67,8 @@ interface ISortableGridContentProvider<IT, ACT> {
* - By default displays the (numeric) result of [getEntryValue].
* - [actionContext] can be used to define `onClick` actions.
*/
fun getEntryActor(item: IT, iconSize: Float, actionContext: ACT): Actor?
fun getEntryActor(item: IT, iconSize: Float, actionContext: ACT): Actor? =
getEntryValue(item).toCenteredLabel()
/** Factory for totals cell [Actor]
* - By default displays the sum over [getEntryValue].
@ -66,6 +77,21 @@ interface ISortableGridContentProvider<IT, ACT> {
* - On the other hand, a sum may not be meaningful even if the cells are numbers - to leave
* the total empty override to return `null`.
*/
fun getTotalsActor(items: Iterable<IT>): Actor?
fun getTotalsActor(items: Iterable<IT>): Actor? =
items.sumOf { getEntryValue(it) }.toCenteredLabel()
companion object {
@JvmStatic
val collator = UncivGame.Current.settings.getCollatorFromLocale()
@JvmStatic
fun getCircledIcon(path: String, iconSize: Float, circleColor: Color = Color.LIGHT_GRAY) =
ImageGetter.getImage(path)
.apply { color = Color.BLACK }
.surroundWithCircle(iconSize, color = circleColor)
@JvmStatic
fun Int.toCenteredLabel(): Label =
this.toLabel().apply { setAlignment(Align.center) }
}
}

View File

@ -5,8 +5,11 @@ 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.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.utils.Layout
import com.badlogic.gdx.utils.Align
import com.unciv.ui.components.ISortableGridContentProvider
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
@ -15,6 +18,7 @@ import com.unciv.ui.components.extensions.center
import com.unciv.ui.components.extensions.pad
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.onClick
import com.unciv.ui.images.IconCircleGroup
import com.unciv.ui.screens.basescreen.BaseScreen
@ -30,7 +34,10 @@ import com.unciv.ui.screens.basescreen.BaseScreen
class SortableGrid<IT, ACT, CT: ISortableGridContentProvider<IT, ACT>> (
/** Provides the columns to render as [ISortableGridContentProvider] instances */
private val columns: Iterable<CT>,
/** Provides the actual "data" as in one object per row that can then be passed to [ISortableGridContentProvider] methods to fetch cell content */
/** Provides the actual "data" as in one object per row that can then be passed to [ISortableGridContentProvider] methods to fetch cell content.
*
* Note: If your initial [sortState] has [SortDirection.None], then enumeration order of this will determine initial presentation order.
*/
private val data: Iterable<IT>,
/** Passed to [ISortableGridContentProvider.getEntryActor] where it can be used to define `onClick` actions. */
private val actionContext: ACT,
@ -81,10 +88,10 @@ class SortableGrid<IT, ACT, CT: ISortableGridContentProvider<IT, ACT>> (
}
private val headerRow = Table(skin)
private val headerIcons = hashMapOf<CT, HeaderGroup>()
private val headerElements = hashMapOf<CT, IHeaderElement>()
private val sortSymbols = hashMapOf<Boolean, Label>()
private val details = Table(skin)
val details = Table(skin) // public otherwise can't be used in reified public method
private val totalsRow = Table(skin)
init {
@ -130,20 +137,33 @@ class SortableGrid<IT, ACT, CT: ISortableGridContentProvider<IT, ACT>> (
sortSymbols[true] = "".toLabel() // U+FFEC
for (column in columns) {
val group = HeaderGroup(column)
headerIcons[column] = group
headerRow.add(group).size(iconSize).align(column.align)
.fill(column.fillX, false).expand(column.expandX, false)
val element = getHeaderElement(column)
headerElements[column] = element
val cell = headerRow.add(element.outerActor)
element.sizeCell(cell)
cell.align(column.align).fill(column.fillX, false).expand(column.expandX, false)
}
}
/** Calls [updateHeader] and [updateDetails] but not [updateCallback].
*
* Clients can call this if some data change affects cell content and sorting.
* Note there is optimization potential here - a mechanism that updates one cell, and resorts only if it is in the currently sorted column
*/
fun update() {
updateHeader()
updateDetails()
}
/** Update the sort direction icons of the header */
fun updateHeader() {
for (column in columns) {
val sortDirection = if (sortState.sortedBy == column) sortState.direction else SortDirection.None
headerIcons[column]?.setSortState(sortDirection)
headerElements[column]?.setSortState(sortDirection)
}
}
/** Rebuild the grid cells to update and/or resort the data */
fun updateDetails() {
details.clear()
if (data.none()) return
@ -190,64 +210,154 @@ class SortableGrid<IT, ACT, CT: ISortableGridContentProvider<IT, ACT>> (
fun SortDirection.inverted() = when {
this == SortDirection.Ascending -> SortDirection.Descending
this == SortDirection.Descending -> SortDirection.Ascending
sortBy.defaultDescending -> SortDirection.Descending
else -> SortDirection.Ascending
else -> sortBy.defaultSort
}
val direction = if (sortState.sortedBy == sortBy) sortState.direction else SortDirection.None
setSort(sortBy, direction.inverted())
}
sortState.run {
if (sortedBy == sortBy) {
direction = direction.inverted()
} else {
sortedBy = sortBy
direction = SortDirection.None.inverted()
}
}
fun setSort(sortBy: CT, direction: SortDirection) {
sortState.sortedBy = sortBy
sortState.direction = direction
// Rebuild header content to show sort state
updateHeader()
// Sort the table: clear and fill with sorted data
updateDetails()
// And resort the table: clear and fill with sorted data
update()
fireCallback()
}
// We must be careful - this is an inner class in order to have access to the SortableGrid
// type parameters, but that also means we have access to this@SortableGrid - automatically.
// Any unqualified method calls not implemented in this or a superclass will silently try a
// method offered by Table! Thus all the explicit `this` - to be really safe.
//
// Using Group to overlay an optional sort symbol on top of the icon - we could also
// do HorizontalGroup to have them side by side. Also, note this is not a WidgetGroup
// so all layout details are left to the container - in this case, a Table.Cell
// This will knowingly place the arrow partly outside the Group bounds.
/** Wrap icon and sort symbol for a header cell */
inner class HeaderGroup(column: CT) : Group() {
private val icon = column.getHeaderIcon(iconSize)
private var sortShown: SortDirection = SortDirection.None
/** Find the first Cell that contains an Actor of type [T] that matches [predicate].
* @return `null` if no such Actor found */
inline fun <reified T : Actor> findCell(predicate: (T) -> Boolean): Cell<T>? =
details.cells.asSequence()
.filterIsInstance<Cell<T>>() // does not remove cells with null actors, still necessary so the return type is fulfilled
.firstOrNull { it.actor is T && predicate(it.actor) }
init {
this.isTransform = false
this.setSize(iconSize, iconSize)
if (icon != null) {
this.onClick { toggleSort(column) }
icon.setSize(iconSize, iconSize)
icon.center(this)
if (column.headerTip.isNotEmpty())
icon.addTooltip(column.headerTip, 18f, tipAlign = Align.center, hideIcons = column.headerTipHideIcons)
this.addActor(icon)
}
}
/** Find the first Cell that contains an Actor of type [T] with the given [name][Actor.name].
*
* Not to be confused with [Group.findActor], which does a recursive search over children, does not filter by the type, and returns the actor.
* @return `null` if no such Actor found */
inline fun <reified T : Actor> findCell(name: String): Cell<T>? =
findCell { it.name == name }
// We must be careful - the implementations of IHeaderElement are inner classes in order to have access
// to the SortableGrid type parameters, but that also means we have access to this@SortableGrid - automatically.
// Any unqualified method calls not implemented in this or a superclass will silently try a
// method offered by Table! Thus make sure any Actor methods run for the contained actors.
/** Wrap icon, label or other Actor and sort symbol for a header cell.
*
* Not an Actor but a wrapper that contains [outerActor] which goes into the actual header.
*
* Where/how the sort symbol is rendered depends on the type returned by [ISortableGridContentProvider.getHeaderActor].
* Instantiate through [getHeaderElement] - can be [EmptyHeaderElement], [LayoutHeaderElement] or [IconHeaderElement].
*/
// Note - not an Actor because Actor is not an interface. Otherwise we *could* build a class that **is** an Actor which can be
// implemented by any Actor subclass but also carry additional fields and methods - via delegation.
interface IHeaderElement {
val outerActor: Actor
val headerActor: Actor?
var sortShown: SortDirection
/** Show or remove the sort symbol.
* @param showSort None removes the symbol, Ascending shows an up arrow, Descending a down arrow */
fun setSortState(showSort: SortDirection) {
if (showSort == sortShown) return
* @param newSort None removes the symbol, Ascending shows an up arrow, Descending a down arrow */
fun setSortState(newSort: SortDirection)
/** Internal: Used by common implementation for [setSortState] */
fun removeSortSymbol(sortSymbol: Label)
/** Internal: Used by common implementation for [setSortState] */
fun showSortSymbol(sortSymbol: Label)
/** Override in case the implementation has specific requirements for the Table.Cell its [outerActor] is hosted in. */
fun sizeCell(cell: Cell<Actor>) {}
}
private fun IHeaderElement.initActivationAndTooltip(column: CT) {
if (column.defaultSort != SortDirection.None)
outerActor.onClick { toggleSort(column) }
if (column.headerTip.isNotEmpty())
headerActor!!.addTooltip(column.headerTip, 18f, tipAlign = Align.center, hideIcons = column.headerTipHideIcons)
}
private fun IHeaderElement.setSortStateImpl(newSort: SortDirection) {
if (newSort == sortShown) return
for (symbol in sortSymbols.values)
removeActor(symbol) // Important: Does nothing if the actor is not our child
sortShown = showSort
if (showSort == SortDirection.None) return
val sortSymbol = sortSymbols[showSort == SortDirection.Descending]!!
removeSortSymbol(symbol) // Important: Does nothing if the actor is not our child
sortShown = newSort
if (newSort == SortDirection.None) return
val sortSymbol = sortSymbols[newSort == SortDirection.Descending]!!
showSortSymbol(sortSymbol)
}
private inner class EmptyHeaderElement : IHeaderElement {
override val outerActor = Actor()
override val headerActor = null
override var sortShown = SortDirection.None
override fun setSortState(newSort: SortDirection) {}
override fun removeSortSymbol(sortSymbol: Label) {}
override fun showSortSymbol(sortSymbol: Label) {}
}
/** Version of [IHeaderElement] that works fine for Image or IconCircleGroup and **overlays** the sort symbol on its lower right.
* Also used for all non-Layout non-null returns from [ISortableGridContentProvider.getHeaderActor].
*/
// Note this is not a WidgetGroup and thus does not implement Layout, so all layout details are left to the container
// - in this case, a Table.Cell. This will knowingly place the arrow partly outside the Group bounds.
private inner class IconHeaderElement(column: CT, override val headerActor: Actor) : IHeaderElement {
override val outerActor = Group()
override var sortShown = SortDirection.None
init {
outerActor.isTransform = false
outerActor.setSize(iconSize, iconSize)
outerActor.addActor(headerActor)
headerActor.setSize(iconSize, iconSize)
headerActor.center(outerActor)
initActivationAndTooltip(column)
}
override fun sizeCell(cell: Cell<Actor>) {
cell.size(iconSize)
}
override fun setSortState(newSort: SortDirection) = setSortStateImpl(newSort)
override fun removeSortSymbol(sortSymbol: Label) {
outerActor.removeActor(sortSymbol)
}
override fun showSortSymbol(sortSymbol: Label) {
sortSymbol.setPosition(iconSize - 2f, 0f)
addActor(sortSymbol)
outerActor.addActor(sortSymbol)
}
}
/** Version of [IHeaderElement] for all Layout returns from [ISortableGridContentProvider.getHeaderActor].
* Draws the sort symbol by rendering [headerActor] and the sort symbol side by side in a HorizontalGroup
*/
private inner class LayoutHeaderElement(column: CT, override val headerActor: Actor) : IHeaderElement {
override val outerActor = HorizontalGroup()
override var sortShown = SortDirection.None
init {
outerActor.isTransform = false
outerActor.align(column.align)
outerActor.addActor(headerActor)
initActivationAndTooltip(column)
}
override fun setSortState(newSort: SortDirection) = setSortStateImpl(newSort)
override fun removeSortSymbol(sortSymbol: Label) {
outerActor.removeActor(sortSymbol)
}
override fun showSortSymbol(sortSymbol: Label) {
outerActor.addActor(sortSymbol)
}
}
private fun getHeaderElement(column: CT): IHeaderElement {
return when (val headerActor = column.getHeaderActor(iconSize)) {
null -> EmptyHeaderElement()
is Image, is IconCircleGroup -> IconHeaderElement(column, headerActor) // They're also `is Layout`, but we want the overlaid header version
is Layout -> LayoutHeaderElement(column, headerActor)
else -> IconHeaderElement(column, headerActor) // We haven't got a better implementation for other non-Layout Actors.
}
}
}

View File

@ -2,20 +2,21 @@ package com.unciv.ui.screens.overviewscreen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame
import com.unciv.logic.GameInfo
import com.unciv.logic.city.City
import com.unciv.logic.city.CityFlags
import com.unciv.models.stats.Stat
import com.unciv.models.translations.tr
import com.unciv.ui.components.ISortableGridContentProvider
import com.unciv.ui.components.ISortableGridContentProvider.Companion.collator
import com.unciv.ui.components.ISortableGridContentProvider.Companion.getCircledIcon
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.surroundWithCircle
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.widgets.SortableGrid
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.cityscreen.CityScreen
import kotlin.math.roundToInt
@ -34,9 +35,9 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider<City, EmpireOver
override val headerTip = "Name"
override val align = Align.left
override val fillX = true
override val defaultDescending = false
override val defaultSort get() = SortableGrid.SortDirection.Ascending
override fun getComparator() = compareBy<City, String>(collator) { it.name.tr(hideIcons = true) }
override fun getHeaderIcon(iconSize: Float) =
override fun getHeaderActor(iconSize: Float) =
ImageGetter.getUnitIcon("Settler")
.surroundWithCircle(iconSize)
override fun getEntryValue(item: City) = 0 // make sure that `stat!!` in the super isn't used
@ -51,7 +52,7 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider<City, EmpireOver
Status {
override val headerTip = "Status\n(puppet, resistance or being razed)"
override fun getHeaderIcon(iconSize: Float) = ImageGetter.getImage("OtherIcons/CityStatus")
override fun getHeaderActor(iconSize: Float) = ImageGetter.getImage("OtherIcons/CityStatus")
override fun getEntryValue(item: City) = when {
item.isBeingRazed -> 3
item.isInResistance() -> 2
@ -72,7 +73,7 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider<City, EmpireOver
},
ConstructionIcon {
override fun getHeaderIcon(iconSize: Float) = null
override fun getHeaderActor(iconSize: Float) = null
override fun getEntryValue(item: City) =
item.cityConstructions.run { turnsToConstruction(currentConstructionFromQueue) }
override fun getEntryActor(item: City, iconSize: Float, actionContext: EmpireOverviewScreen): Actor? {
@ -88,10 +89,10 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider<City, EmpireOver
override val expandX = false
override val equalizeHeight = true
override val headerTip = "Current construction"
override val defaultDescending = false
override val defaultSort get() = SortableGrid.SortDirection.Ascending
override fun getComparator() =
compareBy<City, String>(collator) { it.cityConstructions.currentConstructionFromQueue.tr(hideIcons = true) }
override fun getHeaderIcon(iconSize: Float) =
override fun getHeaderActor(iconSize: Float) =
getCircledIcon("OtherIcons/Settings", iconSize)
override fun getEntryValue(item: City) = 0
override fun getEntryActor(item: City, iconSize: Float, actionContext: EmpireOverviewScreen) =
@ -124,10 +125,10 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider<City, EmpireOver
WLTK {
override val headerTip = "We Love The King Day"
override val defaultDescending = false
override val defaultSort get() = SortableGrid.SortDirection.Ascending
override fun getComparator() =
super.getComparator().thenBy { it.demandedResource.tr(hideIcons = true) }
override fun getHeaderIcon(iconSize: Float) =
override fun getHeaderActor(iconSize: Float) =
getCircledIcon("OtherIcons/WLTK 2", iconSize, Color.TAN)
override fun getEntryValue(item: City) =
if (item.isWeLoveTheKingDayActive()) 1 else 0
@ -153,10 +154,10 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider<City, EmpireOver
Garrison {
override val headerTip = "Garrisoned by unit"
override val defaultDescending = false
override val defaultSort get() = SortableGrid.SortDirection.Ascending
override fun getComparator() =
compareBy<City, String>(collator) { it.getGarrison()?.name?.tr(hideIcons = true) ?: "" }
override fun getHeaderIcon(iconSize: Float) =
override fun getHeaderActor(iconSize: Float) =
getCircledIcon("OtherIcons/Shield", iconSize)
override fun getEntryValue(item: City) =
if (item.getGarrison() != null) 1 else 0
@ -166,7 +167,7 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider<City, EmpireOver
val unitIcon = ImageGetter.getConstructionPortrait(unit.baseUnit.getIconName(), iconSize * 0.7f)
unitIcon.addTooltip(unitName, 18f, tipAlign = Align.topLeft)
unitIcon.onClick {
actionContext.select(EmpireOverviewCategories.Units, UnitOverviewTab.getUnitIdentifier(unit) )
actionContext.select(EmpireOverviewCategories.Units, UnitOverviewTabColumn.getUnitIdentifier(unit) )
}
return unitIcon
}
@ -185,8 +186,7 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider<City, EmpireOver
override val fillX = false
override val expandX = false
override val equalizeHeight = false
override val defaultDescending = true
override val defaultSort get() = SortableGrid.SortDirection.Descending
//endregion
//region Overridable methods
@ -194,7 +194,7 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider<City, EmpireOver
* - Must override unless a texture exists for "StatIcons/$name" - e.g. a [Stat] column or [Population].
* - _Should_ be sized to [iconSize].
*/
override fun getHeaderIcon(iconSize: Float): Actor? =
override fun getHeaderActor(iconSize: Float): Actor? =
ImageGetter.getStatIcon(name)
/** A getter for the numeric value to display in a cell
@ -206,34 +206,5 @@ enum class CityOverviewTabColumn : ISortableGridContentProvider<City, EmpireOver
override fun getEntryValue(item: City): Int =
item.cityStats.currentCityStats[stat!!].roundToInt()
/** Factory for entry cell [Actor]
* - By default displays the (numeric) result of [getEntryValue].
* - [actionContext] will be the parent screen used to define `onClick` actions.
*/
override fun getEntryActor(item: City, iconSize: Float, actionContext: EmpireOverviewScreen): Actor? =
getEntryValue(item).toCenteredLabel()
//endregion
/** Factory for totals cell [Actor]
* - By default displays the sum over [getEntryValue].
* - Note a count may be meaningful even if entry cells display something other than a number,
* In that case _not_ overriding this and supply a meaningful [getEntryValue] may be easier.
* - On the other hand, a sum may not be meaningful even if the cells are numbers - to leave
* the total empty override to return `null`.
*/
override fun getTotalsActor(items: Iterable<City>): Actor? =
items.sumOf { getEntryValue(it) }.toCenteredLabel()
companion object {
private val collator = UncivGame.Current.settings.getCollatorFromLocale()
private fun getCircledIcon(path: String, iconSize: Float, circleColor: Color = Color.LIGHT_GRAY) =
ImageGetter.getImage(path)
.apply { color = Color.BLACK }
.surroundWithCircle(iconSize, color = circleColor)
private fun Int.toCenteredLabel(): Label =
this.toLabel().apply { setAlignment(Align.center) }
}
}

View File

@ -16,6 +16,7 @@ abstract class EmpireOverviewTab (
open val persistableData = persistedData ?: EmpireOverviewTabPersistableData()
override fun activated(index: Int, caption: String, pager: TabbedPager) {
if (caption.isEmpty()) return
overviewScreen.game.settings.lastOverviewPage = caption
}

View File

@ -1,44 +1,16 @@
package com.unciv.ui.screens.overviewscreen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.Action
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.Table
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.GUI
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.logic.map.tile.Tile
import com.unciv.models.UnitActionType
import com.unciv.models.UpgradeUnitAction
import com.unciv.models.ruleset.unit.BaseUnit
import com.unciv.ui.components.extensions.addSeparator
import com.unciv.ui.components.extensions.brighten
import com.unciv.ui.components.extensions.center
import com.unciv.ui.components.extensions.darken
import com.unciv.ui.components.extensions.equalizeColumns
import com.unciv.ui.components.extensions.surroundWithCircle
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toPrettyString
import com.unciv.ui.components.fonts.Fonts
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.widgets.ExpanderTab
import com.unciv.ui.components.widgets.SortableGrid
import com.unciv.ui.components.widgets.TabbedPager
import com.unciv.ui.components.widgets.UnitGroup
import com.unciv.ui.images.IconTextButton
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popups.UnitUpgradeMenu
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.pickerscreens.PromotionPickerScreen
import com.unciv.ui.screens.pickerscreens.UnitRenamePopup
import com.unciv.ui.screens.worldscreen.unit.actions.UnitActionsUpgrade
import kotlin.math.abs
//TODO use SortableGrid
/**
* Supplies the Unit sub-table for the Empire Overview
*/
@ -47,26 +19,14 @@ class UnitOverviewTab(
overviewScreen: EmpireOverviewScreen,
persistedData: EmpireOverviewTabPersistableData? = null
) : EmpireOverviewTab(viewingPlayer, overviewScreen) {
class UnitTabPersistableData(
class UnitTabPersistableData : EmpireOverviewTabPersistableData(), SortableGrid.ISortState<UnitOverviewTabColumn> {
var scrollY: Float? = null
) : EmpireOverviewTabPersistableData() {
override fun isEmpty() = scrollY == null
override var sortedBy: UnitOverviewTabColumn = UnitOverviewTabColumn.Name
override var direction = SortableGrid.SortDirection.None
override fun isEmpty() = scrollY == null && sortedBy == UnitOverviewTabColumn.Name && direction != SortableGrid.SortDirection.Descending
}
override val persistableData = (persistedData as? UnitTabPersistableData) ?: UnitTabPersistableData()
override fun activated(index: Int, caption: String, pager: TabbedPager) {
if (persistableData.scrollY != null)
pager.setPageScrollY(index, persistableData.scrollY!!)
super.activated(index, caption, pager)
}
override fun deactivated(index: Int, caption: String, pager: TabbedPager) {
persistableData.scrollY = pager.getPageScrollY(index)
removeBlinkAction()
}
private val supplyTableWidth = (overviewScreen.stage.width * 0.25f).coerceAtLeast(240f)
private val unitListTable = Table() // could be `this` instead, extra nesting helps readability a little
private val unitHeaderTable = Table()
private val fixedContent = Table()
// used for select()
@ -79,262 +39,74 @@ class UnitOverviewTab(
blinkActor = null
}
//todo The original did its own sort:
/*
val oldIterator = viewingPlayer.units.getCivUnits().sortedWith(
compareBy(
{ it.displayName() },
{ !it.due },
{ it.currentMovement <= Constants.minimumMovementEpsilon },
{ abs(it.currentTile.position.x) + abs(it.currentTile.position.y) }
)
)
*/
// We're using getCivUnits enumeration order instead so far.
// - Adding that sort to the data source using Sequence would be inefficient because it is re-enumerated on every resort, and user sorts would mean that initial sort would largely be overridden
// - Materializing the sort result would only waste memory
// - But - isn't getCivUnits() deterministic anyway - controls "Next Unit" order? actually, getCivUnitsStartingAtNextDue would give that, it slices by an internal pointer
//todo the comments and todo below are copied verbatim from CityOverviewTab - synergies?
private val grid = SortableGrid(
columns = UnitOverviewTabColumn.values().asIterable(),
data = viewingPlayer.units.getCivUnits().asIterable(),
actionContext = this,
sortState = persistableData,
iconSize = 20f,
paddingVert = 5f,
paddingHorz = 8f,
separateHeader = true
) { header, details, totals ->
// Notes: header.parent is the LinkedScrollPane of TabbedPager. Its linked twin is details.parent.parent.parent however!
// horizontal "slack" if available width > content width is taken up between SortableGrid and CityOverviewTab for the details,
// but not so for the header. We must force the LinkedScrollPane somehow (no? how?) to do so - or the header Table itself.
equalizeColumns(details, header, totals)
// todo Kludge! Positioning and alignment of the header Table within its parent has quirks when content width < stage width
// This code should likely be included in SortableGrid anyway?
if (header.width < this.width) header.width = this.width
this.validate()
}
override fun getFixedContent() = fixedContent
init {
fixedContent.add(getUnitSupplyTable()).align(Align.top).padBottom(10f).row()
fixedContent.add(unitHeaderTable.updateUnitHeaderTable())
val supplyTableWidth = (overviewScreen.stage.width * 0.25f).coerceAtLeast(240f)
val unitSupplyTable = UnitSupplyTable.create(overviewScreen, this, viewingPlayer, supplyTableWidth)
fixedContent.add(unitSupplyTable).align(Align.top).padBottom(10f).row()
fixedContent.add(grid.getHeader()).grow()
top()
add(unitListTable.updateUnitListTable())
equalizeColumns(unitListTable, unitHeaderTable)
add(grid)
}
// Here overloads are simpler than a generic:
private fun Table.addLabeledValue (label: String, value: Int) {
add(label.toLabel()).left()
add(value.toLabel()).right().row()
override fun activated(index: Int, caption: String, pager: TabbedPager) {
if (persistableData.scrollY != null)
pager.setPageScrollY(index, persistableData.scrollY!!)
super.activated(index, caption, pager)
}
private fun Table.addLabeledValue (label: String, value: String) {
add(label.toLabel()).left()
add(value.toLabel()).right().row()
override fun deactivated(index: Int, caption: String, pager: TabbedPager) {
persistableData.scrollY = pager.getPageScrollY(index)
removeBlinkAction()
}
private fun showWorldScreenAt(position: Vector2, unit: MapUnit?) {
GUI.resetToWorldScreen()
GUI.getMap().setCenterPosition(position, forceSelectUnit = unit)
}
private fun showWorldScreenAt(unit: MapUnit) = showWorldScreenAt(unit.currentTile.position, unit)
private fun showWorldScreenAt(tile: Tile) = showWorldScreenAt(tile.position, null)
private fun getUnitSupplyTable(): ExpanderTab {
val stats = viewingPlayer.stats
val deficit = stats.getUnitSupplyDeficit()
val icon = if (deficit <= 0) null else Group().apply {
isTransform = false
setSize(36f, 36f)
val image = ImageGetter.getImage("OtherIcons/ExclamationMark")
image.color = Color.FIREBRICK
image.setSize(36f, 36f)
image.center(this)
image.setOrigin(Align.center)
addActor(image)
}
return ExpanderTab(
title = "Unit Supply",
fontSize = Constants.defaultFontSize,
icon = icon,
startsOutOpened = deficit > 0,
defaultPad = 0f,
expanderWidth = supplyTableWidth,
onChange = {
overviewScreen.resizePage(this)
}
) {
it.defaults().pad(5f).fill(false)
it.background = BaseScreen.skinStrings.getUiBackground(
"OverviewScreen/UnitOverviewTab/UnitSupplyTable",
tintColor = BaseScreen.skinStrings.skinConfig.baseColor.darken(0.6f)
)
it.addLabeledValue("Base Supply", stats.getBaseUnitSupply())
it.addLabeledValue("Cities", stats.getUnitSupplyFromCities())
it.addLabeledValue("Population", stats.getUnitSupplyFromPop())
it.addSeparator()
it.addLabeledValue("Total Supply", stats.getUnitSupply())
it.addLabeledValue("In Use", viewingPlayer.units.getCivUnitsSize())
it.addSeparator()
it.addLabeledValue("Supply Deficit", deficit)
it.addLabeledValue("Production Penalty", "${stats.getUnitSupplyProductionPenalty().toInt()}%")
if (deficit > 0) {
val penaltyLabel = "Increase your supply or reduce the amount of units to remove the production penalty"
.toLabel(Color.FIREBRICK)
penaltyLabel.wrap = true
it.add(penaltyLabel).colspan(2).left()
.width(supplyTableWidth).row()
}
}
}
private fun Table.updateUnitHeaderTable(): Table {
defaults().pad(5f)
add("Name".toLabel())
add() // Column: edit-name
add("Action".toLabel())
add(Fonts.strength.toString().toLabel())
add(Fonts.rangedStrength.toString().toLabel())
add(Fonts.movement.toString().toLabel())
add("Closest city".toLabel())
add("Promotions".toLabel())
add("Upgrade".toLabel())
add("Health".toLabel())
addSeparator().padBottom(0f)
return this
}
private fun Table.updateUnitListTable(): Table {
clear()
val game = overviewScreen.game
defaults().pad(5f)
for (unit in viewingPlayer.units.getCivUnits().sortedWith(
compareBy({ it.displayName() },
{ !it.due },
{ it.currentMovement <= Constants.minimumMovementEpsilon },
{ abs(it.currentTile.position.x) + abs(it.currentTile.position.y) })
)) {
val baseUnit = unit.baseUnit
// Unit button column - name, health, fortified, sleeping, embarked are visible here
val button = IconTextButton(
unit.displayName(),
UnitGroup(unit, 20f).apply { if (!unit.isIdle()) color.a = 0.5f },
fontColor = if (unit.isIdle()) Color.WHITE else Color.LIGHT_GRAY
)
button.name = getUnitIdentifier(unit) // Marker to find a unit in select()
button.onClick {
showWorldScreenAt(unit)
}
add(button).fillX()
// Column: edit-name
val editIcon = ImageGetter.getImage("OtherIcons/Pencil").apply { this.color = Color.WHITE }.surroundWithCircle(30f, true, Color.valueOf("000c31"))
editIcon.onClick {
UnitRenamePopup(
screen = overviewScreen,
unit = unit,
actionOnClose = {
overviewScreen.game.replaceCurrentScreen(
EmpireOverviewScreen(viewingPlayer, selection = getUnitIdentifier(unit))
)
})
}
add(editIcon)
// Column: action
fun getWorkerActionText(unit: MapUnit): String? = when {
// See UnitTurnManager.endTurn, if..workOnImprovement or UnitGroup.getActionImage: similar logic
!unit.cache.hasUniqueToBuildImprovements -> null
unit.currentMovement == 0f -> null
unit.currentTile.improvementInProgress == null -> null
!unit.canBuildImprovement(unit.getTile().getTileImprovementInProgress()!!) -> null
else -> unit.currentTile.improvementInProgress
}
fun getActionText(unit: MapUnit): String? {
val workerText by lazy { getWorkerActionText(unit) }
return when {
unit.action == null -> workerText
unit.isFortified() -> UnitActionType.Fortify.value
unit.isMoving() -> "Moving"
unit.isAutomated() && workerText != null -> "[$workerText] ${Fonts.automate}"
else -> unit.action
}
}
add(getActionText(unit)?.toLabel())
// Columns: strength, ranged
if (baseUnit.strength > 0) add(baseUnit.strength.toLabel()) else add()
if (baseUnit.rangedStrength > 0) add(baseUnit.rangedStrength.toLabel()) else add()
add(unit.getMovementString().toLabel())
// Closest city column
val closestCity =
unit.getTile().getTilesInDistance(3).firstOrNull { it.isCityCenter() }
val cityColor = if (unit.getTile() == closestCity) Color.FOREST.brighten(0.5f) else Color.WHITE
if (closestCity != null)
add(closestCity.getCity()!!.name.toLabel(cityColor).apply {
onClick { showWorldScreenAt(closestCity) }
})
else add()
// Promotions column
val promotionsTable = Table()
updatePromotionsTable(promotionsTable, unit)
promotionsTable.onClick {
if (unit.promotions.canBePromoted() || unit.promotions.promotions.isNotEmpty()) {
game.pushScreen(PromotionPickerScreen(unit) {
updatePromotionsTable(promotionsTable, unit)
})
}
}
add(promotionsTable)
// Upgrade column
val upgradeTable = Table()
updateUpgradeTable(upgradeTable, unit)
add(upgradeTable)
// Numeric health column - there's already a health bar on the button, but...?
if (unit.health < 100) add(unit.health.toLabel()) else add()
row()
}
return this
}
private fun updateUpgradeTable(table: Table, unit: MapUnit){
table.clearChildren()
val unitActions = UnitActionsUpgrade.getUpgradeActionAnywhere(unit)
if (unitActions.none()) table.add()
for (unitAction in unitActions){
val enable = unitAction.action != null && viewingPlayer.isCurrentPlayer() &&
GUI.isAllowedChangeState()
val unitToUpgradeTo = (unitAction as UpgradeUnitAction).unitToUpgradeTo
val selectKey = getUnitIdentifier(unit, unitToUpgradeTo)
val upgradeIcon = ImageGetter.getUnitIcon(unitToUpgradeTo.name,
if (enable) Color.GREEN else Color.GREEN.darken(0.5f))
upgradeIcon.onClick {
UnitUpgradeMenu(overviewScreen.stage, upgradeIcon, unit, unitAction, enable) {
unitListTable.updateUnitListTable()
select(selectKey)
}
}
table.add(upgradeIcon).size(28f)
}
}
private fun updatePromotionsTable(table: Table, unit: MapUnit) {
table.clearChildren()
// getPromotions goes by json order on demand - so this is the same sorting as on UnitTable,
// but not same as on PromotionPickerScreen (which e.g. tries to respect prerequisite proximity)
val promotions = unit.promotions.getPromotions(true)
val showPromoteStar = unit.promotions.canBePromoted()
if (promotions.any()) {
val iconCount = promotions.count() + (if (showPromoteStar) 1 else 0)
val numberOfLines = (iconCount - 1) / 8 + 1 // Int math: -1,/,+1 means divide rounding *up*
val promotionsPerLine = (iconCount - 1) / numberOfLines + 1
for (linePromotions in promotions.chunked(promotionsPerLine)) {
for (promotion in linePromotions) {
table.add(ImageGetter.getPromotionPortrait(promotion.name))
}
if (linePromotions.size == promotionsPerLine) table.row()
}
}
if (!showPromoteStar) return
table.add(
ImageGetter.getImage("OtherIcons/Star").apply {
color = if (GUI.isAllowedChangeState() && unit.currentMovement > 0f && unit.attacksThisTurn == 0)
Color.GOLDENROD
else Color.GOLDENROD.darken(0.25f)
}
).size(24f).padLeft(8f)
}
companion object {
fun getUnitIdentifier(unit: MapUnit, unitToUpgradeTo: BaseUnit? = null): String {
val name = unitToUpgradeTo?.name ?: unit.name
return "$name@${unit.getTile().position.toPrettyString()}"
}
}
internal fun update() = grid.update()
override fun select(selection: String): Float? {
val cell = unitListTable.cells.asSequence()
.filter { it.actor is IconTextButton && it.actor.name == selection }
.firstOrNull() ?: return null
val button = cell.actor as IconTextButton
val cell = grid.findCell<IconTextButton>(selection)
?: return null
val button = cell.actor
val scrollY = (0 until cell.row)
.map { unitListTable.getRowHeight(it) }.sum() -
(parent.height - unitListTable.getRowHeight(cell.row)) / 2
.map { grid.details.getRowHeight(it) }.sum() -
(parent.height - grid.details.getRowHeight(cell.row)) / 2
removeBlinkAction()
blinkAction = Actions.repeat(3, Actions.sequence(
@ -345,5 +117,4 @@ class UnitOverviewTab(
button.addAction(blinkAction)
return scrollY
}
}

View File

@ -0,0 +1,143 @@
package com.unciv.ui.screens.overviewscreen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.utils.Align
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.models.translations.tr
import com.unciv.ui.components.ISortableGridContentProvider
import com.unciv.ui.components.ISortableGridContentProvider.Companion.toCenteredLabel
import com.unciv.ui.components.extensions.brighten
import com.unciv.ui.components.extensions.surroundWithCircle
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.fonts.Fonts
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.widgets.SortableGrid
import com.unciv.ui.components.widgets.UnitGroup
import com.unciv.ui.images.IconTextButton
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.pickerscreens.UnitRenamePopup
//todo Extending getEntryValue here to have a second String-based "channel" - could go into SortableGrid, possibly by defining a DataType per column???
enum class UnitOverviewTabColumn(
private val headerLabel: String? = null,
override val headerTip: String = "",
private val isNumeric: Boolean = false
) : ISortableGridContentProvider<MapUnit, UnitOverviewTab> {
//region Enum Instances
Name {
override val fillX = true
override fun getEntryString(item: MapUnit) = item.displayName().tr(hideIcons = true)
override fun getEntryActor(item: MapUnit, iconSize: Float, actionContext: UnitOverviewTab): Actor {
// Unit button column - name, health, fortified, sleeping, embarked are visible here
val button = IconTextButton(
item.displayName(),
UnitGroup(item, 20f).apply { if (!unit.isIdle()) color.a = 0.5f },
fontColor = if (item.isIdle()) Color.WHITE else Color.LIGHT_GRAY
)
button.name = getUnitIdentifier(item) // Marker to find a unit in select()
button.onClick {
showWorldScreenAt(item)
}
return button
}
override fun getTotalsActor(items: Iterable<MapUnit>) = items.count().toCenteredLabel()
},
EditName("") {
override val defaultSort get() = SortableGrid.SortDirection.None
override fun getEntryActor(item: MapUnit, iconSize: Float, actionContext: UnitOverviewTab): Actor {
val selectKey = getUnitIdentifier(item)
val editIcon = ImageGetter.getImage("OtherIcons/Pencil")
.apply { this.color = Color.WHITE }
.surroundWithCircle(30f, true, Color(0x000c31))
editIcon.onClick {
UnitRenamePopup(actionContext.overviewScreen, item) {
actionContext.update()
actionContext.overviewScreen.select(EmpireOverviewCategories.Units, selectKey)
}
}
return editIcon
}
},
Action {
override fun getEntryString(item: MapUnit): String? = getActionText(item)
},
Strength(Fonts.strength.toString(), "Strength", true) {
override val defaultSort get() = SortableGrid.SortDirection.Descending
override fun getEntryValue(item: MapUnit) = item.baseUnit.strength
},
RangedStrength(Fonts.rangedStrength.toString(), "Ranged strength", true) {
override val defaultSort get() = SortableGrid.SortDirection.Descending
override fun getEntryValue(item: MapUnit) = item.baseUnit.rangedStrength
},
Movement(Fonts.movement.toString(), "Movement", true) {
override val defaultSort get() = SortableGrid.SortDirection.Descending
override fun getEntryString(item: MapUnit) = item.getMovementString()
override fun getComparator() = compareBy<MapUnit> { it.getMaxMovement() }.thenBy { it.currentMovement }
},
ClosestCity("Closest city") {
//todo these overrides call a getTilesInDistance(3).firstOrNull loop independently and possibly repeatedly - caching?
override fun getEntryString(item: MapUnit) = getClosestCityTile(item)?.getCity()?.name
override fun getEntryActor(item: MapUnit, iconSize: Float, actionContext: UnitOverviewTab): Actor? {
val closestCityTile = getClosestCityTile(item) ?: return null
val cityColor = if (item.getTile() == closestCityTile) Color.FOREST.brighten(0.5f) else Color.WHITE
val label = closestCityTile.getCity()!!.name.toLabel(fontColor = cityColor, alignment = Align.center)
label.onClick { showWorldScreenAt(closestCityTile) }
return label
}
private fun getClosestCityTile(item: MapUnit) = item.getTile()
.getTilesInDistance(3).firstOrNull { it.isCityCenter() }
},
Promotions(isNumeric = true) {
override val defaultSort get() = SortableGrid.SortDirection.Descending
override fun getEntryValue(item: MapUnit) =
(if (item.promotions.canBePromoted()) 10000 else 0) +
item.promotions.promotions.size // Not numberOfPromotions - DO count free ones. Or sort by totalXpProduced?
override fun getEntryActor(item: MapUnit, iconSize: Float, actionContext: UnitOverviewTab) = getPromotionsTable(item, actionContext)
},
Upgrade {
//todo these overrides call UnitActionsUpgrade.getUpgradeActionAnywhere independently and possibly repeatedly - caching?
override fun getEntryString(item: MapUnit) = getUpgradeSortString(item)
override fun getEntryActor(item: MapUnit, iconSize: Float, actionContext: UnitOverviewTab) = getUpgradeTable(item, actionContext)
override fun getTotalsActor(items: Iterable<MapUnit>) = items.count { getUpgradeSortString(it) != null }.toCenteredLabel()
},
Health(isNumeric = true) {
override fun getEntryValue(item: MapUnit) = item.health
override fun getEntryString(item: MapUnit) = if (item.health == 100) null else item.health.toString()
override fun getTotalsActor(items: Iterable<MapUnit>) = items.count { it.health < 100 }.toCenteredLabel()
},
;
//endregion
//region Overridden superclass fields
override val align = Align.center
override val fillX = false
override val expandX = false
override val equalizeHeight = false
override val defaultSort get() = SortableGrid.SortDirection.Ascending
//endregion
open fun getEntryString(item: MapUnit): String? = getEntryValue(item).takeIf { it > 0 }?.toString()
//region Overridden superclass methods
override fun getHeaderActor(iconSize: Float) = (headerLabel ?: name).toLabel()
override fun getEntryValue(item: MapUnit) = 0
override fun getEntryActor(item: MapUnit, iconSize: Float, actionContext: UnitOverviewTab): Actor? =
getEntryString(item)?.toLabel(alignment = Align.center)
override fun getComparator() = if (isNumeric) super.getComparator()
// Sort empty cells to the end by faking a `String.MAX_VALUE` - to do it properly would be a far more verbose Comparator subclass
else compareBy(ISortableGridContentProvider.collator) { getEntryString(it)?.tr(hideIcons = true) ?: "\uD83D\uDE00zzz" }
override fun getTotalsActor(items: Iterable<MapUnit>): Actor? = null
//endregion
companion object : UnitOverviewTabHelpers()
}

View File

@ -0,0 +1,151 @@
package com.unciv.ui.screens.overviewscreen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.GUI
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.logic.map.tile.Tile
import com.unciv.models.UnitActionType
import com.unciv.models.UpgradeUnitAction
import com.unciv.models.ruleset.unit.BaseUnit
import com.unciv.models.translations.tr
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.darken
import com.unciv.ui.components.extensions.toPrettyString
import com.unciv.ui.components.fonts.Fonts
import com.unciv.ui.components.input.onClick
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popups.UnitUpgradeMenu
import com.unciv.ui.screens.pickerscreens.PromotionPickerScreen
import com.unciv.ui.screens.worldscreen.unit.actions.UnitActionsUpgrade
/**
* Helper library for [UnitOverviewTabColumn]
*
* Note - this will be made into a companion object by simply inheriting it, so do treat it as singleton
*/
open class UnitOverviewTabHelpers {
/** Create an identifier to support selecting a specific unit - or finding it again after a resort or after an upgrade.
* This is for UI only, as there can be no 100% guarantee the find will succeed or be unambiguous.
*/
internal fun getUnitIdentifier(unit: MapUnit, unitToUpgradeTo: BaseUnit? = null): String {
val name = unitToUpgradeTo?.name ?: unit.name
return "$name@${unit.getTile().position.toPrettyString()}"
}
private fun showWorldScreenAt(position: Vector2, unit: MapUnit?) {
GUI.resetToWorldScreen()
GUI.getMap().setCenterPosition(position, forceSelectUnit = unit)
}
protected fun showWorldScreenAt(unit: MapUnit) = showWorldScreenAt(unit.currentTile.position, unit)
protected fun showWorldScreenAt(tile: Tile) = showWorldScreenAt(tile.position, null)
private fun getWorkerActionText(unit: MapUnit): String? = when {
// See UnitTurnManager.endTurn, if..workOnImprovement or UnitGroup.getActionImage: similar logic
!unit.cache.hasUniqueToBuildImprovements -> null
unit.currentMovement == 0f -> null
unit.currentTile.improvementInProgress == null -> null
!unit.canBuildImprovement(unit.getTile().getTileImprovementInProgress()!!) -> null
else -> unit.currentTile.improvementInProgress
}
protected fun getActionText(unit: MapUnit): String? {
val workerText by lazy { getWorkerActionText(unit) }
return when {
unit.action == null -> workerText
unit.isFortified() -> UnitActionType.Fortify.value
unit.isMoving() -> "Moving"
unit.isAutomated() && workerText != null -> "[$workerText] ${Fonts.automate}"
else -> unit.action
}
}
protected fun getUpgradeTable(unit: MapUnit, actionContext: UnitOverviewTab): Table? {
val table = Table()
val unitActions = UnitActionsUpgrade.getUpgradeActionAnywhere(unit)
if (unitActions.none()) return null
val canEnable = actionContext.viewingPlayer.isCurrentPlayer() && GUI.isAllowedChangeState()
for (unitAction in unitActions) {
val enable = canEnable && unitAction.action != null
val unitToUpgradeTo = (unitAction as UpgradeUnitAction).unitToUpgradeTo
val selectKey = getUnitIdentifier(unit, unitToUpgradeTo)
val upgradeIcon = ImageGetter.getUnitIcon(unitToUpgradeTo.name,
if (enable) Color.GREEN else Color.GREEN.darken(0.5f))
upgradeIcon.onClick {
UnitUpgradeMenu(actionContext.overviewScreen.stage, upgradeIcon, unit, unitAction, enable) {
actionContext.update()
actionContext.overviewScreen.select(EmpireOverviewCategories.Units, selectKey) // actionContext.select skips setting scrollY
}
}
upgradeIcon.addTooltip(unitToUpgradeTo.name, 24f, tipAlign = Align.bottomLeft)
table.add(upgradeIcon).size(28f)
}
return table
}
protected fun getUpgradeSortString(unit: MapUnit): String? {
val upgrade = UnitActionsUpgrade.getUpgradeActionAnywhere(unit).firstOrNull()
?: return null
return (upgrade as UpgradeUnitAction).unitToUpgradeTo.name.tr(hideIcons = true)
}
protected fun getPromotionsTable(unit: MapUnit, actionContext: UnitOverviewTab): Table {
// This was once designed to be redrawn in place without rebuilding the grid.
// That created problems with sorting - and determining when the state would allow minimal updating is complex.
// But the old way also had the mini-bug that PromotionPicker allows unit rename which wasn't reflected on the grid...
// Now it always does rebuild all rows (as simple as actionContext.update instead of updatePromotionsTable).
val promotionsTable = Table()
val canEnable = actionContext.viewingPlayer.isCurrentPlayer() && GUI.isAllowedChangeState()
updatePromotionsTable(promotionsTable, unit, canEnable)
val selectKey = getUnitIdentifier(unit)
fun onPromotionsTableClick() {
val canPromote = canEnable && unit.promotions.canBePromoted()
if (!canPromote && unit.promotions.promotions.isEmpty()) return
// We can either add a promotion or at least view existing ones.
// PromotionPickerScreen is reponsible for checking viewingPlayer.isCurrentPlayer and isAllowedChangeState **again**.
actionContext.overviewScreen.game.pushScreen(
PromotionPickerScreen(unit) {
// Todo seems the picker does not call this if only the unit rename was used
actionContext.update()
actionContext.overviewScreen.select(EmpireOverviewCategories.Units, selectKey) // actionContext.select skips setting scrollY
}
)
}
promotionsTable.onClick(::onPromotionsTableClick)
return promotionsTable
}
private fun updatePromotionsTable(table: Table, unit: MapUnit, canEnable: Boolean) {
table.clearChildren()
// getPromotions goes by json order on demand - so this is the same sorting as on UnitTable,
// but not same as on PromotionPickerScreen (which e.g. tries to respect prerequisite proximity)
val promotions = unit.promotions.getPromotions(true)
val showPromoteStar = unit.promotions.canBePromoted()
if (promotions.any()) {
val iconCount = promotions.count() + (if (showPromoteStar) 1 else 0)
val numberOfLines = (iconCount - 1) / 8 + 1 // Int math: -1,/,+1 means divide rounding *up*
val promotionsPerLine = (iconCount - 1) / numberOfLines + 1
for (linePromotions in promotions.chunked(promotionsPerLine)) {
for (promotion in linePromotions) {
table.add(ImageGetter.getPromotionPortrait(promotion.name))
}
if (linePromotions.size == promotionsPerLine) table.row()
}
}
if (!showPromoteStar) return
table.add(
ImageGetter.getImage("OtherIcons/Star").apply {
color = if (canEnable && unit.currentMovement > 0f && unit.attacksThisTurn == 0)
Color.GOLDENROD
else Color.GOLDENROD.darken(0.25f)
}
).size(24f).padLeft(8f)
}
}

View File

@ -0,0 +1,81 @@
package com.unciv.ui.screens.overviewscreen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.logic.civilization.Civilization
import com.unciv.ui.components.extensions.addSeparator
import com.unciv.ui.components.extensions.center
import com.unciv.ui.components.extensions.darken
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.widgets.ExpanderTab
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
// This is a static factory to avoid making ExpanderTab open. UnitSupplyTable object used purely as namespace.
internal object UnitSupplyTable {
fun create(
overviewScreen: EmpireOverviewScreen,
unitOverviewTab: UnitOverviewTab,
viewingPlayer: Civilization,
supplyTableWidth: Float
): ExpanderTab {
val stats = viewingPlayer.stats
val deficit = stats.getUnitSupplyDeficit()
val icon = if (deficit <= 0) null else Group().apply {
isTransform = false
setSize(36f, 36f)
val image = ImageGetter.getImage("OtherIcons/ExclamationMark")
image.color = Color.FIREBRICK
image.setSize(36f, 36f)
image.center(this)
image.setOrigin(Align.center)
addActor(image)
}
return ExpanderTab(
title = "Unit Supply",
fontSize = Constants.defaultFontSize,
icon = icon,
startsOutOpened = deficit > 0,
defaultPad = 0f,
expanderWidth = supplyTableWidth,
onChange = {
overviewScreen.resizePage(unitOverviewTab)
}
) {
it.defaults().pad(5f).fill(false)
it.background = BaseScreen.skinStrings.getUiBackground(
"OverviewScreen/UnitOverviewTab/UnitSupplyTable",
tintColor = BaseScreen.skinStrings.skinConfig.baseColor.darken(0.6f)
)
it.addLabeledValue("Base Supply", stats.getBaseUnitSupply())
it.addLabeledValue("Cities", stats.getUnitSupplyFromCities())
it.addLabeledValue("Population", stats.getUnitSupplyFromPop())
it.addSeparator()
it.addLabeledValue("Total Supply", stats.getUnitSupply())
it.addLabeledValue("In Use", viewingPlayer.units.getCivUnitsSize())
it.addSeparator()
it.addLabeledValue("Supply Deficit", deficit)
it.addLabeledValue("Production Penalty", "${stats.getUnitSupplyProductionPenalty().toInt()}%")
if (deficit > 0) {
val penaltyLabel = "Increase your supply or reduce the amount of units to remove the production penalty"
.toLabel(Color.FIREBRICK)
penaltyLabel.wrap = true
it.add(penaltyLabel).colspan(2).left()
.width(supplyTableWidth).row()
}
}
}
// Here overloads are simpler than a generic:
private fun Table.addLabeledValue (label: String, value: Int) {
add(label.toLabel()).left()
add(value.toLabel()).right().row()
}
private fun Table.addLabeledValue (label: String, value: String) {
add(label.toLabel()).left()
add(value.toLabel()).right().row()
}
}

View File

@ -16,6 +16,7 @@ import com.unciv.models.translations.tr
import com.unciv.ui.audio.SoundPlayer
import com.unciv.ui.components.extensions.isEnabled
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.KeyboardBinding
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.input.onActivation
@ -26,11 +27,20 @@ import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.basescreen.RecreateOnResize
import kotlin.math.abs
class PromotionPickerScreen(
class PromotionPickerScreen private constructor(
val unit: MapUnit,
private val closeOnPick: Boolean = true,
private val onChange: (() -> Unit)? = null
private val closeOnPick: Boolean,
private val originalName: String?,
private val onChange: (() -> Unit)?
) : PickerScreen(), RecreateOnResize {
/** Show promotions organized by depencencies, allow picking new ones, allow unit rename
* @param unit The MapUnit to work with
* @param closeOnPick Should picking a new promotion close the screen?
* @param onChange Optional callback called when a promotion is picked or during close if the name was changed
*/
constructor(unit: MapUnit, closeOnPick: Boolean = true, onChange: (() -> Unit)? = null)
: this(unit, closeOnPick, unit.instanceName, onChange)
// Style stuff
private val colors = skin[PromotionScreenColors::class.java]
private val promotedLabelStyle = Label.LabelStyle(skin[Label.LabelStyle::class.java]).apply {
@ -56,7 +66,12 @@ class PromotionPickerScreen(
init {
setDefaultCloseAction()
closeButton.onActivation {
if (unit.instanceName != originalName)
onChange?.invoke()
game.popScreen()
}
closeButton.keyShortcuts.add(KeyCharAndCode.BACK)
if (canPromoteNow) {
rightSideButton.setText("Pick promotion".tr())
@ -339,7 +354,7 @@ class PromotionPickerScreen(
override fun recreate() = recreate(closeOnPick)
fun recreate(closeOnPick: Boolean): BaseScreen {
val newScreen = PromotionPickerScreen(unit, closeOnPick, onChange)
val newScreen = PromotionPickerScreen(unit, closeOnPick, originalName, onChange)
newScreen.setScrollY(scrollPane.scrollY)
return newScreen
}