From c45d3ecb7c3d441cd11a81aebf933cf3684e95f1 Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Wed, 28 Jun 2023 09:49:35 +0200 Subject: [PATCH] Promotion picker allowing picking chains in one go (#9655) * Try to allow chained promotion picking * Reorg PromotionPickerScreen into own package * Draft for new PromotionTree * Change PromotionPickerScreen to use new tree (picking still not done) * Finish new PromotionPickerScreen - code * Finish new PromotionPickerScreen - assets and linting * Finish new PromotionPickerScreen - polish positioning and lines * Finish new PromotionPickerScreen - fix sound * Finish new PromotionPickerScreen - little optimization * Finish new PromotionPickerScreen - emphasize line along path * Finish new PromotionPickerScreen - merge fix * Finish new PromotionPickerScreen - address comments * Finish new PromotionPickerScreen - fix sort and update wiki --- android/assets/Skin.json | 67 +++ .../jsons/translations/German.properties | 1 + .../jsons/translations/template.properties | 1 + .../unciv/logic/map/mapunit/UnitPromotions.kt | 3 + core/src/com/unciv/models/ruleset/Ruleset.kt | 35 +- .../unciv/models/ruleset/unit/Promotion.kt | 36 +- core/src/com/unciv/ui/images/Portrait.kt | 16 +- .../screens/overviewscreen/UnitOverviewTab.kt | 27 +- .../screens/pickerscreens/PromotionButton.kt | 54 ++ .../pickerscreens/PromotionPickerScreen.kt | 502 ++++++------------ .../pickerscreens/PromotionScreenColors.kt | 20 + .../ui/screens/pickerscreens/PromotionTree.kt | 183 +++++++ .../screens/pickerscreens/UnitRenamePopup.kt | 1 - docs/Other/Unit-related-JSON-files.md | 18 +- 14 files changed, 564 insertions(+), 400 deletions(-) create mode 100644 core/src/com/unciv/ui/screens/pickerscreens/PromotionButton.kt create mode 100644 core/src/com/unciv/ui/screens/pickerscreens/PromotionScreenColors.kt create mode 100644 core/src/com/unciv/ui/screens/pickerscreens/PromotionTree.kt diff --git a/android/assets/Skin.json b/android/assets/Skin.json index fc7dfb4b3a..d98e334f5b 100644 --- a/android/assets/Skin.json +++ b/android/assets/Skin.json @@ -82,6 +82,60 @@ "g": 0.0627451, "b": 0.039215688, "a": 1 + }, + "promotion-default": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "promotion-selected": { + "r": 0.2824, + "g": 0.5765, + "b": 0.6863, + "a": 1 + }, + "promotion-path": { + "r": 0.1882, + "g": 0.3843, + "b": 0.4575, + "a": 1 + }, + "promotion-promoted": { + "r": 0.8, + "g": 0.6745, + "b": 0, + "a": 1 + }, + "promotion-promoted-text": { + "r": 0.16, + "g": 0.1349, + "b": 0, + "a": 1 + }, + "promotion-pickable": { + "r": 0.1098, + "g": 0.3137, + "b": 0, + "a": 1 + }, + "promotion-prerequisite": { + "r": 0.405, + "g": 0.506, + "b": 0.81, + "a": 1 + }, + "promotion-grouplines": { + "r": 1, + "g": 1, + "b": 1, + "a": 1 + }, + "promotion-otherlines": { + "r": 1, + "g": 0.7, + "b": 0, + "a": 0 } }, "com.badlogic.gdx.scenes.scene2d.ui.Skin$TintedDrawable": { @@ -319,5 +373,18 @@ "cursor": "white", "selection": "selection" } + }, + "com.unciv.ui.screens.pickerscreens.PromotionScreenColors": { + "default": { + "default": "promotion-default", + "selected": "promotion-selected", + "pathToSelection": "promotion-path", + "promoted": "promotion-promoted", + "promotedText": "promotion-promoted-text", + "pickable": "promotion-pickable", + "prerequisite": "promotion-prerequisite", + "groupLines": "promotion-grouplines", + "otherLines": "promotion-otherlines" + } } } diff --git a/android/assets/jsons/translations/German.properties b/android/assets/jsons/translations/German.properties index 291d0fbf48..1b651e7c14 100644 --- a/android/assets/jsons/translations/German.properties +++ b/android/assets/jsons/translations/German.properties @@ -1685,6 +1685,7 @@ Dogfighting III = Kurvenkampf III Choose name for [unitName] = Wähle Namen für [unitName] [unitFilter] units gain the [promotion] promotion = [unitFilter] Einheiten erhalten die [promotion] Beförderung Requires = Benötigt +Path to [promotion] is ambiguous = Der Weg zu [promotion] ist noch nicht klar # Multiplayer Turn Checker Service diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 5ae6d53675..5b62a4c6d8 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -1684,6 +1684,7 @@ Dogfighting III = Choose name for [unitName] = [unitFilter] units gain the [promotion] promotion = Requires = +Path to [promotion] is ambiguous = # Multiplayer Turn Checker Service diff --git a/core/src/com/unciv/logic/map/mapunit/UnitPromotions.kt b/core/src/com/unciv/logic/map/mapunit/UnitPromotions.kt index 6fb14ddb8e..64130c719d 100644 --- a/core/src/com/unciv/logic/map/mapunit/UnitPromotions.kt +++ b/core/src/com/unciv/logic/map/mapunit/UnitPromotions.kt @@ -51,6 +51,9 @@ class UnitPromotions : IsPartOfGameInfoSerialization { /** @return the XP points needed to "buy" the next promotion. 10, 30, 60, 100, 150,... */ fun xpForNextPromotion() = (numberOfPromotions + 1) * 10 + /** @return the XP points needed to "buy" the next [count] promotions. */ + fun xpForNextNPromotions(count: Int) = (1..count).sumOf { (numberOfPromotions + it) * 10 } + /** @return Total XP including that already "spent" on promotions */ fun totalXpProduced() = XP + (numberOfPromotions * (numberOfPromotions + 1)) * 5 diff --git a/core/src/com/unciv/models/ruleset/Ruleset.kt b/core/src/com/unciv/models/ruleset/Ruleset.kt index 58bc756233..833e2e8a7f 100644 --- a/core/src/com/unciv/models/ruleset/Ruleset.kt +++ b/core/src/com/unciv/models/ruleset/Ruleset.kt @@ -162,7 +162,7 @@ class Ruleset { cityStateTypes.putAll(ruleset.cityStateTypes) ruleset.modOptions.unitsToRemove .flatMap { unitToRemove -> - units.filter { it.apply { value.ruleset=this@Ruleset }.value.matchesFilter(unitToRemove) }.keys + units.filter { it.apply { value.ruleset = this@Ruleset }.value.matchesFilter(unitToRemove) }.keys }.toSet().forEach { units.remove(it) } @@ -170,30 +170,7 @@ class Ruleset { modOptions.uniques.addAll(ruleset.modOptions.uniques) modOptions.constants.merge(ruleset.modOptions.constants) - // Allow each mod to define their own columns, and if there's a conflict, later mods will be shifted right - // We should never be editing the original ruleset objects, only copies - val addRulesetUnitPromotionClones = ruleset.unitPromotions.values.map { it.clone() } - val existingPromotionLocations = - unitPromotions.values.map { "${it.row}/${it.column}" }.toHashSet() - val promotionsWithConflictingLocations = addRulesetUnitPromotionClones.filter { - existingPromotionLocations.contains("${it.row}/${it.column}") - } - val columnsWithConflictingLocations = - promotionsWithConflictingLocations.map { it.column }.distinct() - - if (columnsWithConflictingLocations.isNotEmpty()) { - var highestExistingColumn = unitPromotions.values.maxOf { it.column } - for (conflictingColumn in columnsWithConflictingLocations) { - highestExistingColumn += 1 - val newColumn = highestExistingColumn - for (promotion in addRulesetUnitPromotionClones) - if (promotion.column == conflictingColumn) - promotion.column = newColumn - } - } - val finalModUnitPromotionsMap = addRulesetUnitPromotionClones.associateBy { it.name } - - unitPromotions.putAll(finalModUnitPromotionsMap) + unitPromotions.putAll(ruleset.unitPromotions) mods += ruleset.mods } @@ -312,14 +289,6 @@ class Ruleset { val promotionsFile = folderHandle.child("UnitPromotions.json") if (promotionsFile.exists()) unitPromotions += createHashmap(json().fromJsonFile(Array::class.java, promotionsFile)) - var topRow = unitPromotions.values.filter { it.column == 0 }.maxOfOrNull { it.row } ?: -1 - for (promotion in unitPromotions.values) - if (promotion.row == -1){ - promotion.column = 0 - topRow += 1 - promotion.row = topRow - } - val questsFile = folderHandle.child("Quests.json") if (questsFile.exists()) quests += createHashmap(json().fromJsonFile(Array::class.java, questsFile)) diff --git a/core/src/com/unciv/models/ruleset/unit/Promotion.kt b/core/src/com/unciv/models/ruleset/unit/Promotion.kt index 3a4850436d..55234f206b 100644 --- a/core/src/com/unciv/models/ruleset/unit/Promotion.kt +++ b/core/src/com/unciv/models/ruleset/unit/Promotion.kt @@ -7,18 +7,23 @@ import com.unciv.models.ruleset.unique.UniqueTarget import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.translations.tr import com.unciv.ui.screens.civilopediascreen.FormattedLine - +import com.unciv.ui.screens.pickerscreens.PromotionPickerScreen class Promotion : RulesetObject() { var prerequisites = listOf() var unitTypes = listOf() // The json parser wouldn't agree to deserialize this as a list of UnitTypes. =( - /** Row of -1 determines that the modder has not set a position */ + /** Used as **column** hint in the current [PromotionPickerScreen] + * This is no longer a direct position, it is used to sort before an automatic distribution. + * -1 determines that the modder has not set a position */ var row = -1 + /** Used as **row** hint in the current [PromotionPickerScreen] + * This is no longer a direct position, it is used to sort before an automatic distribution. + */ var column = 0 - fun clone():Promotion { + fun clone(): Promotion { val newPromotion = Promotion() // RulesetObject fields @@ -36,7 +41,7 @@ class Promotion : RulesetObject() { override fun getUniqueTarget() = UniqueTarget.Promotion - /** Used to describe a Promotion on the PromotionPickerScreen */ + /** Used to describe a Promotion on the PromotionPickerScreen - fully translated */ fun getDescription(promotionsForUnitType: Collection):String { val textList = ArrayList() @@ -146,4 +151,27 @@ class Promotion : RulesetObject() { return textList } + + companion object { + data class PromotionBaseNameAndLevel( + val nameWithoutBrackets: String, + val level: Int, + val basePromotionName: String + ) + /** Split a promotion name into base and level, e.g. "Drill II" -> 2 to "Drill" + * + * Used by Portrait (where it only has the string, the Promotion object is forgotten) and + * PromotionPickerScreen. Here to allow clear "Promotion.getBaseNameAndLevel" signature. + */ + fun getBaseNameAndLevel(promotionName: String): PromotionBaseNameAndLevel { + val nameWithoutBrackets = promotionName.replace("[", "").replace("]", "") + val level = when { + nameWithoutBrackets.endsWith(" I") -> 1 + nameWithoutBrackets.endsWith(" II") -> 2 + nameWithoutBrackets.endsWith(" III") -> 3 + else -> 0 + } + return PromotionBaseNameAndLevel(nameWithoutBrackets, level, nameWithoutBrackets.dropLast(if (level == 0) 0 else level + 1)) + } + } } diff --git a/core/src/com/unciv/ui/images/Portrait.kt b/core/src/com/unciv/ui/images/Portrait.kt index 0eba9d9fb0..cdf84ee63f 100644 --- a/core/src/com/unciv/ui/images/Portrait.kt +++ b/core/src/com/unciv/ui/images/Portrait.kt @@ -6,6 +6,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Image import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Align import com.unciv.models.ruleset.Ruleset +import com.unciv.models.ruleset.unit.Promotion import com.unciv.models.stats.Stats import com.unciv.ui.components.extensions.center import com.unciv.ui.components.extensions.centerX @@ -272,25 +273,16 @@ class PortraitPromotion(name: String, size: Float) : Portrait(Type.Promotion, na } override fun getDefaultImage(): Image { + val (nameWithoutBrackets, level, basePromotionName) = Promotion.getBaseNameAndLevel(imageName) - val nameWithoutBrackets = imageName.replace("[", "").replace("]", "") - - level = when { - nameWithoutBrackets.endsWith(" I") -> 1 - nameWithoutBrackets.endsWith(" II") -> 2 - nameWithoutBrackets.endsWith(" III") -> 3 - else -> 0 - } - - val basePromotionName = nameWithoutBrackets.dropLast(if (level == 0) 0 else level + 1) - + this.level = level val pathWithoutBrackets = "UnitPromotionIcons/$nameWithoutBrackets" val pathBase = "UnitPromotionIcons/$basePromotionName" val pathUnit = "UnitIcons/${basePromotionName.removeSuffix(" ability")}" return when { ImageGetter.imageExists(pathWithoutBrackets) -> { - level = 0 + this.level = 0 ImageGetter.getImage(pathWithoutBrackets) } ImageGetter.imageExists(pathBase) -> ImageGetter.getImage(pathBase) diff --git a/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTab.kt b/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTab.kt index 0c3a993c4e..fc4e907c2e 100644 --- a/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTab.kt +++ b/core/src/com/unciv/ui/screens/overviewscreen/UnitOverviewTab.kt @@ -6,6 +6,8 @@ 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.Cell +import com.badlogic.gdx.scenes.scene2d.ui.Image import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Align import com.unciv.Constants @@ -24,10 +26,10 @@ 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.input.onClick 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.input.onClick import com.unciv.ui.images.IconTextButton import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.basescreen.BaseScreen @@ -258,17 +260,22 @@ class UnitOverviewTab( } } - if (unit.promotions.canBePromoted()) - promotionsTable.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) + val canPromoteCell: Cell? = + if (unit.promotions.canBePromoted()) + promotionsTable.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) + else null promotionsTable.onClick { if (unit.promotions.canBePromoted() || unit.promotions.promotions.isNotEmpty()) { - game.pushScreen(PromotionPickerScreen(unit)) + game.pushScreen(PromotionPickerScreen(unit) { + if (canPromoteCell != null && !unit.promotions.canBePromoted()) + canPromoteCell.size(0f).pad(0f).setActor(null) + }) } } add(promotionsTable) diff --git a/core/src/com/unciv/ui/screens/pickerscreens/PromotionButton.kt b/core/src/com/unciv/ui/screens/pickerscreens/PromotionButton.kt new file mode 100644 index 0000000000..68899da43f --- /dev/null +++ b/core/src/com/unciv/ui/screens/pickerscreens/PromotionButton.kt @@ -0,0 +1,54 @@ +package com.unciv.ui.screens.pickerscreens + +import com.badlogic.gdx.scenes.scene2d.Touchable +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.utils.Align +import com.unciv.models.ruleset.unit.Promotion +import com.unciv.ui.components.BorderedTable +import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.screens.basescreen.BaseScreen + +internal class PromotionButton( + val node: PromotionTree.PromotionNode, + val isPickable: Boolean, + private val adoptedLabelStyle: Label.LabelStyle, + maxWidth: Float +) : BorderedTable( + path="PromotionScreen/PromotionButton", + defaultBgShape = BaseScreen.skinStrings.roundedEdgeRectangleMidShape, + defaultBgBorder = BaseScreen.skinStrings.roundedEdgeRectangleMidBorderShape +) { + private val label = node.promotion.name.toLabel(hideIcons = true) + private val defaultLabelStyle = label.style + private val colors = BaseScreen.skin[PromotionScreenColors::class.java] + + init { + + touchable = Touchable.enabled + borderSize = 5f + + pad(5f) + align(Align.left) + add(ImageGetter.getPromotionPortrait(node.promotion.name)).padRight(10f) + label.setEllipsis(true) + add(label).left().maxWidth(maxWidth) + + updateColor(false, emptySet(), emptySet()) + } + + fun updateColor(isSelected: Boolean, pathToSelection: Set, prerequisites: Set) { + bgColor = when { + isSelected -> colors.selected + node.isAdopted -> colors.promoted + node.promotion in pathToSelection -> colors.pathToSelection + node in prerequisites -> colors.prerequisite + isPickable -> colors.pickable + else -> colors.default + } + + label.style = if (!isSelected && node.isAdopted) adoptedLabelStyle + else defaultLabelStyle + } + +} diff --git a/core/src/com/unciv/ui/screens/pickerscreens/PromotionPickerScreen.kt b/core/src/com/unciv/ui/screens/pickerscreens/PromotionPickerScreen.kt index b61897919e..ba068eb0f2 100644 --- a/core/src/com/unciv/ui/screens/pickerscreens/PromotionPickerScreen.kt +++ b/core/src/com/unciv/ui/screens/pickerscreens/PromotionPickerScreen.kt @@ -3,142 +3,41 @@ package com.unciv.ui.screens.pickerscreens import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.math.Vector2 import com.badlogic.gdx.scenes.scene2d.Actor -import com.badlogic.gdx.scenes.scene2d.Touchable import com.badlogic.gdx.scenes.scene2d.ui.Cell 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.utils.Align import com.unciv.GUI import com.unciv.logic.map.mapunit.MapUnit import com.unciv.models.TutorialTrigger import com.unciv.models.UncivSound import com.unciv.models.ruleset.unit.Promotion import com.unciv.models.translations.tr -import com.unciv.ui.components.BorderedTable -import com.unciv.ui.components.extensions.colorFromRGB -import com.unciv.ui.components.extensions.darken +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.onClick import com.unciv.ui.components.input.onDoubleClick -import com.unciv.ui.components.extensions.setFontColor -import com.unciv.ui.components.extensions.toLabel -import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.RecreateOnResize -import java.lang.Integer.max +import com.unciv.utils.Concurrency +import kotlinx.coroutines.delay import kotlin.math.abs -class PromotionNode(val promotion: Promotion) { - var maxDepth = 0 - - /** How many level this promotion has */ - var levels = 1 - - val successors: ArrayList = ArrayList() - val predecessors: ArrayList = ArrayList() - - val baseName = getBasePromotionName() - - fun isRoot() : Boolean { - return predecessors.isEmpty() - } - - fun calculateDepth(excludeNodes: ArrayList, currentDepth: Int) { - maxDepth = max(maxDepth, currentDepth) - excludeNodes.add(this) - successors.filter { !excludeNodes.contains(it) }.forEach { it.calculateDepth(excludeNodes,currentDepth+1) } - } - - private fun getBasePromotionName(): String { - val nameWithoutBrackets = promotion.name.replace("[", "").replace("]", "") - val level = when { - nameWithoutBrackets.endsWith(" I") -> 1 - nameWithoutBrackets.endsWith(" II") -> 2 - nameWithoutBrackets.endsWith(" III") -> 3 - else -> 0 - } - return nameWithoutBrackets.dropLast(if (level == 0) 0 else level + 1) - } - - class CustomComparator( - private val baseNode: PromotionNode - ) : Comparator { - override fun compare(a: PromotionNode, b: PromotionNode): Int { - val baseName = baseNode.baseName - val aName = a.baseName - val bName = b.baseName - return when (aName) { - baseName -> -1 - bName -> 0 - else -> 1 - } - } - } - -} - -private class PromotionButton( - val node: PromotionNode, - val isPickable: Boolean = true, - val isPromoted: Boolean = false -) : BorderedTable( - path="PromotionScreen/PromotionButton", - defaultBgShape = BaseScreen.skinStrings.roundedEdgeRectangleMidShape, - defaultBgBorder = BaseScreen.skinStrings.roundedEdgeRectangleMidBorderShape -) { - - var isSelected = false - val label = node.promotion.name.toLabel(hideIcons = true).apply { - wrap = false - setAlignment(Align.left) - setEllipsis(true) - } - - init { - - touchable = Touchable.enabled - borderSize = 5f - - pad(5f) - align(Align.left) - add(ImageGetter.getPromotionPortrait(node.promotion.name)).padRight(10f) - add(label).left().maxWidth(130f) - - updateColor() - } - - fun updateColor() { - - val color = when { - isSelected -> PromotionPickerScreen.Selected - isPickable -> PromotionPickerScreen.Pickable - isPromoted -> PromotionPickerScreen.Promoted - else -> PromotionPickerScreen.Default - } - - bgColor = color - - val textColor = when { - isSelected -> Color.WHITE - isPromoted -> PromotionPickerScreen.Promoted.cpy().darken(0.8f) - else -> Color.WHITE - } - label.setFontColor(textColor) - } - -} - -class PromotionPickerScreen(val unit: MapUnit) : PickerScreen(), RecreateOnResize { - - companion object Colors { - val Default: Color = Color.BLACK - val Selected: Color = colorFromRGB(72, 147, 175) - val Promoted: Color = colorFromRGB(255, 215, 0).darken(0.2f) - val Pickable: Color = colorFromRGB(28, 80, 0) - val Prerequisite: Color = colorFromRGB(14, 92, 86) +class PromotionPickerScreen( + val unit: MapUnit, + private val onChange: (() -> Unit)? = null +) : PickerScreen(), RecreateOnResize { + // Style stuff + private val colors = skin[PromotionScreenColors::class.java] + private val promotedLabelStyle = Label.LabelStyle(skin[Label.LabelStyle::class.java]).apply { + fontColor = colors.promotedText } + private val buttonCellMaxWidth: Float + private val buttonCellMinWidth: Float + // Widgets private val promotionsTable = Table() private val promotionToButton = LinkedHashMap() private var selectedPromotion: PromotionButton? = null @@ -150,138 +49,88 @@ class PromotionPickerScreen(val unit: MapUnit) : PickerScreen(), RecreateOnResiz private val canPromoteNow = canChangeState && canBePromoted && unit.currentMovement > 0 && unit.attacksThisTurn == 0 + // Logic + private val tree = PromotionTree(unit) + + init { setDefaultCloseAction() if (canPromoteNow) { rightSideButton.setText("Pick promotion".tr()) - rightSideButton.onClick(UncivSound.Promote) { - if (selectedPromotion?.isPickable == true) - acceptPromotion(selectedPromotion?.node) + rightSideButton.onClick(UncivSound.Silent) { + acceptPromotion(selectedPromotion) } } else { rightSideButton.isVisible = false } - descriptionLabel.setText(updateDescriptionLabel()) + updateDescriptionLabel() - val availablePromotionsGroup = Table() - availablePromotionsGroup.defaults().pad(5f) - - val unitType = unit.type - val promotionsForUnitType = unit.civ.gameInfo.ruleset.unitPromotions.values.filter { - it.unitTypes.contains(unitType.name) || unit.promotions.promotions.contains(it.name) - } - //Always allow the user to rename the unit as many times as they like. - val renameButton = "Choose name for [${unit.name}]".toTextButton() - renameButton.isEnabled = true - - renameButton.onClick { - if (!canChangeState) return@onClick - UnitRenamePopup( - screen = this, - unit = unit, - actionOnClose = { - game.replaceCurrentScreen(PromotionPickerScreen(unit)) + if (canChangeState) { + //Always allow the user to rename the unit as many times as they like. + val renameButton = "Choose name for [${unit.name}]".toTextButton() + renameButton.onClick { + UnitRenamePopup(this, unit) { + game.replaceCurrentScreen(recreate()) } - ) + } + topTable.add(renameButton).pad(5f).row() } - availablePromotionsGroup.add(renameButton) - topTable.add(availablePromotionsGroup).row() - fillTable(promotionsForUnitType) + // Create all buttons without placing them yet, measure + buttonCellMaxWidth = ((stage.width - 80f) / tree.getMaxColumns()) + .coerceIn(190f, 300f) + for (node in tree.allNodes()) + promotionToButton[node.promotion.name] = getButton(tree, node) + buttonCellMinWidth = (promotionToButton.values.maxOfOrNull { it.prefWidth + 10f } ?: 0f) + .coerceIn(190f, buttonCellMaxWidth) + + fillTable() displayTutorial(TutorialTrigger.Experience) } - private fun acceptPromotion(node: PromotionNode?) { + private fun acceptPromotion(button: PromotionButton?) { // if user managed to click disabled button, still do nothing - if (node == null) return + if (button == null || !button.isPickable) return - unit.promotions.addPromotion(node.promotion.name) - game.replaceCurrentScreen(recreate()) - } - - private fun fillTable(promotions: Collection) { - val map = LinkedHashMap() - - val availablePromotions = unit.promotions.getAvailablePromotions() - - // Create nodes - // Pass 1 - create nodes for all promotions - for (promotion in promotions) - map[promotion.name] = PromotionNode(promotion) - - // Pass 2 - remove nodes which are unreachable (dependent only on absent promotions) - for (promotion in promotions) { - if (promotion.prerequisites.isNotEmpty()) { - val isReachable = promotion.prerequisites.any { map.containsKey(it) } - if (!isReachable) - map.remove(promotion.name) - } - } - - // Pass 3 - fill nodes successors/predecessors, based on promotions prerequisites - for (node in map.values) { - for (prerequisiteName in node.promotion.prerequisites) { - val prerequisiteNode = map[prerequisiteName] - if (prerequisiteNode != null) { - node.predecessors.add(prerequisiteNode) - prerequisiteNode.successors.add(node) - // Prerequisite has the same base name -> +1 more level - if (prerequisiteNode.baseName == node.baseName) - prerequisiteNode.levels += 1 + // Can't use stage.addAction as the screen is going to die immediately + val path = tree.getPathTo(button.node.promotion) + if (path.size == 1) { + Concurrency.runOnGLThread { SoundPlayer.play(UncivSound.Promote) } + } else { + Concurrency.runOnGLThread { + SoundPlayer.play(UncivSound.Promote) + Concurrency.run { + delay(200) + Concurrency.runOnGLThread { SoundPlayer.play(UncivSound.Promote) } } } } - // Traverse each root node tree and calculate max possible depths of each node - for (node in map.values) { - if (node.isRoot()) - node.calculateDepth(arrayListOf(node), 0) - } + for (promotion in path) + unit.promotions.addPromotion(promotion.name) - // For each non-root node remove all predecessors except the one with the least max depth. - // This is needed to compactify trees and remove circular dependencies (A -> B -> C -> A) - for (node in map.values) { - if (node.isRoot()) - continue + onChange?.invoke() + game.replaceCurrentScreen(recreate()) + } - // Choose best predecessor - the one with less depth - var best: PromotionNode? = null - for (predecessor in node.predecessors) { - if (best == null || predecessor.maxDepth < best.maxDepth) - best = predecessor - } - - // Remove everything else, leave only best - for (predecessor in node.predecessors) - predecessor.successors.remove(node) - node.predecessors.clear() - node.predecessors.add(best!!) - best.successors.add(node) - } - - // Sort nodes successors so promotions with same base name go first - for (node in map.values) { - node.successors.sortWith(PromotionNode.CustomComparator(node)) - } + private fun fillTable() { + val placedButtons = mutableSetOf() // Create cell matrix - val maxColumns = map.size + 1 - val maxRows = map.size + 1 - - val cellMatrix = ArrayList>>() - for (y in 0..maxRows) { - cellMatrix.add(ArrayList()) - for (x in 0..maxColumns) { - val cell = promotionsTable.add() - cellMatrix[y].add(cell) + val maxColumns = tree.getMaxColumns() + val maxRows = tree.getMaxRows() + val cellMatrix = Array(maxRows + 1) { + Array(maxColumns + 1) { + promotionsTable.add() as Cell + }.also { + promotionsTable.row() } - promotionsTable.row() } - /** Check whether cell is inhabited by actor already */ + /** Check whether a horizontal range of cells is inhabited by any actor already */ fun isTherePlace(row: Int, col: Int, levels: Int) : Boolean { for (i in 0 until levels) { if (cellMatrix[row][col+i].actor != null) @@ -290,24 +139,17 @@ class PromotionPickerScreen(val unit: MapUnit) : PickerScreen(), RecreateOnResiz return true } - /** Recursively place buttons for node and it's successors into free cells */ - fun placeButton(col: Int, row: Int, node: PromotionNode) : Int { + /** Recursively place buttons for node and its successors into free cells */ + fun placeButton(col: Int, row: Int, node: PromotionTree.PromotionNode) : Int { val name = node.promotion.name // If promotion button not yet placed - if (promotionToButton[name] == null) { + if (name !in placedButtons) { // If place is free - we place button if (isTherePlace(row, col, node.levels)) { - val cell = cellMatrix[row][col] - val isPromotionAvailable = node.promotion in availablePromotions - val hasPromotion = unit.promotions.promotions.contains(name) - val isPickable = canPromoteNow && isPromotionAvailable && !hasPromotion - val button = getButton(promotions, node, isPickable, hasPromotion) - promotionToButton[name] = button - cell.setActor(button) - cell.pad(5f) - cell.padRight(20f) - cell.minWidth(190f) - cell.maxWidth(190f) + cellMatrix[row][col].setActor(promotionToButton[name]) + .pad(5f).padRight(20f) + .minWidth(buttonCellMinWidth).maxWidth(buttonCellMaxWidth) + placedButtons += name } // If place is not free - try to find another in the next row else { @@ -315,79 +157,82 @@ class PromotionPickerScreen(val unit: MapUnit) : PickerScreen(), RecreateOnResiz } } - // Filter successors who haven't been placed yet (to avoid circular dependencies) + // Filter children who haven't been placed yet (to avoid circular dependencies) // and try to place them in the next column. + // Note this materializes all intermediaries as Lists, but they're small + // Also note having placeButton with nointrivial side effecths in a chain isn't good practice, + // But the alternative is coding the max manually. // Return the max row this whole tree ever reached. - return node.successors.filter { - !promotionToButton.containsKey(it.promotion.name) - }.map { - placeButton(col+1, row, it) - }.maxOfOrNull { it }?: row + return node.children + .filter { it.promotion.name !in placedButtons } + .sortedBy { it.baseName != node.baseName } // Prioritize getting groups in a row - relying on sensible json "column" isn't enough + .maxOfOrNull { placeButton(col + 1, row, it) } + ?: row } // Build each tree starting from root nodes var row = 0 - for (node in map.values) { - if (node.isRoot()) { - row = placeButton(0, row, node) - // Each root tree should start from a completely empty row. - row += 1 - } + for (node in tree.allRoots()) { + row = placeButton(0, row, node) + // Each root tree should start from a completely empty row. + row += 1 } topTable.add(promotionsTable) - - addConnectingLines() - + addConnectingLines(emptySet()) } - private fun getButton(allPromotions: Collection, node: PromotionNode, - isPickable: Boolean = true, isPromoted: Boolean = false) : PromotionButton { + private fun getButton(tree: PromotionTree, node: PromotionTree.PromotionNode) : PromotionButton { + val isPickable = (!node.pathIsAmbiguous || node.distanceToAdopted == 1) && tree.canBuyUpTo(node.promotion) - val button = PromotionButton( - node = node, - isPromoted = isPromoted, - isPickable = isPickable - ) + val button = PromotionButton(node, isPickable, promotedLabelStyle, buttonCellMaxWidth - 60f) button.onClick { - selectedPromotion?.isSelected = false - selectedPromotion?.updateColor() selectedPromotion = button - button.isSelected = true - button.updateColor() + + val path = tree.getPathTo(button.node.promotion) + val pathAsSet = path.toSet() + val prerequisites = button.node.parents for (btn in promotionToButton.values) - btn.updateColor() - button.node.promotion.prerequisites.forEach { promotionToButton[it]?.apply { - if (!this.isPromoted) - bgColor = Prerequisite }} + btn.updateColor(btn == selectedPromotion, pathAsSet, prerequisites) rightSideButton.isEnabled = isPickable rightSideButton.setText(node.promotion.name.tr()) - descriptionLabel.setText(updateDescriptionLabel(node.promotion.getDescription(allPromotions))) + updateDescriptionLabel(isPickable, tree, node, path) - addConnectingLines() + addConnectingLines(pathAsSet) } if (isPickable) - button.onDoubleClick(UncivSound.Promote) { - acceptPromotion(node) + button.onDoubleClick(UncivSound.Silent) { + acceptPromotion(button) } return button } - private fun addConnectingLines() { + private fun addConnectingLines(path: Set) { promotionsTable.pack() scrollPane.updateVisualScroll() for (line in lines) line.remove() lines.clear() + fun addLine(x: Float, y: Float, width: Float, height: Float, color: Color) { + if (color.a == 0f) return + val line = ImageGetter.getWhiteDot() + line.setBounds(x, y, width, height) + line.color = color + promotionsTable.addActorAt(0, line) + lines.add(line) + } + for (button in promotionToButton.values) { - for (prerequisite in button.node.promotion.prerequisites) { + val currentNode = button.node + for (prerequisite in currentNode.promotion.prerequisites) { val prerequisiteButton = promotionToButton[prerequisite] ?: continue + val prerequisiteNode = prerequisiteButton.node var buttonCoords = Vector2(0f, button.height / 2) button.localToStageCoordinates(buttonCoords) @@ -397,15 +242,16 @@ class PromotionPickerScreen(val unit: MapUnit) : PickerScreen(), RecreateOnResiz prerequisiteButton.localToStageCoordinates(prerequisiteCoords) promotionsTable.stageToLocalCoordinates(prerequisiteCoords) + val isNodeInPath = currentNode.promotion in path + val isSelectionPath = isNodeInPath && + (prerequisiteNode.isAdopted || prerequisiteNode.promotion in path) val lineColor = when { - button.isSelected -> Selected - prerequisiteButton.node.baseName == button.node.baseName -> Color.WHITE.cpy() - else -> Color.CLEAR - } - val lineSize = when { - button.isSelected -> 4f - else -> 2f + isSelectionPath -> colors.selected + isNodeInPath -> colors.pathToSelection + prerequisiteNode.baseName == currentNode.baseName -> colors.groupLines + else -> colors.otherLines } + val lineSize = if (isSelectionPath) 4f else 2f if (buttonCoords.x < prerequisiteCoords.x) { val temp = buttonCoords.cpy() @@ -413,67 +259,48 @@ class PromotionPickerScreen(val unit: MapUnit) : PickerScreen(), RecreateOnResiz prerequisiteCoords = temp } - + val halfLineSize = lineSize / 2 if (buttonCoords.y != prerequisiteCoords.y) { val deltaX = buttonCoords.x - prerequisiteCoords.x val deltaY = buttonCoords.y - prerequisiteCoords.y - val halfLength = deltaX / 2f - - val line = ImageGetter.getWhiteDot().apply { - width = halfLength+lineSize/2 - height = lineSize - x = prerequisiteCoords.x - y = prerequisiteCoords.y - lineSize / 2 - } - val line1 = ImageGetter.getWhiteDot().apply { - width = halfLength + lineSize/2 - height = lineSize - x = buttonCoords.x - width - y = buttonCoords.y - lineSize / 2 - } - val line2 = ImageGetter.getWhiteDot().apply { - width = lineSize - height = abs(deltaY) - x = buttonCoords.x - halfLength - lineSize / 2 - y = buttonCoords.y + (if (deltaY > 0f) -height-lineSize/2 else lineSize/2) - } - - line.color = lineColor - line1.color = lineColor - line2.color = lineColor - - promotionsTable.addActor(line) - promotionsTable.addActor(line1) - promotionsTable.addActor(line2) - - line.toBack() - line1.toBack() - line2.toBack() - - lines.add(line) - lines.add(line1) - lines.add(line2) + val halfLength = deltaX / 2f + halfLineSize + addLine( + width = halfLength, + height = lineSize, + x = prerequisiteCoords.x, + y = prerequisiteCoords.y - halfLineSize, + color = lineColor + ) + addLine( + width = halfLength, + height = lineSize, + x = buttonCoords.x - halfLength, + y = buttonCoords.y - halfLineSize, + color = lineColor + ) + addLine( + width = lineSize, + height = abs(deltaY), + x = buttonCoords.x - halfLength, + y = buttonCoords.y + (if (deltaY > 0f) -deltaY - halfLineSize else halfLineSize), + color = lineColor + ) } else { - - val line = ImageGetter.getWhiteDot().apply { - width = buttonCoords.x - prerequisiteCoords.x - height = lineSize - x = prerequisiteCoords.x - y = prerequisiteCoords.y - lineSize / 2 - } - line.color = lineColor - promotionsTable.addActor(line) - line.toBack() - lines.add(line) - + addLine( + width = buttonCoords.x - prerequisiteCoords.x, + height = lineSize, + x = prerequisiteCoords.x, + y = prerequisiteCoords.y - halfLineSize, + color = lineColor + ) } } } for (line in lines) { - if (line.color == Selected) + if (line.color == colors.selected || line.color == colors.pathToSelection) line.zIndex = lines.size } } @@ -484,18 +311,29 @@ class PromotionPickerScreen(val unit: MapUnit) : PickerScreen(), RecreateOnResiz scrollPane.updateVisualScroll() } - private fun updateDescriptionLabel(): String { - return unit.displayName().tr() + private fun updateDescriptionLabel() { + descriptionLabel.setText(unit.displayName().tr()) } - private fun updateDescriptionLabel(promotionDescription: String): String { - var newDescriptionText = unit.displayName().tr() - newDescriptionText += "\n" + promotionDescription - return newDescriptionText + private fun updateDescriptionLabel( + isPickable: Boolean, + tree: PromotionTree, + node: PromotionTree.PromotionNode, + path: List + ) { + val isAmbiguous = node.pathIsAmbiguous && node.distanceToAdopted > 1 && tree.canBuyUpTo(node.promotion) + val topLine = unit.displayName().tr() + when { + node.isAdopted -> "" + isAmbiguous -> " - {Path to [${node.promotion.name}] is ambiguous}".tr() + !isPickable -> "" + else -> path.joinToString(" → ", ": ") { it.name.tr() } + } + val promotionText = node.promotion.getDescription(tree.possiblePromotions) + descriptionLabel.setText("$topLine\n$promotionText") } override fun recreate(): BaseScreen { - val newScreen = PromotionPickerScreen(unit) + val newScreen = PromotionPickerScreen(unit, onChange) newScreen.setScrollY(scrollPane.scrollY) return newScreen } diff --git a/core/src/com/unciv/ui/screens/pickerscreens/PromotionScreenColors.kt b/core/src/com/unciv/ui/screens/pickerscreens/PromotionScreenColors.kt new file mode 100644 index 0000000000..27b3ec0c17 --- /dev/null +++ b/core/src/com/unciv/ui/screens/pickerscreens/PromotionScreenColors.kt @@ -0,0 +1,20 @@ +package com.unciv.ui.screens.pickerscreens + +import com.badlogic.gdx.graphics.Color + + +/** Colours used on the [PromotionPickerScreen] + * + * These are backed by Skin.json + */ +class PromotionScreenColors { + val default: Color = Color.BLACK + val selected: Color = Color(0.2824f, 0.5765f, 0.6863f, 1f) // colorFromRGB(72, 147, 175) + val pathToSelection: Color = Color(0.1882f, 0.3843f, 0.4575f, 1f) // selected.darken(0.33f) + val promoted: Color = Color(0.8f, 0.6745f, 0f, 1f) // colorFromRGB(255, 215, 0).darken(0.2f) + val promotedText: Color = Color(0.16f, 0.1349f, 0f, 1f) // promoted.darken(0.8f) + val pickable: Color = Color(0.1098f, 0.3137f, 0f, 1f) // colorFromRGB(28, 80, 0) + val prerequisite: Color = Color(0.4f, 0.5f, 0.8f, 1f) // HSV(225,50,80): muted Royal + val groupLines: Color = Color.WHITE + val otherLines: Color = Color.CLEAR +} diff --git a/core/src/com/unciv/ui/screens/pickerscreens/PromotionTree.kt b/core/src/com/unciv/ui/screens/pickerscreens/PromotionTree.kt new file mode 100644 index 0000000000..f4ec7816da --- /dev/null +++ b/core/src/com/unciv/ui/screens/pickerscreens/PromotionTree.kt @@ -0,0 +1,183 @@ +package com.unciv.ui.screens.pickerscreens + +import com.unciv.GUI +import com.unciv.logic.map.mapunit.MapUnit +import com.unciv.models.ruleset.unique.StateForConditionals +import com.unciv.models.ruleset.unique.UniqueType +import com.unciv.models.ruleset.unit.Promotion +import com.unciv.models.translations.tr +import com.unciv.utils.Log + +internal class PromotionTree(val unit: MapUnit) { + /** Ordered set of Promotions to show - by Json column/row and translated name */ + // Not using SortedSet - that uses needlessly complex implementations that remember the comparator + val possiblePromotions: LinkedHashSet + /** Ordered map, key is the Promotion name, same order as [possiblePromotions] */ + private val nodes: LinkedHashMap + + class PromotionNode( + val promotion: Promotion, + val isAdopted: Boolean + ) { + /** How many prerequisite steps are needed to reach a [isRoot] promotion */ + var depth = Int.MIN_VALUE + /** How many unit-promoting steps are needed to reach this node */ + var distanceToAdopted = Int.MAX_VALUE + + /** The nodes for direct prerequisites of this one (unordered) + * Note this is not necessarily cover all prerequisites of the node's promotion - see [unreachable] */ + val parents = mutableSetOf() + /** Follow this to get an unambiguous path to a root */ + var preferredParent: PromotionNode? = null + /** All nodes having this one as direct prerequisite - must preserve order as UI uses it */ + val children = linkedSetOf() + + /** Off if there is only one "best" path of equal cost to adopt this node's promotion */ + var pathIsAmbiguous = false + /** On for promotions having unavailable prerequisites (missing in ruleset, or not allowed for the unit's + * UnitType, and not already adopted either); or currently disabled by a [UniqueType.OnlyAvailableWhen] unique. + * (should never be on with a vanilla ruleset) */ + var unreachable = false + + /** Name of this node's promotion with [level] suffixes removed, and [] brackets removed */ + val baseName: String + /** "Level" of this node's promotion (e.g. Drill I: 1, Drill III: 3 - 0 for promotions without such a suffix) */ + val level: Int + /** How many levels of this promotion there are below (including this), minimum 1 (Drill I: 3 / Drill III: 1) */ + var levels = 1 + + /** `true` if this node's promotion has no prerequisites */ + val isRoot get() = parents.isEmpty() + + override fun toString() = promotion.name + + init { + val splitName = Promotion.getBaseNameAndLevel(promotion.name) + this.level = splitName.level + this.baseName = splitName.basePromotionName + } + } + + init { + val collator = GUI.getSettings().getCollatorFromLocale() + val rulesetPromotions = unit.civ.gameInfo.ruleset.unitPromotions.values + val unitType = unit.baseUnit.unitType + val adoptedPromotions = unit.promotions.promotions + + // The following sort is mostly redundant with our vanilla rulesets. + // Still, want to make sure processing left to right, top to bottom will be usable. + possiblePromotions = rulesetPromotions.asSequence() + .filter { + unitType in it.unitTypes || it.name in adoptedPromotions + } + .sortedWith( + // Remember to make sure row=0/col=0 stays on top while those without explicit pos go to the end + // Also remember the names are historical, row means column on our current screen design. + compareBy { + if (it.row < 0) Int.MAX_VALUE + else if (it.row == 0) Int.MIN_VALUE + it.column + else it.column + } + .thenBy { it.row } + .thenBy(collator) { it.name.tr(hideIcons = true) } + ) + .toCollection(linkedSetOf()) + + // Create incomplete node objects + nodes = possiblePromotions.asSequence() + .map { it.name to PromotionNode(it, it.name in adoptedPromotions) } + .toMap(LinkedHashMap(possiblePromotions.size)) + + // Fill parent/child relations, ignoring prerequisites not in possiblePromotions + for (node in nodes.values) { + for (prerequisite in node.promotion.prerequisites) { + val parent = nodes[prerequisite] ?: continue + if (node in allChildren(parent)) { + Log.debug("Ignoring circular reference: %s requires %s", node, parent) + continue + } + node.parents += parent + parent.children += node + if (node.level > 0 && node.baseName == parent.baseName) + parent.levels++ + } + } + + // Determine unreachable / disabled nodes + val state = StateForConditionals(unit.civ, unit = unit, tile = unit.getTile()) + for (node in nodes.values) { + // defensive - I don't know how to provoke the situation, but if it ever occurs, disallow choosing that promotion + if (node.promotion.prerequisites.isNotEmpty() && node.parents.isEmpty()) + node.unreachable = true + if (node.promotion.getMatchingUniques(UniqueType.OnlyAvailableWhen, StateForConditionals.IgnoreConditionals) + .any { !it.conditionalsApply(state) }) + node.unreachable = true + } + + // Calculate depth and distanceToAdopted - nonrecursively, shallows first. + // Also determine preferredParent / pathIsAmbiguous by weighing distanceToAdopted + for (node in allRoots()) { + node.depth = 0 + node.distanceToAdopted = if (node.unreachable) Int.MAX_VALUE else if (node.isAdopted) 0 else 1 + } + for (depth in 0..99) { + var complete = true + for (node in nodes.values) { + if (node.depth == Int.MIN_VALUE) { + complete = false + continue + } + if (node.depth != depth) continue + for (child in node.children) { + val distance = if (node.distanceToAdopted == Int.MAX_VALUE) Int.MAX_VALUE + else if (child.isAdopted) 0 else node.distanceToAdopted + 1 + when { + child.depth == Int.MIN_VALUE -> Unit // "New" node / first reached + child.distanceToAdopted < distance -> continue // Already reached a better way + child.distanceToAdopted == distance -> { // Already reached same distance + child.pathIsAmbiguous = true + child.preferredParent = null + continue + } + // else: Already reached, but a worse way - overwrite fully + } + child.depth = depth + 1 + child.distanceToAdopted = distance + child.pathIsAmbiguous = node.pathIsAmbiguous + child.preferredParent = node.takeUnless { node.pathIsAmbiguous } + } + } + if (complete) break + } + } + + fun allNodes() = nodes.values.asSequence() + fun allRoots() = allNodes().filter { it.isRoot } + private fun allChildren(node: PromotionNode): Sequence { + return sequenceOf(node) + node.children.flatMap { allChildren(it) } + } + + private fun getReachableNode(promotion: Promotion): PromotionNode? = + nodes[promotion.name]?.takeUnless { it.distanceToAdopted == Int.MAX_VALUE } + + fun canBuyUpTo(promotion: Promotion): Boolean = unit.promotions.run { + val node = getReachableNode(promotion) ?: return false + if (node.isAdopted) return false + return XP >= xpForNextNPromotions(node.distanceToAdopted) + } + + fun getPathTo(promotion: Promotion): List { + var node = getReachableNode(promotion) ?: return emptyList() + val result = mutableListOf(node.promotion) + while (true) { + node = node.preferredParent ?: break + if (node.isAdopted) break + result.add(node.promotion) + } + return result.asReversed() + } + + // These exist to allow future optimization - this is safe, but more than actually needed + fun getMaxRows() = nodes.size + fun getMaxColumns() = nodes.values.maxOf { it.promotion.row.coerceAtLeast(it.depth + 1) } +} diff --git a/core/src/com/unciv/ui/screens/pickerscreens/UnitRenamePopup.kt b/core/src/com/unciv/ui/screens/pickerscreens/UnitRenamePopup.kt index 40f8827a46..0b243e8420 100644 --- a/core/src/com/unciv/ui/screens/pickerscreens/UnitRenamePopup.kt +++ b/core/src/com/unciv/ui/screens/pickerscreens/UnitRenamePopup.kt @@ -24,4 +24,3 @@ class UnitRenamePopup(val screen: BaseScreen, val unit: MapUnit, val actionOnClo } } - diff --git a/docs/Other/Unit-related-JSON-files.md b/docs/Other/Unit-related-JSON-files.md index c31825161a..91964a0c48 100644 --- a/docs/Other/Unit-related-JSON-files.md +++ b/docs/Other/Unit-related-JSON-files.md @@ -44,14 +44,16 @@ Remember, promotions can be "bought" with XP, but also granted by the unit type, Each promotion can have the following properties: -| Attribute | Type | Optional | Notes | -| --------- | ---- | -------- | ----- | -| name | String | Required | See above for "I, II, III" progressions | -| prerequisites | List | Default empty | Prerequisite promotions | -| effect | String | Default empty | Deprecated, use uniques instead | -| unitTypes | List | Default empty | The unit types for which this promotion applies as specified in [UnitTypes.json](#unittypesjson) | -| uniques | List | Default empty | List of effects, [see here](../Modders/Unique-parameters.md#unit-uniques) | -| civilopediaText | List | Default empty | see [civilopediaText chapter](Miscellaneous-JSON-files.md#civilopedia-text) | +| Attribute | Type | Optional | Notes | +|-----------------|--------|---------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| name | String | Required | See above for "I, II, III" progressions | +| prerequisites | List | Default empty | Prerequisite promotions | +| effect | String | Default empty | Deprecated and ignored, use uniques instead | +| column | Int | Yes | Determines placement order on the promotion picker screen. Name is historical, these coordinates no longer control placement directly. Promotions without coordinates are ensured to be placed last. (…) | +| row | Int | Yes | … In base mods without any coordinates, promotions without prerequisites are sorted alphabetically and placed top down, the rest of the screen will structure the dependencies logically. If your mod has a "Heal instantly", it is suggested to use row=0 to place it on top. | +| unitTypes | List | Default empty | The unit types for which this promotion applies as specified in [UnitTypes.json](#unittypesjson) | +| uniques | List | Default empty | List of effects, [see here](../Modders/uniques.md#unit-uniques) | +| civilopediaText | List | Default empty | see [civilopediaText chapter](Miscellaneous-JSON-files.md#civilopedia-text) | ## UnitTypes.json