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,
"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"
}
}
}

View File

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

View File

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

View File

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

View File

@ -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<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")
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.translations.tr
import com.unciv.ui.screens.civilopediascreen.FormattedLine
import com.unciv.ui.screens.pickerscreens.PromotionPickerScreen
class Promotion : RulesetObject() {
var prerequisites = listOf<String>()
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
/** 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<Promotion>):String {
val textList = ArrayList<String>()
@ -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))
}
}
}

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.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)

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.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<Image>? =
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)

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.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<PromotionNode> = ArrayList()
val predecessors: ArrayList<PromotionNode> = ArrayList()
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)
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<String, PromotionButton>()
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<Promotion>) {
val map = LinkedHashMap<String, PromotionNode>()
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<String>()
// Create cell matrix
val maxColumns = map.size + 1
val maxRows = map.size + 1
val cellMatrix = ArrayList<ArrayList<Cell<Actor>>>()
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<Actor?>
}.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<Promotion>, 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<Promotion>) {
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<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 {
val newScreen = PromotionPickerScreen(unit)
val newScreen = PromotionPickerScreen(unit, onChange)
newScreen.setScrollY(scrollPane.scrollY)
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:
| 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