Charts improvements (#9168)

* Let spectator see civ groups in VictoryScreen

* Display defeated players normally for charts. Otherwise it's difficult to see which line belongs to them.

* Don't show a 0 for defeated civs.

* Delete dead code.

* Show the civ icon on the last data point within the chart, not next to it and simplify some computations with now obsolete paddings.

* Support negative values in the chart

* Remove TODO for negative values
This commit is contained in:
WhoIsJohannes 2023-04-13 10:55:59 +02:00 committed by GitHub
parent e72591e470
commit 9ea135fba8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 107 additions and 96 deletions

View File

@ -4,9 +4,7 @@ import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.g2d.Batch
import com.badlogic.gdx.graphics.glutils.ShapeRenderer
import com.badlogic.gdx.math.Matrix4
import com.badlogic.gdx.math.Rectangle
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.Widget
@ -14,18 +12,14 @@ import com.badlogic.gdx.utils.Align
import com.unciv.logic.civilization.Civilization
import com.unciv.ui.components.extensions.surroundWithCircle
import com.unciv.ui.screens.victoryscreen.VictoryScreenCivGroup
import com.unciv.ui.screens.victoryscreen.VictoryScreenCivGroup.DefeatedPlayerStyle
import kotlin.math.ceil
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.pow
private data class DataPoint(val x: Int, val y: Int, val civ: Civilization)
// TODO: This currently does not support negative values (e.g. for happiness or gold). Adding this
// seems like a major hassle. The question would be if you'd still want the x axis to be on the
// bottom, or whether it should move up somewhere to the middle. What if all values are negative?
// Should it then go to the top? And where do the labels of the x-axis go anyways? Or would we just
// want a non-zero based y-axis (yikes). Also computing the labels for the y axis, so that they are
// "nice" (whatever that means) would be quite challenging.
class LineChart(
data: Map<Int, Map<Civilization, Int>>,
private val viewingCiv: Civilization,
@ -49,12 +43,12 @@ class LineChart(
* as `0` is not counted. */
private val maxLabels = 10
private val paddingBetweenCivs = 10f
private val civGroupToChartPadding = 10f
private val xLabels: List<Int>
private val yLabels: List<Int>
private val hasNegativeYValues: Boolean
private val negativeYLabel: Int
private val dataPoints: List<DataPoint> = data.flatMap { turn ->
turn.value.map { (civ, value) ->
DataPoint(turn.key, value, civ)
@ -62,11 +56,21 @@ class LineChart(
}
init {
hasNegativeYValues = dataPoints.any { it.y < 0 }
xLabels = generateLabels(dataPoints.maxOf { it.x })
yLabels = generateLabels(dataPoints.maxOf { it.y })
val lowestValue = dataPoints.minOf { it.y }
negativeYLabel = if (hasNegativeYValues) -getNextNumberDivisibleByPowOfTen(-lowestValue) else 0
}
private fun generateLabels(maxValue: Int): List<Int> {
val maxLabelValue = getNextNumberDivisibleByPowOfTen(maxValue)
val stepSize = ceil(maxLabelValue.toFloat() / maxLabels).toInt()
// `maxLabels + 1` because we want to end at `maxLabels * stepSize`.
return (0 until maxLabels + 1).map { (it * stepSize) }
}
private fun getNextNumberDivisibleByPowOfTen(maxValue: Int): Int {
val numberOfDigits = ceil(log10(maxValue.toDouble())).toInt()
val maxLabelValue = when {
numberOfDigits <= 0 -> 1
@ -78,54 +82,26 @@ class LineChart(
ceil(maxValue.toDouble() / oneWithZeros).toInt() * oneWithZeros.toInt()
}
}
val stepSize = ceil(maxLabelValue.toFloat() / maxLabels).toInt()
// `maxLabels + 1` because we want to end at `maxLabels * stepSize`.
return (0 until maxLabels + 1).map { (it * stepSize) }
return maxLabelValue
}
override fun draw(batch: Batch, parentAlpha: Float) {
super.draw(batch, parentAlpha)
// Save the current batch transformation matrix
val oldTransformMatrix = batch.transformMatrix.cpy()
val oldTransformMatrix = batch.transformMatrix.cpy()
// Set the batch transformation matrix to the local coordinates of the LineChart widget
val stageCoords = localToStageCoordinates(Vector2(0f, 0f))
batch.transformMatrix = Matrix4().translate(stageCoords.x, stageCoords.y, 0f)
/* +++ WhoIsJohannes's code drew all the CivGroups on the right - I replaced that with just a
+++ Nation symbol at the selected civ's line end
// We draw civilization labels first, because they limit the extension of the chart to the
// right. We want to draw orientation lines together with the labels of the y axis and
// therefore we need to know first how much space the civilization boxes took on the right.
var yPosOfNextCiv = chartHeight
val civGroups = lastTurnDataPoints.toList().sortedByDescending { (_, v) -> v.y }.map {
VictoryScreenCivGroup(it.first, " - ", it.second.y.toString(), currentPlayerCiv)
}
val largestCivGroupWidth = civGroups.maxOf { it.width }
civGroups.forEach {
it.setPosition(
chartWidth - largestCivGroupWidth + (largestCivGroupWidth - it.width) / 2,
yPosOfNextCiv - it.height
)
it.draw(batch, 1f)
// Currently we don't really check whether y is overflowing to the bottom here.
yPosOfNextCiv -= it.height + paddingBetweenCivs
}
*/
val lastTurnDataPoints = getLastTurnDataPoints()
val selectedCivIcon: Actor? =
if (selectedCiv !in lastTurnDataPoints) null
else VictoryScreenCivGroup(selectedCiv, "", viewingCiv).children[0].run {
(this as? Image)?.surroundWithCircle(30f, color = Color.LIGHT_GRAY)
?: this
}
val largestCivGroupWidth = if (selectedCivIcon == null) -civGroupToChartPadding else 33f
val labelHeight = Label("123", Label.LabelStyle(Fonts.font, axisLabelColor)).height
val yLabelsAsLabels =
yLabels.map { Label(it.toString(), Label.LabelStyle(Fonts.font, axisLabelColor)) }
val widestYLabelWidth = yLabelsAsLabels.maxOf { it.width }
val negativeYLabelAsLabel =
Label(negativeYLabel.toString(), Label.LabelStyle(Fonts.font, axisLabelColor))
val widestYLabelWidth = max(yLabelsAsLabels.maxOf { it.width }, negativeYLabelAsLabel.width)
// We assume here that all labels have the same height. We need to deduct the height of
// a label from the available height, because otherwise the label on the top would
// overrun the height since the (x,y) coordinates define the bottom left corner of the
@ -138,34 +114,39 @@ class LineChart(
xAxisLabelsHeight + axisToLabelPadding + axisLineWidth / 2 - zeroYAxisLabelHeight / 2
val yAxisLabelYRange = yAxisLabelMaxY - yAxisLabelMinY
// We draw the y-axis labels second. They will take away some space on the left of the
// We draw the y-axis labels first. They will take away some space on the left of the
// widget which we need to consider when drawing the rest of the graph.
yLabelsAsLabels.forEachIndexed { index, label ->
val yPos = yAxisLabelMinY + index * (yAxisLabelYRange / (yLabels.size - 1))
var yAxisYPosition = 0f
val negativeOrientationLineYPosition = yAxisLabelMinY + labelHeight / 2
val yLabelsToDraw = if (hasNegativeYValues) listOf(negativeYLabelAsLabel) + yLabelsAsLabels else yLabelsAsLabels
yLabelsToDraw.forEachIndexed { index, label ->
val yPos = yAxisLabelMinY + index * (yAxisLabelYRange / (yLabelsToDraw.size - 1))
label.setPosition((widestYLabelWidth - label.width) / 2, yPos)
label.draw(batch, 1f)
// Draw y-axis orientation lines
if (index > 0) drawLine(
// Draw y-axis orientation lines and x-axis
val zeroIndex = if (hasNegativeYValues) 1 else 0
drawLine(
batch,
widestYLabelWidth + axisToLabelPadding + axisLineWidth,
yPos + labelHeight / 2,
chartWidth - largestCivGroupWidth - civGroupToChartPadding,
chartWidth,
yPos + labelHeight / 2,
orientationLineColor,
orientationLineWidth
if (index != zeroIndex) orientationLineColor else axisColor,
if (index != zeroIndex) orientationLineWidth else axisLineWidth
)
if (index == zeroIndex) {
yAxisYPosition = yPos + labelHeight / 2
}
}
// Draw x-axis labels
val xLabelsAsLabels =
xLabels.map { Label(it.toString(), Label.LabelStyle(Fonts.font, axisLabelColor)) }
val firstXAxisLabelWidth = xLabelsAsLabels[0].width
val lastXAxisLabelWidth = xLabelsAsLabels[xLabelsAsLabels.size - 1].width
val xAxisLabelMinX =
widestYLabelWidth + axisToLabelPadding + axisLineWidth / 2 - firstXAxisLabelWidth / 2
val xAxisLabelMaxX =
chartWidth - largestCivGroupWidth - paddingBetweenCivs - lastXAxisLabelWidth / 2
widestYLabelWidth + axisToLabelPadding + axisLineWidth / 2
val xAxisLabelMaxX = chartWidth - lastXAxisLabelWidth / 2
val xAxisLabelXRange = xAxisLabelMaxX - xAxisLabelMinX
xLabels.forEachIndexed { index, labelAsInt ->
val label = Label(labelAsInt.toString(), Label.LabelStyle(Fonts.font, axisLabelColor))
@ -173,41 +154,31 @@ class LineChart(
label.setPosition(xPos - label.width / 2, 0f)
label.draw(batch, 1f)
// Draw x-axis orientation lines
if (index > 0) drawLine(
// Draw x-axis orientation lines and y-axis
drawLine(
batch,
xPos,
labelHeight + axisToLabelPadding + axisLineWidth,
xPos,
chartHeight,
orientationLineColor,
orientationLineWidth
if (index > 0) orientationLineColor else axisColor,
if (index >0) orientationLineWidth else axisLineWidth
)
}
// Draw y-axis
val yAxisX = widestYLabelWidth + axisToLabelPadding + axisLineWidth / 2
val xAxisY = labelHeight + axisToLabelPadding + axisLineWidth / 2
drawLine(batch, yAxisX, xAxisY, yAxisX, chartHeight, axisColor, axisLineWidth)
// Draw x-axis
val xAxisRight = chartWidth - largestCivGroupWidth - civGroupToChartPadding
drawLine(batch, yAxisX, xAxisY, xAxisRight, xAxisY, axisColor, axisLineWidth)
// Draw line charts for each color
val linesMinX = widestYLabelWidth + axisToLabelPadding + axisLineWidth
val linesMaxX =
chartWidth - largestCivGroupWidth - civGroupToChartPadding - lastXAxisLabelWidth / 2
val linesMinY = labelHeight + axisToLabelPadding + axisLineWidth
val linesMaxX = chartWidth - lastXAxisLabelWidth / 2
val linesMinY = yAxisYPosition
val linesMaxY = chartHeight - labelHeight / 2
val scaleX = (linesMaxX - linesMinX) / xLabels.max()
val scaleY = (linesMaxY - linesMinY) / yLabels.max()
val negativeScaleY = if (hasNegativeYValues) (linesMinY - negativeOrientationLineYPosition) / -negativeYLabel else 0f
val sortedPoints = dataPoints.sortedBy { it.x }
val pointsByCiv = sortedPoints.groupBy { it.civ }
// We want the current player civ to be drawn last, so it is never overlapped by another player.
val civIterationOrder =
// By default the players with the highest points will be drawn last (i.e. they will
// By default the players with the highest points will be drawn last (i.e. they will
// overlap others).
pointsByCiv.keys.toList().sortedBy { lastTurnDataPoints[it]!!.y }
.toMutableList()
@ -221,24 +192,38 @@ class LineChart(
for (i in 1 until points.size) {
val prevPoint = points[i - 1]
val currPoint = points[i]
// See TODO at the top of the file. We currently don't support negative values.
if (prevPoint.y < 0) continue
if (currPoint.y < 0) continue
val prevPointYScale = if (prevPoint.y < 0f) negativeScaleY else scaleY
val currPointYScale = if (currPoint.y < 0f) negativeScaleY else scaleY
drawLine(
batch,
linesMinX + prevPoint.x * scaleX, linesMinY + prevPoint.y * scaleY,
linesMinX + currPoint.x * scaleX, linesMinY + currPoint.y * scaleY,
linesMinX + prevPoint.x * scaleX, linesMinY + prevPoint.y * prevPointYScale,
linesMinX + currPoint.x * scaleX, linesMinY + currPoint.y * currPointYScale,
civ.nation.getOuterColor(), chartLineWidth
)
}
}
// Draw the selected Civ icon to the right of its last datapoint
selectedCivIcon?.run {
val yPos = linesMinY + lastTurnDataPoints[selectedCiv]!!.y * scaleY
setPosition(chartWidth, yPos, Align.right)
setSize(33f, 33f) // Dead Civs need this
draw(batch, parentAlpha)
// Draw the selected Civ icon on its last datapoint
if (i == points.size - 1 && selectedCiv == civ && selectedCiv in lastTurnDataPoints) {
val selectedCivIcon =
VictoryScreenCivGroup(
selectedCiv,
"",
viewingCiv,
DefeatedPlayerStyle.REGULAR
).children[0].run {
(this as? Image)?.surroundWithCircle(30f, color = Color.LIGHT_GRAY)
?: this
}
selectedCivIcon.run {
setPosition(
linesMinX + currPoint.x * scaleX,
linesMinY + currPoint.y * currPointYScale,
Align.center
)
setSize(33f, 33f) // Dead Civs need this
draw(batch, parentAlpha)
}
}
}
}
// Restore the previous batch transformation matrix

View File

@ -16,6 +16,7 @@ import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.worldscreen.WorldScreen
import com.unciv.ui.screens.newgamescreen.TranslatedSelectBox
import com.unciv.ui.screens.victoryscreen.VictoryScreenCivGroup.DefeatedPlayerStyle
class VictoryScreenCharts(
worldScreen: WorldScreen
@ -69,7 +70,7 @@ class VictoryScreenCharts(
for (civEntry in sortedCivs) {
if (civEntry.civ != selectedCiv) civButtonsTable.add()
else civButtonsTable.add(markerIcon).size(24f).right()
val button = VictoryScreenCivGroup(civEntry, viewingCiv)
val button = VictoryScreenCivGroup(civEntry, viewingCiv, DefeatedPlayerStyle.REGULAR)
button.touchable = Touchable.enabled
civButtonsTable.add(button).row()
button.onClick {

View File

@ -20,15 +20,38 @@ internal class VictoryScreenCivGroup(
civ: Civilization,
separator: String,
additionalInfo: String,
currentPlayer: Civilization
currentPlayer: Civilization,
defeatedPlayerStyle: DefeatedPlayerStyle
) : Table() {
// Note this Table has no skin - works as long as no element tries to get its skin from the parent
constructor(civEntry: VictoryScreen.CivWithStat, currentPlayer: Civilization)
: this(civEntry.civ, ": ", civEntry.value.toString(), currentPlayer)
constructor(civ: Civilization, additionalInfo: String, currentPlayer: Civilization)
// That tr() is only needed to support additionalInfo containing {} because tr() doesn't support nested ones.
: this(civ, "\n", additionalInfo.tr(), currentPlayer)
internal enum class DefeatedPlayerStyle {
REGULAR,
GREYED_OUT,
}
constructor(
civEntry: VictoryScreen.CivWithStat,
currentPlayer: Civilization,
defeatedPlayerStyle: DefeatedPlayerStyle = DefeatedPlayerStyle.GREYED_OUT
)
: this(
civEntry.civ,
": ",
// Don't show a `0` for defeated civs.
if (civEntry.civ.isDefeated()) "" else civEntry.value.toString(),
currentPlayer,
defeatedPlayerStyle
)
constructor(
civ: Civilization,
additionalInfo: String,
currentPlayer: Civilization,
defeatedPlayerStyle: DefeatedPlayerStyle = DefeatedPlayerStyle.GREYED_OUT
)
// That tr() is only needed to support additionalInfo containing {} because tr() doesn't support nested ones.
: this(civ, "\n", additionalInfo.tr(), currentPlayer, defeatedPlayerStyle)
init {
var labelText = if (additionalInfo.isEmpty()) civ.civName
@ -37,12 +60,14 @@ internal class VictoryScreenCivGroup(
val backgroundColor: Color
when {
civ.isDefeated() -> {
civ.isDefeated() && defeatedPlayerStyle == DefeatedPlayerStyle.GREYED_OUT -> {
add(ImageGetter.getImage("OtherIcons/DisbandUnit")).size(30f)
backgroundColor = Color.LIGHT_GRAY
labelColor = Color.BLACK
}
currentPlayer == civ // || game.viewEntireMapForDebug
currentPlayer.isSpectator()
|| civ.isDefeated() && defeatedPlayerStyle == DefeatedPlayerStyle.REGULAR
|| currentPlayer == civ // || game.viewEntireMapForDebug
|| currentPlayer.knows(civ)
|| currentPlayer.isDefeated()
|| currentPlayer.victoryManager.hasWon() -> {