Victory screen score charts (#9121)

* VictoryScreen with Charts by WhoIsJohannes

* VictoryScreen with Charts by WhoIsJohannes - atlas

* VictoryScreen with Charts by WhoIsJohannes - lost icon attribution
This commit is contained in:
SomeTroglodyte
2023-04-08 20:38:27 +02:00
committed by GitHub
parent 93d2ba2af5
commit 6b084f0e6b
7 changed files with 591 additions and 169 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

After

Width:  |  Height:  |  Size: 517 KiB

View File

@ -0,0 +1,285 @@
package com.unciv.ui.components
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
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 kotlin.math.ceil
import kotlin.math.log10
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,
private val selectedCiv: Civilization,
private val chartWidth: Float,
private val chartHeight: Float
) : Widget() {
private val shapeRenderer = ShapeRenderer()
private val axisLineWidth = 2f
private val axisColor = Color.WHITE
private val axisLabelColor = axisColor
private val axisToLabelPadding = 5f
private val chartLineWidth = 3f
private val orientationLineWidth = 0.5f
private val orientationLineColor = Color.LIGHT_GRAY
/** This should not be changed lightly. There's code (e.g. for generating the labels) that
* assumes multiples of 10. Also please note, that the real number of labels is `maxLabels + 1`
* 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 dataPoints: List<DataPoint> = data.flatMap { turn ->
turn.value.map { (civ, value) ->
DataPoint(turn.key, value, civ)
}
}
init {
xLabels = generateLabels(dataPoints.maxOf { it.x })
yLabels = generateLabels(dataPoints.maxOf { it.y })
}
private fun generateLabels(maxValue: Int): List<Int> {
val numberOfDigits = ceil(log10(maxValue.toDouble())).toInt()
val maxLabelValue = when {
numberOfDigits <= 0 -> 1
else -> {
// Some examples:
// If `maxValue = 97` => `oneWithZeros = 10^(2-1) = 10 => ceil(97/10) * 10 = 100
// If `maxValue = 567` => `oneWithZeros = 10^(3-1) = 100 => ceil(567/100) * 100 = 600
val oneWithZeros = 10.0.pow(numberOfDigits - 1)
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) }
}
override fun draw(batch: Batch, parentAlpha: Float) {
super.draw(batch, parentAlpha)
// Save the current batch transformation matrix
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 }
// 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
// Label.
val yAxisLabelMaxY = chartHeight - labelHeight
// This is to account for the x axis and its labels which are below the lowest point
val xAxisLabelsHeight = labelHeight
val zeroYAxisLabelHeight = labelHeight
val yAxisLabelMinY =
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
// 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))
label.setPosition((widestYLabelWidth - label.width) / 2, yPos)
label.draw(batch, 1f)
// Draw y-axis orientation lines
if (index > 0) drawLine(
batch,
widestYLabelWidth + axisToLabelPadding + axisLineWidth,
yPos + labelHeight / 2,
chartWidth - largestCivGroupWidth - civGroupToChartPadding,
yPos + labelHeight / 2,
orientationLineColor,
orientationLineWidth
)
}
// 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
val xAxisLabelXRange = xAxisLabelMaxX - xAxisLabelMinX
xLabels.forEachIndexed { index, labelAsInt ->
val label = Label(labelAsInt.toString(), Label.LabelStyle(Fonts.font, axisLabelColor))
val xPos = xAxisLabelMinX + index * (xAxisLabelXRange / (xLabels.size - 1))
label.setPosition(xPos - label.width / 2, 0f)
label.draw(batch, 1f)
// Draw x-axis orientation lines
if (index > 0) drawLine(
batch,
xPos,
labelHeight + axisToLabelPadding + axisLineWidth,
xPos,
chartHeight,
orientationLineColor,
orientationLineWidth
)
}
// 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 linesMaxY = chartHeight - labelHeight / 2
val scaleX = (linesMaxX - linesMinX) / xLabels.max()
val scaleY = (linesMaxY - linesMinY) / yLabels.max()
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
// overlap others).
pointsByCiv.keys.toList().sortedBy { lastTurnDataPoints[it]!!.y }
.toMutableList()
// The current player might be a spectator.
if (selectedCiv in civIterationOrder) {
civIterationOrder.remove(selectedCiv)
civIterationOrder.add(selectedCiv)
}
for (civ in civIterationOrder) {
val points = pointsByCiv[civ]!!
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
drawLine(
batch,
linesMinX + prevPoint.x * scaleX, linesMinY + prevPoint.y * scaleY,
linesMinX + currPoint.x * scaleX, linesMinY + currPoint.y * scaleY,
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)
}
// Restore the previous batch transformation matrix
batch.transformMatrix = oldTransformMatrix
}
private fun getLastTurnDataPoints(): MutableMap<Civilization, DataPoint> {
val lastDataPoints = mutableMapOf<Civilization, DataPoint>()
for (dataPoint in dataPoints) {
if (!lastDataPoints.containsKey(dataPoint.civ) || lastDataPoints[dataPoint.civ]!!.x < dataPoint.x) {
lastDataPoints[dataPoint.civ] = dataPoint
}
}
return lastDataPoints
}
private fun drawLine(
batch: Batch,
x1: Float,
y1: Float,
x2: Float,
y2: Float,
color: Color,
width: Float
) {
shapeRenderer.projectionMatrix = batch.projectionMatrix
shapeRenderer.transformMatrix = batch.transformMatrix
batch.end()
shapeRenderer.begin(ShapeRenderer.ShapeType.Filled)
shapeRenderer.color = color
shapeRenderer.rectLine(x1, y1, x2, y2, width)
shapeRenderer.end()
batch.begin()
}
override fun getMinWidth() = chartWidth
override fun getMinHeight() = chartHeight
override fun getPrefWidth() = chartWidth
override fun getPrefHeight() = chartHeight
override fun getMaxWidth() = chartWidth
override fun getMaxHeight() = chartHeight
}

View File

@ -60,6 +60,11 @@ class VictoryScreen(
override fun getContent(worldScreen: WorldScreen) = VictoryScreenCivRankings(worldScreen) override fun getContent(worldScreen: WorldScreen) = VictoryScreenCivRankings(worldScreen)
override fun isHidden(playerCiv: Civilization) = UncivGame.Current.settings.useDemographics override fun isHidden(playerCiv: Civilization) = UncivGame.Current.settings.useDemographics
}, },
Charts('C', "OtherIcons/Charts") {
override fun getContent(worldScreen: WorldScreen) = VictoryScreenCharts(worldScreen)
override fun isHidden(playerCiv: Civilization) =
!playerCiv.isSpectator() && playerCiv.statsHistory.size < 2
},
Replay('P', "OtherIcons/Load", allowAsSecret = true) { Replay('P', "OtherIcons/Load", allowAsSecret = true) {
override fun getContent(worldScreen: WorldScreen) = VictoryScreenReplay(worldScreen) override fun getContent(worldScreen: WorldScreen) = VictoryScreenReplay(worldScreen)
override fun isHidden(playerCiv: Civilization) = override fun isHidden(playerCiv: Civilization) =

View File

@ -0,0 +1,123 @@
package com.unciv.ui.screens.victoryscreen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.ui.Container
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.logic.civilization.Civilization
import com.unciv.ui.components.AutoScrollPane
import com.unciv.ui.components.LineChart
import com.unciv.ui.components.TabbedPager
import com.unciv.ui.components.extensions.onChange
import com.unciv.ui.components.extensions.onClick
import com.unciv.ui.components.extensions.packIfNeeded
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
class VictoryScreenCharts(
worldScreen: WorldScreen
) : Table(BaseScreen.skin), TabbedPager.IPageExtensions {
private val gameInfo = worldScreen.gameInfo
private var rankingType = RankingType.Score
private var selectedCiv = worldScreen.selectedCiv
private val viewingCiv = worldScreen.viewingCiv
private val rankingTypeSelect = TranslatedSelectBox(RankingType.values().map { it.name }, rankingType.name, skin)
private val civButtonsTable = Table()
private val civButtonsScroll = AutoScrollPane(civButtonsTable)
private val controlsColumn = Table()
private val markerIcon = ImageGetter.getImage("OtherIcons/Star").apply {
color = Color.GOLD
align = Align.center
}
private val chartHolder = Container<LineChart?>(null)
init {
civButtonsScroll.setScrollingDisabled(true, false)
civButtonsTable.defaults().space(20f).fillX()
controlsColumn.defaults().space(20f).fillX()
controlsColumn.add(rankingTypeSelect).right().row()
controlsColumn.add(civButtonsScroll).fillY()
defaults().fill().pad(20f)
add(controlsColumn)
add(chartHolder).growX().top().padLeft(0f)
rankingTypeSelect.onChange {
rankingType = RankingType.values()
.firstOrNull { it.name == rankingTypeSelect.selected.value }
?: RankingType.Score
update()
}
}
private fun update() {
updateControls()
updateChart()
}
private fun updateControls() {
civButtonsTable.clear()
val sortedCivs = gameInfo.civilizations.asSequence()
.filter { it.isMajorCiv() }
.map { VictoryScreen.CivWithStat(it, rankingType) }
.sortedByDescending { it.value }
for (civEntry in sortedCivs) {
if (civEntry.civ != selectedCiv) civButtonsTable.add()
else civButtonsTable.add(markerIcon).size(24f).right()
val button = VictoryScreenCivGroup(civEntry, viewingCiv)
button.touchable = Touchable.enabled
civButtonsTable.add(button).row()
button.onClick {
selectedCiv = civEntry.civ
update()
}
}
civButtonsTable.add().padBottom(20f).row()
civButtonsTable.pack()
civButtonsScroll.layout()
}
private fun updateChart() {
// LineChart does not "cooperate" in Layout - the size we set here is final.
// These values seem to fit the cell it'll be in - we subtract padding and some extra manually
packIfNeeded()
chartHolder.actor = LineChart(
getLineChartData(rankingType),
viewingCiv,
selectedCiv,
parent.width - getColumnWidth(0) - 60f,
parent.height - 60f
)
chartHolder.invalidateHierarchy()
}
private fun getLineChartData(
rankingType: RankingType
): Map<Int, Map<Civilization, Int>> {
return gameInfo.civilizations.asSequence()
.filter { it.isMajorCiv() }
.flatMap { civ ->
civ.statsHistory
.filterValues { it.containsKey(rankingType) }
.map { (turn, data) -> Pair(turn, Pair(civ, data.getValue(rankingType))) }
}.groupBy({ it.first }, { it.second })
.mapValues { group -> group.value.toMap() }
}
override fun activated(index: Int, caption: String, pager: TabbedPager) {
pager.setScrollDisabled(true)
getCell(controlsColumn).height(parent.height)
getCell(chartHolder).height(parent.height)
if (chartHolder.actor == null) update()
civButtonsTable.invalidateHierarchy()
}
override fun deactivated(index: Int, caption: String, pager: TabbedPager) {
pager.setScrollDisabled(false)
}
}

View File

@ -728,6 +728,8 @@ Unless otherwise specified, all the following are from [the Noun Project](https:
- [Transform] created by letstalkaboutdune - [Transform] created by letstalkaboutdune
- [Swords](https://thenounproject.com/icon/swords-1580316/) created by Muhajir ila Robbi for Blockaded tile marker - [Swords](https://thenounproject.com/icon/swords-1580316/) created by Muhajir ila Robbi for Blockaded tile marker
- [Keyboard](https://thenounproject.com/icon/keyboard-2685534/) by Twenty Foo Studio for Options Keys - [Keyboard](https://thenounproject.com/icon/keyboard-2685534/) by Twenty Foo Studio for Options Keys
- [charts](https://thenounproject.com/icon/charts-2312023/) by Srinivas Agra (gimped to appear bolder) for the Charts page
### Main menu ### Main menu