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
This commit is contained in:
SomeTroglodyte 2023-06-28 09:49:35 +02:00 committed by GitHub
parent d298f85099
commit c45d3ecb7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 564 additions and 400 deletions

View File

@ -82,6 +82,60 @@
"g": 0.0627451, "g": 0.0627451,
"b": 0.039215688, "b": 0.039215688,
"a": 1 "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": { "com.badlogic.gdx.scenes.scene2d.ui.Skin$TintedDrawable": {
@ -319,5 +373,18 @@
"cursor": "white", "cursor": "white",
"selection": "selection" "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"
}
} }
} }

View File

@ -1685,6 +1685,7 @@ Dogfighting III = Kurvenkampf III
Choose name for [unitName] = Wähle Namen für [unitName] Choose name for [unitName] = Wähle Namen für [unitName]
[unitFilter] units gain the [promotion] promotion = [unitFilter] Einheiten erhalten die [promotion] Beförderung [unitFilter] units gain the [promotion] promotion = [unitFilter] Einheiten erhalten die [promotion] Beförderung
Requires = Benötigt Requires = Benötigt
Path to [promotion] is ambiguous = Der Weg zu [promotion] ist noch nicht klar
# Multiplayer Turn Checker Service # Multiplayer Turn Checker Service

View File

@ -1684,6 +1684,7 @@ Dogfighting III =
Choose name for [unitName] = Choose name for [unitName] =
[unitFilter] units gain the [promotion] promotion = [unitFilter] units gain the [promotion] promotion =
Requires = Requires =
Path to [promotion] is ambiguous =
# Multiplayer Turn Checker Service # Multiplayer Turn Checker Service

View File

