mirror of
https://github.com/yairm210/Unciv.git
synced 2025-08-03 16:49:15 +07:00
Fix minimap actor leak (#6659)
* Formatting * Fix minimap actor leak The minimap update method tried to update borders only on tiles that changed owners, which is fine if that was what it did. But only the "remove old borders" part was in the "changed owners" branch, not "add new borders", so what actually happened was that each update all borders where the tile owner didn't change got added again, which is basically all borders. This caused a huge slowdown the more actions you do on the world map in a single turn (i.e. move a lot of units). When a new turn starts, the minimap gets completely thrown away and rebuilt, so then it's fine again. This changes the minimap to properly add/remove borders when something actually changes. * Refactor: Split classes up into separate files Also, split up the Minimap.init method into smaller methods to make it a bit more readable * Finish up comment
This commit is contained in:
19
core/src/com/unciv/ui/images/ClippingImage.kt
Normal file
19
core/src/com/unciv/ui/images/ClippingImage.kt
Normal file
@ -0,0 +1,19 @@
|
||||
package com.unciv.ui.images
|
||||
|
||||
import com.badlogic.gdx.graphics.g2d.Batch
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Image
|
||||
import com.badlogic.gdx.scenes.scene2d.utils.Drawable
|
||||
|
||||
/**
|
||||
* Image that is constrained by the size of its parent. Instead of spilling over if it is larger than the parent, the spilling parts simply get clipped off.
|
||||
*/
|
||||
class ClippingImage(drawable: Drawable) : Image(drawable) {
|
||||
override fun draw(batch: Batch, parentAlpha: Float) {
|
||||
batch.flush()
|
||||
if (clipBegin(0f, 0f, parent.width, parent.height)) {
|
||||
super.draw(batch, parentAlpha)
|
||||
batch.flush()
|
||||
clipEnd()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,361 +0,0 @@
|
||||
package com.unciv.ui.worldscreen
|
||||
|
||||
import com.badlogic.gdx.graphics.Color
|
||||
import com.badlogic.gdx.graphics.g2d.Batch
|
||||
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.Group
|
||||
import com.badlogic.gdx.scenes.scene2d.Touchable
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Image
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||
import com.badlogic.gdx.scenes.scene2d.utils.Drawable
|
||||
import com.badlogic.gdx.utils.Align
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.logic.HexMath
|
||||
import com.unciv.logic.civilization.CivilizationInfo
|
||||
import com.unciv.logic.map.MapShape
|
||||
import com.unciv.logic.map.MapSize
|
||||
import com.unciv.logic.map.TileInfo
|
||||
import com.unciv.ui.utils.*
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.atan
|
||||
import com.unciv.ui.images.IconCircleGroup
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.utils.onClick
|
||||
import com.unciv.ui.utils.surroundWithCircle
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class Minimap(val mapHolder: WorldMapHolder, minimapSize: Int) : Group(){
|
||||
private val allTiles = Group()
|
||||
class MinimapTileImages(val tileHexagonImage:Image) {
|
||||
var cityCircleImage: IconCircleGroup? = null
|
||||
var owningCiv: CivilizationInfo? = null
|
||||
var neighborToBorderImage = HashMap<TileInfo,Image>()
|
||||
}
|
||||
private val tileinfoToImages = HashMap<TileInfo, MinimapTileImages>()
|
||||
private val scrollPositionIndicators = ArrayList<ClippingImage>()
|
||||
|
||||
init {
|
||||
isTransform = false // don't try to resize rotate etc - this table has a LOT of children so that's valuable render time!
|
||||
|
||||
var topX = 0f
|
||||
var topY = 0f
|
||||
var bottomX = 0f
|
||||
var bottomY = 0f
|
||||
|
||||
// fun hexRow(vector2: Vector2) = vector2.x + vector2.y
|
||||
// val maxHexRow = mapHolder.tileMap.values.asSequence().map { hexRow(it.position) }.maxOrNull()!!
|
||||
// val minHexRow = mapHolder.tileMap.values.asSequence().map { hexRow(it.position) }.minOrNull()!!
|
||||
// val totalHexRows = maxHexRow - minHexRow
|
||||
// val groupSize = (minimapSize + 1) * 200f / totalHexRows
|
||||
// On hexagonal maps totalHexRows as calculated above is always 2 * radius.
|
||||
|
||||
// Support rectangular maps with extreme aspect ratios by scaling to the larger coordinate with a slight weighting to make the bounding box 4:3
|
||||
val effectiveRadius = with(mapHolder.tileMap.mapParameters) {
|
||||
if (shape != MapShape.rectangular) mapSize.radius
|
||||
else max (mapSize.height, mapSize.width * 3 / 4) * MapSize.Huge.radius / MapSize.Huge.height
|
||||
}
|
||||
val mapSizePercent = if (minimapSize < 22) minimapSize + 9 else minimapSize * 5 - 75
|
||||
val smallerWorldSize = mapHolder.worldScreen.stage.let { min(it.width,it.height) }
|
||||
val groupSize = smallerWorldSize * mapSizePercent / 100 / effectiveRadius
|
||||
|
||||
for (tileInfo in mapHolder.tileMap.values) {
|
||||
val hex = ImageGetter.getImage("OtherIcons/Hexagon")
|
||||
|
||||
val positionalVector = HexMath.hex2WorldCoords(tileInfo.position)
|
||||
|
||||
hex.setSize(groupSize, groupSize)
|
||||
hex.setPosition(positionalVector.x * 0.5f * groupSize,
|
||||
positionalVector.y * 0.5f * groupSize)
|
||||
hex.onClick {
|
||||
mapHolder.setCenterPosition(tileInfo.position)
|
||||
}
|
||||
allTiles.addActor(hex)
|
||||
tileinfoToImages[tileInfo] = MinimapTileImages(hex)
|
||||
|
||||
topX = max(topX, hex.x + groupSize)
|
||||
topY = max(topY, hex.y + groupSize)
|
||||
bottomX = min(bottomX, hex.x)
|
||||
bottomY = min(bottomY, hex.y)
|
||||
}
|
||||
|
||||
for (group in allTiles.children) {
|
||||
group.moveBy(-bottomX, -bottomY)
|
||||
}
|
||||
|
||||
// there are tiles "below the zero",
|
||||
// so we zero out the starting position of the whole board so they will be displayed as well
|
||||
allTiles.setSize(topX - bottomX, topY - bottomY)
|
||||
|
||||
scrollPositionIndicators.add(ClippingImage(ImageGetter.getDrawable("OtherIcons/Camera")))
|
||||
// If we are continuous scrolling (world wrap), add another 2 scrollPositionIndicators which
|
||||
// get drawn at proper offsets to simulate looping
|
||||
if (mapHolder.continuousScrollingX) {
|
||||
scrollPositionIndicators.add(ClippingImage(ImageGetter.getDrawable("OtherIcons/Camera")))
|
||||
scrollPositionIndicators.add(ClippingImage(ImageGetter.getDrawable("OtherIcons/Camera")))
|
||||
}
|
||||
for (indicator in scrollPositionIndicators) {
|
||||
indicator.touchable = Touchable.disabled
|
||||
allTiles.addActor(indicator)
|
||||
}
|
||||
|
||||
setSize(allTiles.width, allTiles.height)
|
||||
addActor(allTiles)
|
||||
}
|
||||
|
||||
/**### Transform and set coordinates for the scrollPositionIndicator.
|
||||
*
|
||||
* Relies on the [MiniMap][MinimapHolder.minimap]'s copy of the main [WorldMapHolder] as input.
|
||||
*
|
||||
* Requires [scrollPositionIndicator] to be a [ClippingImage] to keep the displayed portion of the indicator within the bounds of the minimap.
|
||||
*/
|
||||
fun updateScrollPosition() {
|
||||
// Only mapHolder.scrollX/Y and mapHolder.scaleX/Y change. scrollX/Y will range from 0 to mapHolder.maxX/Y,
|
||||
// with all extremes centering the corresponding map edge on screen. Y axis is 0 top, maxY bottom.
|
||||
// Visible area relative to this coordinate system seems to be mapHolder.width/2 * mapHolder.height/2.
|
||||
// Minimap coordinates are measured from the allTiles Group, which is a bounding box over the entire map, and (0,0) @ lower left.
|
||||
|
||||
// Helpers for readability - each single use, but they should help explain the logic
|
||||
operator fun Rectangle.times(other:Vector2) = Rectangle(x * other.x, y * other.y, width * other.x, height * other.y)
|
||||
fun Vector2.centeredRectangle(size: Vector2) = Rectangle(x - size.x/2, y - size.y/2, size.x, size.y)
|
||||
fun Rectangle.invertY(max: Float) = Rectangle(x, max - height - y, width, height)
|
||||
fun Actor.setViewport(rect: Rectangle) { x = rect.x; y = rect.y; width = rect.width; height = rect.height }
|
||||
|
||||
val worldToMiniFactor = Vector2(allTiles.width / mapHolder.maxX, allTiles.height / mapHolder.maxY)
|
||||
val worldVisibleArea = Vector2(mapHolder.width / 2 / mapHolder.scaleX, mapHolder.height / 2 / mapHolder.scaleY)
|
||||
val worldViewport = Vector2(mapHolder.scrollX, mapHolder.scrollY).centeredRectangle(worldVisibleArea)
|
||||
val miniViewport = worldViewport.invertY(mapHolder.maxY) * worldToMiniFactor
|
||||
// This _could_ place parts of the 'camera' icon outside the minimap if it were a standard Image, thus the ClippingImage helper class
|
||||
scrollPositionIndicators[0].setViewport(miniViewport)
|
||||
|
||||
// If world wrap enabled, draw another 2 viewports at proper offset to simulate wrapping
|
||||
if (scrollPositionIndicators.size != 1) {
|
||||
miniViewport.x -= allTiles.width
|
||||
scrollPositionIndicators[1].setViewport(miniViewport)
|
||||
miniViewport.x += allTiles.width * 2
|
||||
scrollPositionIndicators[2].setViewport(miniViewport)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(cloneCivilization: CivilizationInfo) {
|
||||
for ((tileInfo, tileImages) in tileinfoToImages) {
|
||||
tileImages.tileHexagonImage.color = when {
|
||||
!(UncivGame.Current.viewEntireMapForDebug || cloneCivilization.exploredTiles.contains(tileInfo.position)) -> Color.DARK_GRAY
|
||||
tileInfo.isCityCenter() && !tileInfo.isWater -> tileInfo.getOwner()!!.nation.getInnerColor()
|
||||
tileInfo.getCity() != null && !tileInfo.isWater -> tileInfo.getOwner()!!.nation.getOuterColor()
|
||||
else -> tileInfo.getBaseTerrain().getColor().lerp(Color.GRAY, 0.5f)
|
||||
}
|
||||
|
||||
if (!cloneCivilization.exploredTiles.contains(tileInfo.position)) continue
|
||||
|
||||
if (tileInfo.isCityCenter() && tileImages.owningCiv != tileInfo.getOwner()) {
|
||||
tileImages.cityCircleImage?.remove()
|
||||
val nation = tileInfo.getOwner()!!.nation
|
||||
val hex = tileImages.tileHexagonImage
|
||||
val nationIconSize = (if (tileInfo.getCity()!!.isCapital() && tileInfo.getOwner()!!.isMajorCiv()) 1.667f else 1.25f)* hex.width
|
||||
val nationIcon= ImageGetter.getCircle().apply { color = nation.getInnerColor() }
|
||||
.surroundWithCircle(nationIconSize, color = nation.getOuterColor())
|
||||
val hexCenterXPosition = hex.x + hex.width/2
|
||||
nationIcon.x = hexCenterXPosition - nationIconSize/2
|
||||
val hexCenterYPosition = hex.y + hex.height/2
|
||||
nationIcon.y = hexCenterYPosition - nationIconSize/2
|
||||
nationIcon.onClick {
|
||||
mapHolder.setCenterPosition(tileInfo.position)
|
||||
}
|
||||
tileImages.cityCircleImage = nationIcon
|
||||
addActor(nationIcon)
|
||||
}
|
||||
|
||||
if (tileImages.owningCiv != tileInfo.getOwner()){
|
||||
tileImages.neighborToBorderImage.values.forEach { it.remove() }
|
||||
tileImages.owningCiv = tileInfo.getOwner()
|
||||
}
|
||||
|
||||
for (neighbor in tileInfo.neighbors){
|
||||
val shouldHaveBorderDisplayed = tileInfo.getOwner() != null &&
|
||||
neighbor.getOwner() != tileInfo.getOwner()
|
||||
if (!shouldHaveBorderDisplayed) {
|
||||
tileImages.neighborToBorderImage[neighbor]?.remove()
|
||||
tileImages.neighborToBorderImage.remove(neighbor)
|
||||
continue
|
||||
}
|
||||
if (tileImages.neighborToBorderImage.containsKey(neighbor)) continue
|
||||
val borderImage = ImageGetter.getWhiteDot()
|
||||
|
||||
// copied from tilegroup border logic
|
||||
|
||||
val hexagonEdgeLength = tileImages.tileHexagonImage.width/2
|
||||
|
||||
borderImage.setSize(hexagonEdgeLength, hexagonEdgeLength/4)
|
||||
borderImage.setOrigin(Align.center)
|
||||
val hexagonCenterX = tileImages.tileHexagonImage.x + tileImages.tileHexagonImage.width/2
|
||||
borderImage.x = hexagonCenterX - borderImage.width/2
|
||||
val hexagonCenterY = tileImages.tileHexagonImage.y + tileImages.tileHexagonImage.height/2
|
||||
borderImage.y = hexagonCenterY - borderImage.height/2
|
||||
// Until this point, the border image is now CENTERED on the tile it's a border for
|
||||
|
||||
val relativeWorldPosition = tileInfo.tileMap.getNeighborTilePositionAsWorldCoords(tileInfo, neighbor)
|
||||
val sign = if (relativeWorldPosition.x < 0) -1 else 1
|
||||
val angle = sign * (atan(sign * relativeWorldPosition.y / relativeWorldPosition.x) * 180 / PI - 90.0).toFloat()
|
||||
|
||||
borderImage.moveBy(-relativeWorldPosition.x * hexagonEdgeLength/2,
|
||||
-relativeWorldPosition.y * hexagonEdgeLength/2)
|
||||
borderImage.rotateBy(angle)
|
||||
borderImage.color = tileInfo.getOwner()!!.nation.getInnerColor()
|
||||
addActor(borderImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For debugging purposes
|
||||
override fun draw(batch: Batch?, parentAlpha: Float) = super.draw(batch, parentAlpha)
|
||||
}
|
||||
|
||||
class MinimapHolder(val mapHolder: WorldMapHolder): Table() {
|
||||
private val worldScreen = mapHolder.worldScreen
|
||||
private var minimapSize = Int.MIN_VALUE
|
||||
lateinit var minimap: Minimap
|
||||
|
||||
/**
|
||||
* Class that unifies the behaviour of the little green map overlay toggle buttons shown next to the minimap.
|
||||
*
|
||||
* @param icon An [Image] to display.
|
||||
* @property getter A function that returns the current backing state of the toggle.
|
||||
* @property setter A function for setting the backing state of the toggle.
|
||||
* @param backgroundColor If non-null, a background colour to show behind the image.
|
||||
*/
|
||||
class MapOverlayToggleButton(
|
||||
icon: Image,
|
||||
private val getter: () -> Boolean,
|
||||
private val setter: (Boolean) -> Unit,
|
||||
backgroundColor: Color? = null
|
||||
) {
|
||||
/** [Actor] of the button. Add this to whatever layout. */
|
||||
val actor: IconCircleGroup by lazy {
|
||||
var innerActor: Actor = icon
|
||||
if (backgroundColor != null) {
|
||||
innerActor = innerActor
|
||||
.surroundWithCircle(30f)
|
||||
.apply { circle.color = backgroundColor }
|
||||
}
|
||||
// So, the "Food" and "Population" stat icons have green as part of their image, but the "Cattle" icon needs a background colour, which is… An interesting mixture/reuse of texture data and render-time processing.
|
||||
innerActor.surroundWithCircle(40f).apply { circle.color = Color.BLACK }
|
||||
}
|
||||
|
||||
init {
|
||||
actor.onClick(::toggle)
|
||||
}
|
||||
|
||||
/** Toggle overlay. Called on click. */
|
||||
fun toggle() {
|
||||
setter(!getter())
|
||||
UncivGame.Current.worldScreen.shouldUpdate = true
|
||||
// Setting worldScreen.shouldUpdate implicitly causes this.update() to be called by the WorldScreen on the next update.
|
||||
}
|
||||
|
||||
/** Update. Called via [WorldScreen.shouldUpdate] on toggle. */
|
||||
fun update() {
|
||||
actor.actor.color.a = if (getter()) 1f else 0.5f
|
||||
}
|
||||
}
|
||||
|
||||
/** Button, next to the minimap, to toggle the unit movement map overlay. */
|
||||
val movementsImageButton = MapOverlayToggleButton(
|
||||
ImageGetter.getImage("StatIcons/Movement").apply { setColor(0f, 0f, 0f, 1f) },
|
||||
getter = { UncivGame.Current.settings.showUnitMovements },
|
||||
setter = { UncivGame.Current.settings.showUnitMovements = it },
|
||||
backgroundColor = Color.GREEN
|
||||
)
|
||||
/** Button, next to the minimap, to toggle the tile yield map overlay. */
|
||||
val yieldImageButton = MapOverlayToggleButton(
|
||||
ImageGetter.getImage("StatIcons/Food"),
|
||||
// This is a use in the UI that has little to do with the stat… These buttons have more in common with each other than they do with other uses of getStatIcon().
|
||||
getter = { UncivGame.Current.settings.showTileYields },
|
||||
setter = { UncivGame.Current.settings.showTileYields = it }
|
||||
)
|
||||
/** Button, next to the minimap, to toggle the worked tiles map overlay. */
|
||||
val populationImageButton = MapOverlayToggleButton(
|
||||
ImageGetter.getImage("StatIcons/Population"),
|
||||
getter = { UncivGame.Current.settings.showWorkedTiles },
|
||||
setter = { UncivGame.Current.settings.showWorkedTiles = it }
|
||||
)
|
||||
/** Button, next to the minimap, to toggle the resource icons map overlay. */
|
||||
val resourceImageButton = MapOverlayToggleButton(
|
||||
ImageGetter.getImage("ResourceIcons/Cattle"),
|
||||
getter = { UncivGame.Current.settings.showResourcesAndImprovements },
|
||||
setter = { UncivGame.Current.settings.showResourcesAndImprovements = it },
|
||||
backgroundColor = Color.GREEN
|
||||
)
|
||||
|
||||
init {
|
||||
rebuildIfSizeChanged()
|
||||
}
|
||||
|
||||
private fun rebuildIfSizeChanged() {
|
||||
val newMinimapSize = worldScreen.game.settings.minimapSize
|
||||
if (newMinimapSize == minimapSize) return
|
||||
minimapSize = newMinimapSize
|
||||
this.clear()
|
||||
minimap = Minimap(mapHolder, minimapSize)
|
||||
add(getToggleIcons()).align(Align.bottom)
|
||||
add(getWrappedMinimap())
|
||||
pack()
|
||||
if (stage != null) x = stage.width - width
|
||||
}
|
||||
|
||||
private fun getWrappedMinimap(): Table {
|
||||
val internalMinimapWrapper = Table()
|
||||
internalMinimapWrapper.add(minimap)
|
||||
|
||||
internalMinimapWrapper.background = ImageGetter.getBackground(Color.GRAY)
|
||||
internalMinimapWrapper.pack()
|
||||
|
||||
val externalMinimapWrapper = Table()
|
||||
externalMinimapWrapper.add(internalMinimapWrapper).pad(5f)
|
||||
externalMinimapWrapper.background = ImageGetter.getBackground(Color.WHITE)
|
||||
externalMinimapWrapper.pack()
|
||||
|
||||
return externalMinimapWrapper
|
||||
}
|
||||
|
||||
/** @return Layout table for the little green map overlay toggle buttons, show to the left of the minimap. */
|
||||
private fun getToggleIcons(): Table {
|
||||
val toggleIconTable = Table()
|
||||
|
||||
toggleIconTable.add(movementsImageButton.actor).row()
|
||||
toggleIconTable.add(yieldImageButton.actor).row()
|
||||
toggleIconTable.add(populationImageButton.actor).row()
|
||||
toggleIconTable.add(resourceImageButton.actor).row()
|
||||
|
||||
return toggleIconTable
|
||||
}
|
||||
|
||||
fun update(civInfo: CivilizationInfo) {
|
||||
rebuildIfSizeChanged()
|
||||
isVisible = UncivGame.Current.settings.showMinimap
|
||||
if (isVisible) {
|
||||
minimap.update(civInfo)
|
||||
movementsImageButton.update()
|
||||
yieldImageButton.update()
|
||||
populationImageButton.update()
|
||||
resourceImageButton.update()
|
||||
}
|
||||
}
|
||||
|
||||
// For debugging purposes
|
||||
override fun draw(batch: Batch?, parentAlpha: Float) = super.draw(batch, parentAlpha)
|
||||
}
|
||||
|
||||
private class ClippingImage(drawable: Drawable) : Image(drawable) {
|
||||
// https://stackoverflow.com/questions/29448099/make-actor-clip-child-image
|
||||
override fun draw(batch: Batch, parentAlpha: Float) {
|
||||
batch.flush()
|
||||
if (clipBegin(0f,0f, parent.width, parent.height)) {
|
||||
super.draw(batch, parentAlpha)
|
||||
batch.flush()
|
||||
clipEnd()
|
||||
}
|
||||
}
|
||||
}
|
@ -47,6 +47,7 @@ import com.unciv.ui.popup.ExitGamePopup
|
||||
import com.unciv.ui.popup.Popup
|
||||
import com.unciv.ui.popup.ToastPopup
|
||||
import com.unciv.ui.popup.hasOpenPopups
|
||||
import com.unciv.ui.worldscreen.minimap.MinimapHolder
|
||||
import com.unciv.ui.worldscreen.unit.UnitActionsTable
|
||||
import com.unciv.ui.worldscreen.unit.UnitTable
|
||||
import java.util.*
|
||||
|
@ -0,0 +1,52 @@
|
||||
package com.unciv.ui.worldscreen.minimap
|
||||
|
||||
import com.badlogic.gdx.graphics.Color
|
||||
import com.badlogic.gdx.scenes.scene2d.Actor
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Image
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.ui.images.IconCircleGroup
|
||||
import com.unciv.ui.utils.onClick
|
||||
import com.unciv.ui.utils.surroundWithCircle
|
||||
|
||||
/**
|
||||
* Class that unifies the behaviour of the little green map overlay toggle buttons shown next to the minimap.
|
||||
*
|
||||
* @param icon An [Image] to display.
|
||||
* @property getter A function that returns the current backing state of the toggle.
|
||||
* @property setter A function for setting the backing state of the toggle.
|
||||
* @param backgroundColor If non-null, a background colour to show behind the image.
|
||||
*/
|
||||
class MapOverlayToggleButton(
|
||||
icon: Image,
|
||||
private val getter: () -> Boolean,
|
||||
private val setter: (Boolean) -> Unit,
|
||||
backgroundColor: Color? = null
|
||||
) {
|
||||
/** [Actor] of the button. Add this to whatever layout. */
|
||||
val actor: IconCircleGroup by lazy {
|
||||
var innerActor: Actor = icon
|
||||
if (backgroundColor != null) {
|
||||
innerActor = innerActor
|
||||
.surroundWithCircle(30f)
|
||||
.apply { circle.color = backgroundColor }
|
||||
}
|
||||
// So, the "Food" and "Population" stat icons have green as part of their image, but the "Cattle" icon needs a background colour, which is… An interesting mixture/reuse of texture data and render-time processing.
|
||||
innerActor.surroundWithCircle(40f).apply { circle.color = Color.BLACK }
|
||||
}
|
||||
|
||||
init {
|
||||
actor.onClick(::toggle)
|
||||
}
|
||||
|
||||
/** Toggle overlay. Called on click. */
|
||||
fun toggle() {
|
||||
setter(!getter())
|
||||
UncivGame.Current.worldScreen.shouldUpdate = true
|
||||
// Setting worldScreen.shouldUpdate implicitly causes this.update() to be called by the WorldScreen on the next update.
|
||||
}
|
||||
|
||||
/** Update. Called via [WorldScreen.shouldUpdate] on toggle. */
|
||||
fun update() {
|
||||
actor.actor.color.a = if (getter()) 1f else 0.5f
|
||||
}
|
||||
}
|
163
core/src/com/unciv/ui/worldscreen/minimap/Minimap.kt
Normal file
163
core/src/com/unciv/ui/worldscreen/minimap/Minimap.kt
Normal file
@ -0,0 +1,163 @@
|
||||
package com.unciv.ui.worldscreen.minimap
|
||||
|
||||
import com.badlogic.gdx.graphics.g2d.Batch
|
||||
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.Group
|
||||
import com.badlogic.gdx.scenes.scene2d.Touchable
|
||||
import com.unciv.logic.civilization.CivilizationInfo
|
||||
import com.unciv.logic.map.MapShape
|
||||
import com.unciv.logic.map.MapSize
|
||||
import com.unciv.ui.images.ClippingImage
|
||||
import com.unciv.ui.utils.*
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.worldscreen.WorldMapHolder
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class Minimap(val mapHolder: WorldMapHolder, minimapSize: Int) : Group() {
|
||||
private val tileLayer = Group()
|
||||
private val minimapTiles: List<MinimapTile>
|
||||
private val scrollPositionIndicators: List<ClippingImage>
|
||||
private var lastViewingCiv: CivilizationInfo? = null
|
||||
|
||||
init {
|
||||
// don't try to resize rotate etc - this table has a LOT of children so that's valuable render time!
|
||||
isTransform = false
|
||||
|
||||
var topX = 0f
|
||||
var topY = 0f
|
||||
var bottomX = 0f
|
||||
var bottomY = 0f
|
||||
|
||||
val tileSize = calcTileSize(minimapSize)
|
||||
minimapTiles = createMinimapTiles(tileSize)
|
||||
for (image in minimapTiles.map { it.image }) {
|
||||
tileLayer.addActor(image)
|
||||
|
||||
// keeps track of the current top/bottom/left/rightmost tiles to size and position the minimap correctly
|
||||
topX = max(topX, image.x + tileSize)
|
||||
topY = max(topY, image.y + tileSize)
|
||||
bottomX = min(bottomX, image.x)
|
||||
bottomY = min(bottomY, image.y)
|
||||
}
|
||||
|
||||
for (group in tileLayer.children) {
|
||||
group.moveBy(-bottomX, -bottomY)
|
||||
}
|
||||
|
||||
// there are tiles "below the zero",
|
||||
// so we zero out the starting position of the whole board so they will be displayed as well
|
||||
tileLayer.setSize(topX - bottomX, topY - bottomY)
|
||||
|
||||
scrollPositionIndicators = createScrollPositionIndicators()
|
||||
scrollPositionIndicators.forEach(tileLayer::addActor)
|
||||
|
||||
setSize(tileLayer.width, tileLayer.height)
|
||||
addActor(tileLayer)
|
||||
}
|
||||
|
||||
private fun calcTileSize(minimapSize: Int): Float {
|
||||
// Support rectangular maps with extreme aspect ratios by scaling to the larger coordinate with a slight weighting to make the bounding box 4:3
|
||||
val effectiveRadius = with(mapHolder.tileMap.mapParameters) {
|
||||
if (shape != MapShape.rectangular) mapSize.radius
|
||||
else max(mapSize.height, mapSize.width * 3 / 4) * MapSize.Huge.radius / MapSize.Huge.height
|
||||
}
|
||||
val mapSizePercent = if (minimapSize < 22) minimapSize + 9 else minimapSize * 5 - 75
|
||||
val smallerWorldDimension = mapHolder.worldScreen.stage.let { min(it.width, it.height) }
|
||||
val tileSize = smallerWorldDimension * mapSizePercent / 100 / effectiveRadius
|
||||
return tileSize
|
||||
}
|
||||
|
||||
private fun createScrollPositionIndicators(): List<ClippingImage> {
|
||||
// If we are continuous scrolling (world wrap), add another 2 scrollPositionIndicators which
|
||||
// get drawn at proper offsets to simulate looping
|
||||
val indicatorAmount = if (mapHolder.continuousScrollingX) 3 else 1
|
||||
|
||||
return List(indicatorAmount, init = {
|
||||
val indicator = ClippingImage(ImageGetter.getDrawable("OtherIcons/Camera"))
|
||||
indicator.touchable = Touchable.disabled
|
||||
return@List indicator
|
||||
})
|
||||
}
|
||||
|
||||
private fun createMinimapTiles(tileSize: Float): List<MinimapTile> {
|
||||
val tiles = ArrayList<MinimapTile>()
|
||||
for (tileInfo in mapHolder.tileMap.values) {
|
||||
val minimapTile = MinimapTile(tileInfo, tileSize, onClick = {
|
||||
mapHolder.setCenterPosition(tileInfo.position)
|
||||
})
|
||||
tiles.add(minimapTile)
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
|
||||
/**### Transform and set coordinates for the scrollPositionIndicator.
|
||||
*
|
||||
* Relies on the [MiniMap][MinimapHolder.minimap]'s copy of the main [WorldMapHolder] as input.
|
||||
*
|
||||
* Requires [scrollPositionIndicator] to be a [ClippingImage] to keep the displayed portion of the indicator within the bounds of the minimap.
|
||||
*/
|
||||
fun updateScrollPosition() {
|
||||
// Only mapHolder.scrollX/Y and mapHolder.scaleX/Y change. scrollX/Y will range from 0 to mapHolder.maxX/Y,
|
||||
// with all extremes centering the corresponding map edge on screen. Y axis is 0 top, maxY bottom.
|
||||
// Visible area relative to this coordinate system seems to be mapHolder.width/2 * mapHolder.height/2.
|
||||
// Minimap coordinates are measured from the allTiles Group, which is a bounding box over the entire map, and (0,0) @ lower left.
|
||||
|
||||
// Helpers for readability - each single use, but they should help explain the logic
|
||||
operator fun Rectangle.times(other: Vector2) = Rectangle(x * other.x, y * other.y, width * other.x, height * other.y)
|
||||
|
||||
fun Vector2.centeredRectangle(size: Vector2) = Rectangle(x - size.x / 2, y - size.y / 2, size.x, size.y)
|
||||
fun Rectangle.invertY(max: Float) = Rectangle(x, max - height - y, width, height)
|
||||
fun Actor.setViewport(rect: Rectangle) {
|
||||
x = rect.x; y = rect.y; width = rect.width; height = rect.height
|
||||
}
|
||||
|
||||
val worldToMiniFactor = Vector2(tileLayer.width / mapHolder.maxX, tileLayer.height / mapHolder.maxY)
|
||||
val worldVisibleArea = Vector2(mapHolder.width / 2 / mapHolder.scaleX, mapHolder.height / 2 / mapHolder.scaleY)
|
||||
val worldViewport = Vector2(mapHolder.scrollX, mapHolder.scrollY).centeredRectangle(worldVisibleArea)
|
||||
val miniViewport = worldViewport.invertY(mapHolder.maxY) * worldToMiniFactor
|
||||
// This _could_ place parts of the 'camera' icon outside the minimap if it were a standard Image, thus the ClippingImage helper class
|
||||
scrollPositionIndicators[0].setViewport(miniViewport)
|
||||
|
||||
// If world wrap enabled, draw another 2 viewports at proper offset to simulate wrapping
|
||||
if (scrollPositionIndicators.size != 1) {
|
||||
miniViewport.x -= tileLayer.width
|
||||
scrollPositionIndicators[1].setViewport(miniViewport)
|
||||
miniViewport.x += tileLayer.width * 2
|
||||
scrollPositionIndicators[2].setViewport(miniViewport)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(viewingCiv: CivilizationInfo) {
|
||||
for (minimapTile in minimapTiles) {
|
||||
val tileInfo = minimapTile.tileInfo
|
||||
val ownerChanged = minimapTile.owningCiv != tileInfo.getOwner()
|
||||
if (ownerChanged) {
|
||||
minimapTile.owningCiv = tileInfo.getOwner()
|
||||
}
|
||||
|
||||
val shouldBeUnrevealed = tileInfo.position !in viewingCiv.exploredTiles
|
||||
val revealStatusChanged = minimapTile.isUnrevealed != shouldBeUnrevealed
|
||||
if (revealStatusChanged || ownerChanged) {
|
||||
minimapTile.updateColor(shouldBeUnrevealed)
|
||||
}
|
||||
|
||||
// If owner didn't change, neither city circle nor borders can have changed
|
||||
if (shouldBeUnrevealed || !ownerChanged) continue
|
||||
|
||||
if (tileInfo.isCityCenter()) {
|
||||
minimapTile.updateCityCircle().updateActorsIn(this)
|
||||
}
|
||||
|
||||
minimapTile.updateBorders().updateActorsIn(this)
|
||||
}
|
||||
lastViewingCiv = viewingCiv
|
||||
}
|
||||
|
||||
|
||||
// For debugging purposes
|
||||
override fun draw(batch: Batch?, parentAlpha: Float) = super.draw(batch, parentAlpha)
|
||||
}
|
||||
|
107
core/src/com/unciv/ui/worldscreen/minimap/MinimapHolder.kt
Normal file
107
core/src/com/unciv/ui/worldscreen/minimap/MinimapHolder.kt
Normal file
@ -0,0 +1,107 @@
|
||||
package com.unciv.ui.worldscreen.minimap
|
||||
|
||||
import com.badlogic.gdx.graphics.Color
|
||||
import com.badlogic.gdx.graphics.g2d.Batch
|
||||
import com.badlogic.gdx.scenes.scene2d.Actor
|
||||
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.UncivGame
|
||||
import com.unciv.logic.civilization.CivilizationInfo
|
||||
import com.unciv.ui.images.IconCircleGroup
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.utils.onClick
|
||||
import com.unciv.ui.utils.surroundWithCircle
|
||||
import com.unciv.ui.worldscreen.WorldMapHolder
|
||||
|
||||
class MinimapHolder(val mapHolder: WorldMapHolder) : Table() {
|
||||
private val worldScreen = mapHolder.worldScreen
|
||||
private var minimapSize = Int.MIN_VALUE
|
||||
lateinit var minimap: Minimap
|
||||
|
||||
/** Button, next to the minimap, to toggle the unit movement map overlay. */
|
||||
val movementsImageButton = MapOverlayToggleButton(
|
||||
ImageGetter.getImage("StatIcons/Movement").apply { setColor(0f, 0f, 0f, 1f) },
|
||||
getter = { UncivGame.Current.settings.showUnitMovements },
|
||||
setter = { UncivGame.Current.settings.showUnitMovements = it },
|
||||
backgroundColor = Color.GREEN
|
||||
)
|
||||
/** Button, next to the minimap, to toggle the tile yield map overlay. */
|
||||
val yieldImageButton = MapOverlayToggleButton(
|
||||
ImageGetter.getImage("StatIcons/Food"),
|
||||
// This is a use in the UI that has little to do with the stat… These buttons have more in common with each other than they do with other uses of getStatIcon().
|
||||
getter = { UncivGame.Current.settings.showTileYields },
|
||||
setter = { UncivGame.Current.settings.showTileYields = it }
|
||||
)
|
||||
/** Button, next to the minimap, to toggle the worked tiles map overlay. */
|
||||
val populationImageButton = MapOverlayToggleButton(
|
||||
ImageGetter.getImage("StatIcons/Population"),
|
||||
getter = { UncivGame.Current.settings.showWorkedTiles },
|
||||
setter = { UncivGame.Current.settings.showWorkedTiles = it }
|
||||
)
|
||||
/** Button, next to the minimap, to toggle the resource icons map overlay. */
|
||||
val resourceImageButton = MapOverlayToggleButton(
|
||||
ImageGetter.getImage("ResourceIcons/Cattle"),
|
||||
getter = { UncivGame.Current.settings.showResourcesAndImprovements },
|
||||
setter = { UncivGame.Current.settings.showResourcesAndImprovements = it },
|
||||
backgroundColor = Color.GREEN
|
||||
)
|
||||
|
||||
init {
|
||||
rebuildIfSizeChanged()
|
||||
}
|
||||
|
||||
private fun rebuildIfSizeChanged() {
|
||||
val newMinimapSize = worldScreen.game.settings.minimapSize
|
||||
if (newMinimapSize == minimapSize) return
|
||||
minimapSize = newMinimapSize
|
||||
this.clear()
|
||||
minimap = Minimap(mapHolder, minimapSize)
|
||||
add(getToggleIcons()).align(Align.bottom)
|
||||
add(getWrappedMinimap())
|
||||
pack()
|
||||
if (stage != null) x = stage.width - width
|
||||
}
|
||||
|
||||
private fun getWrappedMinimap(): Table {
|
||||
val internalMinimapWrapper = Table()
|
||||
internalMinimapWrapper.add(minimap)
|
||||
|
||||
internalMinimapWrapper.background = ImageGetter.getBackground(Color.GRAY)
|
||||
internalMinimapWrapper.pack()
|
||||
|
||||
val externalMinimapWrapper = Table()
|
||||
externalMinimapWrapper.add(internalMinimapWrapper).pad(5f)
|
||||
externalMinimapWrapper.background = ImageGetter.getBackground(Color.WHITE)
|
||||
externalMinimapWrapper.pack()
|
||||
|
||||
return externalMinimapWrapper
|
||||
}
|
||||
|
||||
/** @return Layout table for the little green map overlay toggle buttons, show to the left of the minimap. */
|
||||
private fun getToggleIcons(): Table {
|
||||
val toggleIconTable = Table()
|
||||
|
||||
toggleIconTable.add(movementsImageButton.actor).row()
|
||||
toggleIconTable.add(yieldImageButton.actor).row()
|
||||
toggleIconTable.add(populationImageButton.actor).row()
|
||||
toggleIconTable.add(resourceImageButton.actor).row()
|
||||
|
||||
return toggleIconTable
|
||||
}
|
||||
|
||||
fun update(civInfo: CivilizationInfo) {
|
||||
rebuildIfSizeChanged()
|
||||
isVisible = UncivGame.Current.settings.showMinimap
|
||||
if (isVisible) {
|
||||
minimap.update(civInfo)
|
||||
movementsImageButton.update()
|
||||
yieldImageButton.update()
|
||||
populationImageButton.update()
|
||||
resourceImageButton.update()
|
||||
}
|
||||
}
|
||||
|
||||
// For debugging purposes
|
||||
override fun draw(batch: Batch?, parentAlpha: Float) = super.draw(batch, parentAlpha)
|
||||
}
|
115
core/src/com/unciv/ui/worldscreen/minimap/MinimapTile.kt
Normal file
115
core/src/com/unciv/ui/worldscreen/minimap/MinimapTile.kt
Normal file
@ -0,0 +1,115 @@
|
||||
package com.unciv.ui.worldscreen.minimap
|
||||
|
||||
import com.badlogic.gdx.graphics.Color
|
||||
import com.badlogic.gdx.scenes.scene2d.Actor
|
||||
import com.badlogic.gdx.scenes.scene2d.Group
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Image
|
||||
import com.badlogic.gdx.utils.Align
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.logic.HexMath
|
||||
import com.unciv.logic.civilization.CivilizationInfo
|
||||
import com.unciv.logic.map.TileInfo
|
||||
import com.unciv.ui.images.IconCircleGroup
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.utils.onClick
|
||||
import com.unciv.ui.utils.surroundWithCircle
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.atan
|
||||
|
||||
internal class MinimapTile(val tileInfo: TileInfo, tileSize: Float, val onClick: () -> Unit) {
|
||||
companion object {
|
||||
val UNREVEALED_COLOR = Color.DARK_GRAY!!
|
||||
}
|
||||
|
||||
val image: Image = ImageGetter.getImage("OtherIcons/Hexagon")
|
||||
var cityCircleImage: IconCircleGroup? = null
|
||||
var owningCiv: CivilizationInfo? = null
|
||||
var neighborToBorderImage = HashMap<TileInfo, Image>()
|
||||
val isUnrevealed get() = image.color == UNREVEALED_COLOR
|
||||
|
||||
init {
|
||||
val positionalVector = HexMath.hex2WorldCoords(tileInfo.position)
|
||||
|
||||
image.color = UNREVEALED_COLOR
|
||||
image.setSize(tileSize, tileSize)
|
||||
image.setPosition(
|
||||
positionalVector.x * 0.5f * tileSize,
|
||||
positionalVector.y * 0.5f * tileSize
|
||||
)
|
||||
image.onClick(onClick)
|
||||
}
|
||||
|
||||
fun updateColor(isTileUnrevealed: Boolean) {
|
||||
image.color = when {
|
||||
!UncivGame.Current.viewEntireMapForDebug && isTileUnrevealed -> UNREVEALED_COLOR
|
||||
tileInfo.isCityCenter() && !tileInfo.isWater -> tileInfo.getOwner()!!.nation.getInnerColor()
|
||||
tileInfo.getCity() != null && !tileInfo.isWater -> tileInfo.getOwner()!!.nation.getOuterColor()
|
||||
else -> tileInfo.getBaseTerrain().getColor().lerp(Color.GRAY, 0.5f)
|
||||
}
|
||||
}
|
||||
|
||||
class ActorChange(val removed: Set<Actor>, val added: Set<Actor>) {
|
||||
fun updateActorsIn(group: Group) {
|
||||
removed.forEach { group.removeActor(it) }
|
||||
added.forEach { group.addActor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBorders(): ActorChange {
|
||||
val imagesBefore = neighborToBorderImage.values.toSet()
|
||||
for (neighbor in tileInfo.neighbors) {
|
||||
val shouldHaveBorderDisplayed = tileInfo.getOwner() != null &&
|
||||
neighbor.getOwner() != tileInfo.getOwner()
|
||||
if (!shouldHaveBorderDisplayed) {
|
||||
neighborToBorderImage.remove(neighbor)
|
||||
continue
|
||||
}
|
||||
if (neighbor in neighborToBorderImage) continue
|
||||
|
||||
val borderImage = ImageGetter.getWhiteDot()
|
||||
|
||||
// copied from tilegroup border logic
|
||||
|
||||
val hexagonEdgeLength = image.width / 2
|
||||
|
||||
borderImage.setSize(hexagonEdgeLength, hexagonEdgeLength / 4)
|
||||
borderImage.setOrigin(Align.center)
|
||||
val hexagonCenterX = image.x + image.width / 2
|
||||
borderImage.x = hexagonCenterX - borderImage.width / 2
|
||||
val hexagonCenterY = image.y + image.height / 2
|
||||
borderImage.y = hexagonCenterY - borderImage.height / 2
|
||||
// Until this point, the border image is now CENTERED on the tile it's a border for
|
||||
|
||||
val relativeWorldPosition = tileInfo.tileMap.getNeighborTilePositionAsWorldCoords(tileInfo, neighbor)
|
||||
val sign = if (relativeWorldPosition.x < 0) -1 else 1
|
||||
val angle = sign * (atan(sign * relativeWorldPosition.y / relativeWorldPosition.x) * 180 / PI - 90.0).toFloat()
|
||||
|
||||
borderImage.moveBy(
|
||||
-relativeWorldPosition.x * hexagonEdgeLength / 2,
|
||||
-relativeWorldPosition.y * hexagonEdgeLength / 2
|
||||
)
|
||||
borderImage.rotateBy(angle)
|
||||
borderImage.color = tileInfo.getOwner()!!.nation.getInnerColor()
|
||||
neighborToBorderImage[neighbor] = borderImage
|
||||
}
|
||||
val imagesAfter = neighborToBorderImage.values.toSet()
|
||||
return ActorChange(imagesBefore - imagesAfter, imagesAfter - imagesBefore)
|
||||
}
|
||||
|
||||
fun updateCityCircle(): ActorChange {
|
||||
val prevCircle = cityCircleImage
|
||||
|
||||
val nation = tileInfo.getOwner()!!.nation
|
||||
val nationIconSize = (if (tileInfo.getCity()!!.isCapital() && tileInfo.getOwner()!!.isMajorCiv()) 1.667f else 1.25f) * image.width
|
||||
val cityCircle = ImageGetter.getCircle().apply { color = nation.getInnerColor() }
|
||||
.surroundWithCircle(nationIconSize, color = nation.getOuterColor())
|
||||
val hexCenterXPosition = image.x + image.width / 2
|
||||
cityCircle.x = hexCenterXPosition - nationIconSize / 2
|
||||
val hexCenterYPosition = image.y + image.height / 2
|
||||
cityCircle.y = hexCenterYPosition - nationIconSize / 2
|
||||
cityCircle.onClick(onClick)
|
||||
cityCircleImage = cityCircle
|
||||
|
||||
return ActorChange(if (prevCircle != null) setOf(prevCircle) else emptySet(), setOf(cityCircle))
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user