mirror of
https://github.com/yairm210/Unciv.git
synced 2025-07-09 15:29:32 +07:00
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:
BIN
android/Images/OtherIcons/Charts.png
Normal file
BIN
android/Images/OtherIcons/Charts.png
Normal file
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 |
285
core/src/com/unciv/ui/components/LineChart.kt
Normal file
285
core/src/com/unciv/ui/components/LineChart.kt
Normal 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
|
||||
}
|
@ -60,6 +60,11 @@ class VictoryScreen(
|
||||
override fun getContent(worldScreen: WorldScreen) = VictoryScreenCivRankings(worldScreen)
|
||||
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) {
|
||||
override fun getContent(worldScreen: WorldScreen) = VictoryScreenReplay(worldScreen)
|
||||
override fun isHidden(playerCiv: Civilization) =
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -728,6 +728,8 @@ Unless otherwise specified, all the following are from [the Noun Project](https:
|
||||
- [Transform] created by letstalkaboutdune
|
||||
- [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
|
||||
- [charts](https://thenounproject.com/icon/charts-2312023/) by Srinivas Agra (gimped to appear bolder) for the Charts page
|
||||
|
||||
|
||||
### Main menu
|
||||
|
||||
|
Reference in New Issue
Block a user