@ -51,6 +51,9 @@ class UnitPromotions : IsPartOfGameInfoSerialization {
/** @return the XP points needed to "buy" the next promotion. 10, 30, 60, 100, 150,... */ /** @return the XP points needed to "buy" the next promotion. 10, 30, 60, 100, 150,... */
fun xpForNextPromotion() = (numberOfPromotions + 1) * 10 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 */ /** @return Total XP including that already "spent" on promotions */
fun totalXpProduced() = XP + (numberOfPromotions * (numberOfPromotions + 1)) * 5 fun totalXpProduced() = XP + (numberOfPromotions * (numberOfPromotions + 1)) * 5

View File

@ -162,7 +162,7 @@ class Ruleset {
cityStateTypes.putAll(ruleset.cityStateTypes) cityStateTypes.putAll(ruleset.cityStateTypes)
ruleset.modOptions.unitsToRemove ruleset.modOptions.unitsToRemove
.flatMap { unitToRemove -> .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 { }.toSet().forEach {
units.remove(it) units.remove(it)
} }
@ -170,30 +170,7 @@ class Ruleset {
modOptions.uniques.addAll(ruleset.modOptions.uniques) modOptions.uniques.addAll(ruleset.modOptions.uniques)
modOptions.constants.merge(ruleset.modOptions.constants) 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 unitPromotions.putAll(ruleset.unitPromotions)
// 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)
mods += ruleset.mods mods += ruleset.mods
} }
@ -312,14 +289,6 @@ class Ruleset {
val promotionsFile = folderHandle.child("UnitPromotions.json") val promotionsFile = folderHandle.child("UnitPromotions.json")
if (promotionsFile.exists()) unitPromotions += createHashmap(json().fromJsonFile(Array<Promotion>::class.java, promotionsFile)) if (promotionsFile.exists()) unitPromotions += createHashmap(json().fromJsonFile(Array<Promotion>::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") val questsFile = folderHandle.child("Quests.json")
if (questsFile.exists()) quests += createHashmap(json().fromJsonFile(Array<Quest>::class.java, questsFile)) if (questsFile.exists()) quests += createHashmap(json().fromJsonFile(Array<Quest>::class.java, questsFile))

View File

@ -7,18 +7,23 @@ import com.unciv.models.ruleset.unique.UniqueTarget
import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.translations.tr import com.unciv.models.translations.tr
import com.unciv.ui.screens.civilopediascreen.FormattedLine import com.unciv.ui.screens.civilopediascreen.FormattedLine
import com.unciv.ui.screens.pickerscreens.PromotionPickerScreen
class Promotion : RulesetObject() { class Promotion : RulesetObject() {
var prerequisites = listOf<String>() var prerequisites = listOf<String>()
var unitTypes = listOf<String>() // The json parser wouldn't agree to deserialize this as a list of UnitTypes. =( var unitTypes = listOf<String>() // 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 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 var column = 0
fun clone():Promotion { fun clone(): Promotion {
val newPromotion = Promotion() val newPromotion = Promotion()
// RulesetObject fields // RulesetObject fields
@ -36,7 +41,7 @@ class Promotion : RulesetObject() {
override fun getUniqueTarget() = UniqueTarget.Promotion 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<Promotion>):String { fun getDescription(promotionsForUnitType: Collection<Promotion>):String {
val textList = ArrayList<String>() val textList = ArrayList<String>()
@ -146,4 +151,27 @@ class Promotion : RulesetObject() {
return textList 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))
}
}
} }

View File

@ -6,6 +6,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align import com.badlogic.gdx.utils.Align
import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.unit.Promotion
import com.unciv.models.stats.Stats import com.unciv.models.stats.Stats
import com.unciv.ui.components.extensions.center import com.unciv.ui.components.extensions.center
import com.unciv.ui.components.extensions.centerX 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 { override fun getDefaultImage(): Image {
val (nameWithoutBrackets, level, basePromotionName) = Promotion.getBaseNameAndLevel(imageName)
val nameWithoutBrackets = imageName.replace("[", "").replace("]", "") this.level = level
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)
val pathWithoutBrackets = "UnitPromotionIcons/$nameWithoutBrackets" val pathWithoutBrackets = "UnitPromotionIcons/$nameWithoutBrackets"
val pathBase = "UnitPromotionIcons/$basePromotionName" val pathBase = "UnitPromotionIcons/$basePromotionName"
val pathUnit = "UnitIcons/${basePromotionName.removeSuffix(" ability")}" val pathUnit = "UnitIcons/${basePromotionName.removeSuffix(" ability")}"
return when { return when {
ImageGetter.imageExists(pathWithoutBrackets) -> { ImageGetter.imageExists(pathWithoutBrackets) -> {
level = 0 this.level = 0
ImageGetter.getImage(pathWithoutBrackets) ImageGetter.getImage(pathWithoutBrackets)
} }
ImageGetter.imageExists(pathBase) -> ImageGetter.getImage(pathBase) ImageGetter.imageExists(pathBase) -> ImageGetter.getImage(pathBase)

View File

@ -6,6 +6,8 @@ import com.badlogic.gdx.scenes.scene2d.Action
import com.badlogic.gdx.scenes.scene2d.Actor import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Group import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.actions.Actions 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.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align import com.badlogic.gdx.utils.Align
import com.unciv.Constants 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.brighten
import com.unciv.ui.components.extensions.center import com.unciv.ui.components.extensions.center
import com.unciv.ui.components.extensions.darken 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.surroundWithCircle
import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toPrettyString 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.IconTextButton
import com.unciv.ui.images.ImageGetter import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.BaseScreen
@ -258,17 +260,22 @@ class UnitOverviewTab(
} }
} }
if (unit.promotions.canBePromoted()) val canPromoteCell: Cell<Image>? =
promotionsTable.add( if (unit.promotions.canBePromoted())
ImageGetter.getImage("OtherIcons/Star").apply { promotionsTable.add(
color = if (GUI.isAllowedChangeState() && unit.currentMovement > 0f && unit.attacksThisTurn == 0) ImageGetter.getImage("OtherIcons/Star").apply {
Color.GOLDENROD color = if (GUI.isAllowedChangeState() && unit.currentMovement > 0f && unit.attacksThisTurn == 0)
else Color.GOLDENROD.darken(0.25f) Color.GOLDENROD
} else Color.GOLDENROD.darken(0.25f)
).size(24f).padLeft(8f) }
).size(24f).padLeft(8f)
else null
promotionsTable.onClick { promotionsTable.onClick {
if (unit.promotions.canBePromoted() || unit.promotions.promotions.isNotEmpty()) { 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) add(promotionsTable)

View File

@ -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<Promotion>, prerequisites: Set<PromotionTree.PromotionNode>) {
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
}
}

View File

@ -3,142 +3,41 @@ package com.unciv.ui.screens.pickerscreens
import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.math.Vector2 import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.Actor 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.Cell
import com.badlogic.gdx.scenes.scene2d.ui.Image 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.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.GUI import com.unciv.GUI
import com.unciv.logic.map.mapunit.MapUnit import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.models.TutorialTrigger import com.unciv.models.TutorialTrigger
import com.unciv.models.UncivSound import com.unciv.models.UncivSound
import com.unciv.models.ruleset.unit.Promotion import com.unciv.models.ruleset.unit.Promotion
import com.unciv.models.translations.tr import com.unciv.models.translations.tr
import com.unciv.ui.components.BorderedTable import com.unciv.ui.audio.SoundPlayer
import com.unciv.ui.components.extensions.colorFromRGB
import com.unciv.ui.components.extensions.darken
import com.unciv.ui.components.extensions.isEnabled 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.onClick
import com.unciv.ui.components.input.onDoubleClick 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.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.basescreen.RecreateOnResize 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 import kotlin.math.abs
class PromotionNode(val promotion: Promotion) { class PromotionPickerScreen(
var maxDepth = 0 val unit: MapUnit,
private val onChange: (() -> Unit)? = null
/** How many level this promotion has */ ) : PickerScreen(), RecreateOnResize {
var levels = 1 // Style stuff
private val colors = skin[PromotionScreenColors::class.java]
val successors: ArrayList<PromotionNode> = ArrayList() private val promotedLabelStyle = Label.LabelStyle(skin[Label.LabelStyle::class.java]).apply {
val predecessors: ArrayList<PromotionNode> = ArrayList() fontColor = colors.promotedText
val baseName = getBasePromotionName()
fun isRoot() : Boolean {
return predecessors.isEmpty()
}
fun calculateDepth(excludeNodes: ArrayList<PromotionNode>, 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<PromotionNode> {
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)
} }
private val buttonCellMaxWidth: Float
private val buttonCellMinWidth: Float
// Widgets
private val promotionsTable = Table() private val promotionsTable = Table()
private val promotionToButton = LinkedHashMap<String, PromotionButton>() private val promotionToButton = LinkedHashMap<String, PromotionButton>()
private var selectedPromotion: PromotionButton? = null private var selectedPromotion: PromotionButton? = null
@ -150,138 +49,88 @@ class PromotionPickerScreen(val unit: MapUnit) : PickerScreen(), RecreateOnResiz
private val canPromoteNow = canChangeState && canBePromoted && private val canPromoteNow = canChangeState && canBePromoted &&
unit.currentMovement > 0 && unit.attacksThisTurn == 0 unit.currentMovement > 0 && unit.attacksThisTurn == 0
// Logic
private val tree = PromotionTree(unit)
init { init {
setDefaultCloseAction() setDefaultCloseAction()
if (canPromoteNow) { if (canPromoteNow) {
rightSideButton.setText("Pick promotion".tr()) rightSideButton.setText("Pick promotion".tr())
rightSideButton.onClick(UncivSound.Promote) { rightSideButton.onClick(UncivSound.Silent) {
if (selectedPromotion?.isPickable == true) acceptPromotion(selectedPromotion)
acceptPromotion(selectedPromotion?.node)
} }
} else { } else {
rightSideButton.isVisible = false rightSideButton.isVisible = false
} }
descriptionLabel.setText(updateDescriptionLabel()) updateDescriptionLabel()
val availablePromotionsGroup = Table() if (canChangeState) {
availablePromotionsGroup.defaults().pad(5f) //Always allow the user to rename the unit as many times as they like.
val renameButton = "Choose name for [${unit.name}]".toTextButton()
val unitType = unit.type renameButton.onClick {
val promotionsForUnitType = unit.civ.gameInfo.ruleset.unitPromotions.values.filter { UnitRenamePopup(this, unit) {
it.unitTypes.contains(unitType.name) || unit.promotions.promotions.contains(it.name) game.replaceCurrentScreen(recreate())
}
//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))
} }
) }
topTable.add(renameButton).pad(5f).row()
} }
availablePromotionsGroup.add(renameButton)
topTable.add(availablePromotionsGroup).row() // Create all buttons without placing them yet, measure
fillTable(promotionsForUnitType) 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) displayTutorial(TutorialTrigger.Experience)
} }
private fun acceptPromotion(node: PromotionNode?) { private fun acceptPromotion(button: PromotionButton?) {
// if user managed to click disabled button, still do nothing // 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) // Can't use stage.addAction as the screen is going to die immediately
game.replaceCurrentScreen(recreate()) val path = tree.getPathTo(button.node.promotion)
} if (path.size == 1) {
Concurrency.runOnGLThread { SoundPlayer.play(UncivSound.Promote) }
private fun fillTable(promotions: Collection<Promotion>) { } else {
val map = LinkedHashMap<String, PromotionNode>() Concurrency.runOnGLThread {
SoundPlayer.play(UncivSound.Promote)
val availablePromotions = unit.promotions.getAvailablePromotions() Concurrency.run {
delay(200)
// Create nodes Concurrency.runOnGLThread { SoundPlayer.play(UncivSound.Promote) }
// 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
} }
} }
} }
// Traverse each root node tree and calculate max possible depths of each node for (promotion in path)
for (node in map.values) { unit.promotions.addPromotion(promotion.name)
if (node.isRoot())
node.calculateDepth(arrayListOf(node), 0)
}
// For each non-root node remove all predecessors except the one with the least max depth. onChange?.invoke()
// This is needed to compactify trees and remove circular dependencies (A -> B -> C -> A) game.replaceCurrentScreen(recreate())
for (node in map.values) { }
if (node.isRoot())
continue
// Choose best predecessor - the one with less depth private fun fillTable() {
var best: PromotionNode? = null val placedButtons = mutableSetOf<String>()
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))
}
// Create cell matrix // Create cell matrix
val maxColumns = map.size + 1 val maxColumns = tree.getMaxColumns()
val maxRows = map.size + 1 val maxRows = tree.getMaxRows()
val cellMatrix = Array(maxRows + 1) {
val cellMatrix = ArrayList<ArrayList<Cell<Actor>>>() Array(maxColumns + 1) {
for (y in 0..maxRows) { promotionsTable.add() as Cell<Actor?>
cellMatrix.add(ArrayList()) }.also {
for (x in 0..maxColumns) { promotionsTable.row()
val cell = promotionsTable.add()
cellMatrix[y].add(cell)
} }
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 { fun isTherePlace(row: Int, col: Int, levels: Int) : Boolean {
for (i in 0 until levels) { for (i in 0 until levels) {
if (cellMatrix[row][col+i].actor != null) if (cellMatrix[row][col+i].actor != null)
@ -290,24 +139,17 @@ class PromotionPickerScreen(val unit: MapUnit) : PickerScreen(), RecreateOnResiz
return true return true
} }
/** Recursively place buttons for node and it's successors into free cells */ /** Recursively place buttons for node and its successors into free cells */
fun placeButton(col: Int, row: Int, node: PromotionNode) : Int { fun placeButton(col: Int, row: Int, node: PromotionTree.PromotionNode) : Int {
val name = node.promotion.name val name = node.promotion.name
// If promotion button not yet placed // If promotion button not yet placed
if (promotionToButton[name] == null) { if (name !in placedButtons) {
// If place is free - we place button // If place is free - we place button
if (isTherePlace(row, col, node.levels)) { if (isTherePlace(row, col, node.levels)) {
val cell = cellMatrix[row][col] cellMatrix[row][col].setActor(promotionToButton[name])
val isPromotionAvailable = node.promotion in availablePromotions .pad(5f).padRight(20f)
val hasPromotion = unit.promotions.promotions.contains(name) .minWidth(buttonCellMinWidth).maxWidth(buttonCellMaxWidth)
val isPickable = canPromoteNow && isPromotionAvailable && !hasPromotion placedButtons += name
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)
} }
// If place is not free - try to find another in the next row // If place is not free - try to find another in the next row
else { 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. // 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 the max row this whole tree ever reached.
return node.successors.filter { return node.children
!promotionToButton.containsKey(it.promotion.name) .filter { it.promotion.name !in placedButtons }
}.map { .sortedBy { it.baseName != node.baseName } // Prioritize getting groups in a row - relying on sensible json "column" isn't enough
placeButton(col+1, row, it) .maxOfOrNull { placeButton(col + 1, row, it) }
}.maxOfOrNull { it }?: row ?: row
} }
// Build each tree starting from root nodes // Build each tree starting from root nodes
var row = 0 var row = 0
for (node in map.values) { for (node in tree.allRoots()) {
if (node.isRoot()) { row = placeButton(0, row, node)
row = placeButton(0, row, node) // Each root tree should start from a completely empty row.
// Each root tree should start from a completely empty row. row += 1
row += 1
}
} }
topTable.add(promotionsTable) topTable.add(promotionsTable)
addConnectingLines(emptySet())
addConnectingLines()
} }
private fun getButton(allPromotions: Collection<Promotion>, node: PromotionNode, private fun getButton(tree: PromotionTree, node: PromotionTree.PromotionNode) : PromotionButton {
isPickable: Boolean = true, isPromoted: Boolean = false) : PromotionButton { val isPickable = (!node.pathIsAmbiguous || node.distanceToAdopted == 1) && tree.canBuyUpTo(node.promotion)
val button = PromotionButton( val button = PromotionButton(node, isPickable, promotedLabelStyle, buttonCellMaxWidth - 60f)
node = node,
isPromoted = isPromoted,
isPickable = isPickable
)
button.onClick { button.onClick {
selectedPromotion?.isSelected = false
selectedPromotion?.updateColor()
selectedPromotion = button 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) for (btn in promotionToButton.values)
btn.updateColor() btn.updateColor(btn == selectedPromotion, pathAsSet, prerequisites)
button.node.promotion.prerequisites.forEach { promotionToButton[it]?.apply {
if (!this.isPromoted)
bgColor = Prerequisite }}
rightSideButton.isEnabled = isPickable rightSideButton.isEnabled = isPickable
rightSideButton.setText(node.promotion.name.tr()) rightSideButton.setText(node.promotion.name.tr())
descriptionLabel.setText(updateDescriptionLabel(node.promotion.getDescription(allPromotions))) updateDescriptionLabel(isPickable, tree, node, path)
addConnectingLines() addConnectingLines(pathAsSet)
} }
if (isPickable) if (isPickable)
button.onDoubleClick(UncivSound.Promote) { button.onDoubleClick(UncivSound.Silent) {
acceptPromotion(node) acceptPromotion(button)
} }
return button return button
} }
private fun addConnectingLines() { private fun addConnectingLines(path: Set<Promotion>) {
promotionsTable.pack() promotionsTable.pack()
scrollPane.updateVisualScroll() scrollPane.updateVisualScroll()
for (line in lines) line.remove() for (line in lines) line.remove()
lines.clear() 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 (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 prerequisiteButton = promotionToButton[prerequisite] ?: continue
val prerequisiteNode = prerequisiteButton.node
var buttonCoords = Vector2(0f, button.height / 2) var buttonCoords = Vector2(0f, button.height / 2)
button.localToStageCoordinates(buttonCoords) button.localToStageCoordinates(buttonCoords)
@ -397,15 +242,16 @@ class PromotionPickerScreen(val unit: MapUnit) : PickerScreen(), RecreateOnResiz
prerequisiteButton.localToStageCoordinates(prerequisiteCoords) prerequisiteButton.localToStageCoordinates(prerequisiteCoords)
promotionsTable.stageToLocalCoordinates(prerequisiteCoords) promotionsTable.stageToLocalCoordinates(prerequisiteCoords)
val isNodeInPath = currentNode.promotion in path
val isSelectionPath = isNodeInPath &&
(prerequisiteNode.isAdopted || prerequisiteNode.promotion in path)
val lineColor = when { val lineColor = when {
button.isSelected -> Selected isSelectionPath -> colors.selected
prerequisiteButton.node.baseName == button.node.baseName -> Color.WHITE.cpy() isNodeInPath -> colors.pathToSelection
else -> Color.CLEAR prerequisiteNode.baseName == currentNode.baseName -> colors.groupLines
} else -> colors.otherLines
val lineSize = when {
button.isSelected -> 4f
else -> 2f
} }
val lineSize = if (isSelectionPath) 4f else 2f
if (buttonCoords.x < prerequisiteCoords.x) { if (buttonCoords.x < prerequisiteCoords.x) {
val temp = buttonCoords.cpy() val temp = buttonCoords.cpy()
@ -413,67 +259,48 @@ class PromotionPickerScreen(val unit: MapUnit) : PickerScreen(), RecreateOnResiz
prerequisiteCoords = temp prerequisiteCoords = temp
} }
val halfLineSize = lineSize / 2
if (buttonCoords.y != prerequisiteCoords.y) { if (buttonCoords.y != prerequisiteCoords.y) {
val deltaX = buttonCoords.x - prerequisiteCoords.x val deltaX = buttonCoords.x - prerequisiteCoords.x
val deltaY = buttonCoords.y - prerequisiteCoords.y val deltaY = buttonCoords.y - prerequisiteCoords.y
val halfLength = deltaX / 2f val halfLength = deltaX / 2f + halfLineSize
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)
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 { } else {
addLine(
val line = ImageGetter.getWhiteDot().apply { width = buttonCoords.x - prerequisiteCoords.x,
width = buttonCoords.x - prerequisiteCoords.x height = lineSize,
height = lineSize x = prerequisiteCoords.x,
x = prerequisiteCoords.x y = prerequisiteCoords.y - halfLineSize,
y = prerequisiteCoords.y - lineSize / 2 color = lineColor
} )
line.color = lineColor
promotionsTable.addActor(line)
line.toBack()
lines.add(line)
} }
} }
} }
for (line in lines) { for (line in lines) {
if (line.color == Selected) if (line.color == colors.selected || line.color == colors.pathToSelection)
line.zIndex = lines.size line.zIndex = lines.size
} }
} }
@ -484,18 +311,29 @@ class PromotionPickerScreen(val unit: MapUnit) : PickerScreen(), RecreateOnResiz
scrollPane.updateVisualScroll() scrollPane.updateVisualScroll()
} }
private fun updateDescriptionLabel(): String { private fun updateDescriptionLabel() {
return unit.displayName().tr() descriptionLabel.setText(unit.displayName().tr())
} }
private fun updateDescriptionLabel(promotionDescription: String): String { private fun updateDescriptionLabel(
var newDescriptionText = unit.displayName().tr() isPickable: Boolean,
newDescriptionText += "\n" + promotionDescription tree: PromotionTree,
return newDescriptionText node: PromotionTree.PromotionNode,
path: List<Promotion>
) {
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 { override fun recreate(): BaseScreen {
val newScreen = PromotionPickerScreen(unit) val newScreen = PromotionPickerScreen(unit, onChange)
newScreen.setScrollY(scrollPane.scrollY) newScreen.setScrollY(scrollPane.scrollY)
return newScreen return newScreen
} }

View File

@ -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
}

View File

@ -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<Promotion>
/** Ordered map, key is the Promotion name, same order as [possiblePromotions] */
private val nodes: LinkedHashMap<String, PromotionNode>
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<PromotionNode>()
/** 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<PromotionNode>()
/** 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<Promotion> {
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<PromotionNode> {
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<Promotion> {
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) }
}

View File

@ -24,4 +24,3 @@ class UnitRenamePopup(val screen: BaseScreen, val unit: MapUnit, val actionOnClo
} }
} }

View File

@ -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: Each promotion can have the following properties:
| Attribute | Type | Optional | Notes | | Attribute | Type | Optional | Notes |
| --------- | ---- | -------- | ----- | |-----------------|--------|---------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| name | String | Required | See above for "I, II, III" progressions | | name | String | Required | See above for "I, II, III" progressions |
| prerequisites | List | Default empty | Prerequisite promotions | | prerequisites | List | Default empty | Prerequisite promotions |
| effect | String | Default empty | Deprecated, use uniques instead | | effect | String | Default empty | Deprecated and ignored, use uniques instead |
| unitTypes | List | Default empty | The unit types for which this promotion applies as specified in [UnitTypes.json](#unittypesjson) | | 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. (…) |
| uniques | List | Default empty | List of effects, [see here](../Modders/Unique-parameters.md#unit-uniques) | | 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. |
| civilopediaText | List | Default empty | see [civilopediaText chapter](Miscellaneous-JSON-files.md#civilopedia-text) | | 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 ## UnitTypes.json