Improve performance of worldmap panning (#7034)

* Refactor: change CrashHandlingStage to UncivStage

* Add possibility to disable pointer enter exit events temporarily

* Disable pointer enter/exit events and TileGroupMap.act while panning

* Change ZoomableScrollPane to be self-contained and reduce coupling
This commit is contained in:
Timo T 2022-06-01 21:26:24 +02:00 committed by GitHub
parent 1abc65163d
commit 068e1587bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 287 additions and 101 deletions

View File

@ -1,34 +1,64 @@
package com.unciv.ui.crashhandling package com.unciv.ui
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.graphics.g2d.Batch import com.badlogic.gdx.graphics.g2d.Batch
import com.badlogic.gdx.scenes.scene2d.Stage import com.badlogic.gdx.scenes.scene2d.Stage
import com.badlogic.gdx.utils.viewport.Viewport import com.badlogic.gdx.utils.viewport.Viewport
import com.unciv.ui.utils.* import com.unciv.ui.utils.wrapCrashHandling
import com.unciv.ui.utils.wrapCrashHandlingUnit
/** Stage that safely brings the game to a [CrashScreen] if any event handlers throw an exception or an error that doesn't get otherwise handled. */ /** Main stage for the game. Safely brings the game to a [CrashScreen] if any event handlers throw an exception or an error that doesn't get otherwise handled. */
class CrashHandlingStage(viewport: Viewport, batch: Batch) : Stage(viewport, batch) { class UncivStage(viewport: Viewport, batch: Batch) : Stage(viewport, batch) {
override fun draw() = { super.draw() }.wrapCrashHandlingUnit()() /**
override fun act() = { super.act() }.wrapCrashHandlingUnit()() * Enables/disables sending pointer enter/exit events to actors on this stage.
override fun act(delta: Float) = { super.act(delta) }.wrapCrashHandlingUnit()() * Checking for the enter/exit bounds is a relatively expensive operation and may thus be disabled temporarily.
*/
var performPointerEnterExitEvents: Boolean = false
override fun touchDown(screenX: Int, screenY: Int, pointer: Int, button: Int) override fun draw() =
= { super.touchDown(screenX, screenY, pointer, button) }.wrapCrashHandling()() ?: true { super.draw() }.wrapCrashHandlingUnit()()
override fun touchDragged(screenX: Int, screenY: Int, pointer: Int)
= { super.touchDragged(screenX, screenY, pointer) }.wrapCrashHandling()() ?: true /** libGDX has no built-in way to disable/enable pointer enter/exit events. It is simply being done in [Stage.act]. So to disable this, we have
override fun touchUp(screenX: Int, screenY: Int, pointer: Int, button: Int) * to replicate the [Stage.act] method without the code for pointer enter/exit events. This is of course inherently brittle, but the only way. */
= { super.touchUp(screenX, screenY, pointer, button) }.wrapCrashHandling()() ?: true override fun act() = {
override fun mouseMoved(screenX: Int, screenY: Int) /** We're replicating [Stage.act], so this value is simply taken from there */
= { super.mouseMoved(screenX, screenY) }.wrapCrashHandling()() ?: true val delta = Gdx.graphics.deltaTime.coerceAtMost(1 / 30f)
override fun scrolled(amountX: Float, amountY: Float)
= { super.scrolled(amountX, amountY) }.wrapCrashHandling()() ?: true if (performPointerEnterExitEvents) {
override fun keyDown(keyCode: Int) super.act(delta)
= { super.keyDown(keyCode) }.wrapCrashHandling()() ?: true } else {
override fun keyUp(keyCode: Int) root.act(delta)
= { super.keyUp(keyCode) }.wrapCrashHandling()() ?: true }
override fun keyTyped(character: Char) }.wrapCrashHandlingUnit()()
= { super.keyTyped(character) }.wrapCrashHandling()() ?: true
override fun act(delta: Float) =
{ super.act(delta) }.wrapCrashHandlingUnit()()
override fun touchDown(screenX: Int, screenY: Int, pointer: Int, button: Int) =
{ super.touchDown(screenX, screenY, pointer, button) }.wrapCrashHandling()() ?: true
override fun touchDragged(screenX: Int, screenY: Int, pointer: Int) =
{ super.touchDragged(screenX, screenY, pointer) }.wrapCrashHandling()() ?: true
override fun touchUp(screenX: Int, screenY: Int, pointer: Int, button: Int) =
{ super.touchUp(screenX, screenY, pointer, button) }.wrapCrashHandling()() ?: true
override fun mouseMoved(screenX: Int, screenY: Int) =
{ super.mouseMoved(screenX, screenY) }.wrapCrashHandling()() ?: true
override fun scrolled(amountX: Float, amountY: Float) =
{ super.scrolled(amountX, amountY) }.wrapCrashHandling()() ?: true
override fun keyDown(keyCode: Int) =
{ super.keyDown(keyCode) }.wrapCrashHandling()() ?: true
override fun keyUp(keyCode: Int) =
{ super.keyUp(keyCode) }.wrapCrashHandling()() ?: true
override fun keyTyped(character: Char) =
{ super.keyTyped(character) }.wrapCrashHandling()() ?: true
} }

View File

@ -308,11 +308,9 @@ class CityScreen(
} }
} }
val tileMapGroup = TileGroupMap(tileGroups, stage.width / 2, stage.height / 2, tileGroupsToUnwrap = tilesToUnwrap) val tileMapGroup = TileGroupMap(tileGroups, tileGroupsToUnwrap = tilesToUnwrap)
mapScrollPane.actor = tileMapGroup mapScrollPane.actor = tileMapGroup
mapScrollPane.setSize(stage.width, stage.height) mapScrollPane.setSize(stage.width, stage.height)
mapScrollPane.setOrigin(stage.width / 2, stage.height / 2)
mapScrollPane.center(stage)
stage.addActor(mapScrollPane) stage.addActor(mapScrollPane)
mapScrollPane.layout() // center scrolling mapScrollPane.layout() // center scrolling

View File

@ -21,7 +21,7 @@ import kotlin.concurrent.thread
/* /*
Crashes are now handled from: Crashes are now handled from:
- Event listeners, by [CrashHandlingStage]. - Event listeners, by [UncivStage].
- The main rendering loop, by [UncivGame.render]. - The main rendering loop, by [UncivGame.render].
- Threads, by [crashHandlingThread]. - Threads, by [crashHandlingThread].
- Main loop runnables, by [postCrashHandlingRunnable]. - Main loop runnables, by [postCrashHandlingRunnable].

View File

@ -22,11 +22,14 @@ import kotlin.math.min
*/ */
class TileGroupMap<T: TileGroup>( class TileGroupMap<T: TileGroup>(
tileGroups: Iterable<T>, tileGroups: Iterable<T>,
private val leftAndRightPadding: Float,
private val topAndBottomPadding: Float,
worldWrap: Boolean = false, worldWrap: Boolean = false,
tileGroupsToUnwrap: Set<T>? = null tileGroupsToUnwrap: Set<T>? = null
): Group() { ): Group() {
/** If the [act] method should be performed. If this is false, every child within this [TileGroupMap] will not get their [act] method called
* and thus not perform any [com.badlogic.gdx.scenes.scene2d.Action]s.
* Most children here already do not do anything in their [act] methods. However, even iterating through all of them */
var shouldAct = true
private var topX = -Float.MAX_VALUE private var topX = -Float.MAX_VALUE
private var topY = -Float.MAX_VALUE private var topY = -Float.MAX_VALUE
private var bottomX = Float.MAX_VALUE private var bottomX = Float.MAX_VALUE
@ -72,7 +75,7 @@ class TileGroupMap<T: TileGroup>(
} }
for (group in tileGroups) { for (group in tileGroups) {
group.moveBy(-bottomX + leftAndRightPadding, -bottomY + topAndBottomPadding) group.moveBy(-bottomX, -bottomY)
} }
if (worldWrap) { if (worldWrap) {
@ -81,11 +84,11 @@ class TileGroupMap<T: TileGroup>(
mirrorTiles.first.setPosition(positionalVector.x * 0.8f * groupSize.toFloat(), mirrorTiles.first.setPosition(positionalVector.x * 0.8f * groupSize.toFloat(),
positionalVector.y * 0.8f * groupSize.toFloat()) positionalVector.y * 0.8f * groupSize.toFloat())
mirrorTiles.first.moveBy(-bottomX + leftAndRightPadding - bottomX * 2, -bottomY + topAndBottomPadding) mirrorTiles.first.moveBy(-bottomX - bottomX * 2, -bottomY )
mirrorTiles.second.setPosition(positionalVector.x * 0.8f * groupSize.toFloat(), mirrorTiles.second.setPosition(positionalVector.x * 0.8f * groupSize.toFloat(),
positionalVector.y * 0.8f * groupSize.toFloat()) positionalVector.y * 0.8f * groupSize.toFloat())
mirrorTiles.second.moveBy(-bottomX + leftAndRightPadding + bottomX * 2, -bottomY + topAndBottomPadding) mirrorTiles.second.moveBy(-bottomX + bottomX * 2, -bottomY)
} }
} }
@ -146,8 +149,8 @@ class TileGroupMap<T: TileGroup>(
// Map's width is reduced by groupSize if it is wrapped, because wrapped map will miss a tile on the right. // Map's width is reduced by groupSize if it is wrapped, because wrapped map will miss a tile on the right.
// This ensures that wrapped maps have a smooth transition. // This ensures that wrapped maps have a smooth transition.
// If map is not wrapped, Map's width doesn't need to be reduce by groupSize // If map is not wrapped, Map's width doesn't need to be reduce by groupSize
if (worldWrap) setSize(topX - bottomX + leftAndRightPadding * 2 - groupSize, topY - bottomY + topAndBottomPadding * 2) if (worldWrap) setSize(topX - bottomX - groupSize, topY - bottomY)
else setSize(topX - bottomX + leftAndRightPadding * 2, topY - bottomY + topAndBottomPadding * 2) else setSize(topX - bottomX, topY - bottomY)
} }
/** /**
@ -155,7 +158,7 @@ class TileGroupMap<T: TileGroup>(
*/ */
fun getPositionalVector(stageCoords: Vector2): Vector2 { fun getPositionalVector(stageCoords: Vector2): Vector2 {
val trueGroupSize = 0.8f * groupSize.toFloat() val trueGroupSize = 0.8f * groupSize.toFloat()
return Vector2(bottomX - leftAndRightPadding, bottomY - topAndBottomPadding) return Vector2(bottomX, bottomY)
.add(stageCoords) .add(stageCoords)
.sub(groupSize.toFloat() / 2f, groupSize.toFloat() / 2f) .sub(groupSize.toFloat() / 2f, groupSize.toFloat() / 2f)
.scl(1f / trueGroupSize) .scl(1f / trueGroupSize)
@ -166,5 +169,9 @@ class TileGroupMap<T: TileGroup>(
// For debugging purposes // For debugging purposes
override fun draw(batch: Batch?, parentAlpha: Float) { super.draw(batch, parentAlpha) } override fun draw(batch: Batch?, parentAlpha: Float) { super.draw(batch, parentAlpha) }
@Suppress("RedundantOverride") @Suppress("RedundantOverride")
override fun act(delta: Float) { super.act(delta) } override fun act(delta: Float) {
if(shouldAct) {
super.act(delta)
}
}
} }

View File

@ -24,7 +24,7 @@ class EditorMapHolder(
parentScreen: BaseScreen, parentScreen: BaseScreen,
internal val tileMap: TileMap, internal val tileMap: TileMap,
private val onTileClick: (TileInfo) -> Unit private val onTileClick: (TileInfo) -> Unit
): ZoomableScrollPane() { ): ZoomableScrollPane(20f, 20f) {
val editorScreen = parentScreen as? MapEditorScreen val editorScreen = parentScreen as? MapEditorScreen
val tileGroups = HashMap<TileInfo, List<TileGroup>>() val tileGroups = HashMap<TileInfo, List<TileGroup>>()
@ -32,7 +32,6 @@ class EditorMapHolder(
private val allTileGroups = ArrayList<TileGroup>() private val allTileGroups = ArrayList<TileGroup>()
private val maxWorldZoomOut = UncivGame.Current.settings.maxWorldZoomOut private val maxWorldZoomOut = UncivGame.Current.settings.maxWorldZoomOut
private val minZoomScale = 1f / maxWorldZoomOut
private var blinkAction: Action? = null private var blinkAction: Action? = null
@ -53,8 +52,6 @@ class EditorMapHolder(
tileGroupMap = TileGroupMap( tileGroupMap = TileGroupMap(
daTileGroups, daTileGroups,
stage.width * maxWorldZoomOut / 2,
stage.height * maxWorldZoomOut / 2,
continuousScrollingX) continuousScrollingX)
actor = tileGroupMap actor = tileGroupMap
val mirrorTileGroups = tileGroupMap.getMirrorTiles() val mirrorTileGroups = tileGroupMap.getMirrorTiles()
@ -140,11 +137,6 @@ class EditorMapHolder(
addAction(blinkAction) // Don't set it on the group because it's an actionless group addAction(blinkAction) // Don't set it on the group because it's an actionless group
} }
override fun zoom(zoomScale: Float) {
if (zoomScale < minZoomScale || zoomScale > 2f) return
setScale(zoomScale)
}
/* /*
The ScrollPane interferes with the dragging listener of MapEditorToolsDrawer. The ScrollPane interferes with the dragging listener of MapEditorToolsDrawer.
Once the ZoomableScrollPane super is initialized, there are 3 listeners + 1 capture listener: Once the ZoomableScrollPane super is initialized, there are 3 listeners + 1 capture listener:
@ -195,7 +187,7 @@ class EditorMapHolder(
if (!isPainting) return if (!isPainting) return
editorScreen!!.hideSelection() editorScreen!!.hideSelection()
val stageCoords = actor.stageToLocalCoordinates(Vector2(event!!.stageX, event.stageY)) val stageCoords = actor?.stageToLocalCoordinates(Vector2(event!!.stageX, event.stageY)) ?: return
val centerTileInfo = getClosestTileTo(stageCoords) val centerTileInfo = getClosestTileTo(stageCoords)
?: return ?: return
editorScreen.tabs.edit.paintTilesWithBrush(centerTileInfo) editorScreen.tabs.edit.paintTilesWithBrush(centerTileInfo)

View File

@ -10,9 +10,9 @@ import com.badlogic.gdx.scenes.scene2d.Stage
import com.badlogic.gdx.scenes.scene2d.ui.* import com.badlogic.gdx.scenes.scene2d.ui.*
import com.badlogic.gdx.scenes.scene2d.utils.Drawable import com.badlogic.gdx.scenes.scene2d.utils.Drawable
import com.badlogic.gdx.utils.viewport.ExtendViewport import com.badlogic.gdx.utils.viewport.ExtendViewport
import com.unciv.ui.crashhandling.CrashHandlingStage
import com.unciv.UncivGame import com.unciv.UncivGame
import com.unciv.models.Tutorial import com.unciv.models.Tutorial
import com.unciv.ui.UncivStage
import com.unciv.ui.images.ImageGetter import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popup.hasOpenPopups import com.unciv.ui.popup.hasOpenPopups
import com.unciv.ui.tutorials.TutorialController import com.unciv.ui.tutorials.TutorialController
@ -32,7 +32,7 @@ abstract class BaseScreen : Screen {
val height = resolutions[1] val height = resolutions[1]
/** The ExtendViewport sets the _minimum_(!) world size - the actual world size will be larger, fitted to screen/window aspect ratio. */ /** The ExtendViewport sets the _minimum_(!) world size - the actual world size will be larger, fitted to screen/window aspect ratio. */
stage = CrashHandlingStage(ExtendViewport(height, height), SpriteBatch()) stage = UncivStage(ExtendViewport(height, height), SpriteBatch())
if (enableSceneDebug) { if (enableSceneDebug) {
stage.setDebugUnderMouse(true) stage.setDebugUnderMouse(true)

View File

@ -1,22 +1,122 @@
package com.unciv.ui.utils package com.unciv.ui.utils
import com.badlogic.gdx.math.Rectangle
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.InputEvent import com.badlogic.gdx.scenes.scene2d.InputEvent
import com.badlogic.gdx.scenes.scene2d.InputListener import com.badlogic.gdx.scenes.scene2d.InputListener
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
import com.badlogic.gdx.scenes.scene2d.utils.ActorGestureListener import com.badlogic.gdx.scenes.scene2d.utils.ActorGestureListener
import com.badlogic.gdx.scenes.scene2d.utils.Cullable
import kotlin.math.sqrt import kotlin.math.sqrt
open class ZoomableScrollPane : ScrollPane(null) { open class ZoomableScrollPane(
val extraCullingX: Float = 0f,
val extraCullingY: Float = 0f,
var minZoom: Float = 0.5f,
var maxZoom: Float = 1 / minZoom // if we can halve the size, then by default also allow to double it
) : ScrollPane(null) {
var continuousScrollingX = false var continuousScrollingX = false
init{ var onViewportChangedListener: ((width: Float, height: Float, viewport: Rectangle) -> Unit)? = null
var onPanStopListener: (() -> Unit)? = null
var onPanStartListener: (() -> Unit)? = null
/**
* Exists so that we are always able to set the center to the edge of the contained actor.
* Otherwise, the [ScrollPane] would always stop at the actor's edge, keeping the center always ([width or height]/2) away from the edge.
* This is lateinit because unfortunately [ScrollPane] uses [setActor] in its constructor, and we override [setActor], so paddingGroup has not been
* constructed at that moment, throwing a NPE.
*/
@Suppress("UNNECESSARY_LATEINIT")
private lateinit var paddingGroup: Group
private val horizontalPadding get() = width / 2
private val verticalPadding get() = height / 2
init {
paddingGroup = Group()
super.setActor(paddingGroup)
addZoomListeners() addZoomListeners()
} }
override fun setActor(actor: Actor?) {
if (!this::paddingGroup.isInitialized) return
paddingGroup.clearChildren()
paddingGroup.addActor(actor)
}
override fun getActor(): Actor? {
if (!this::paddingGroup.isInitialized || !paddingGroup.hasChildren()) return null
return paddingGroup.children[0]
}
override fun scrollX(pixelsX: Float) {
super.scrollX(pixelsX)
updateCulling()
onViewportChanged()
}
override fun scrollY(pixelsY: Float) {
super.scrollY(pixelsY)
updateCulling()
onViewportChanged()
}
override fun sizeChanged() {
updatePadding()
super.sizeChanged()
updateCulling()
}
private fun updatePadding() {
val content = actor
if (content == null) return
// Padding is always [dimension / 2] because we want to be able to have the center of the scrollPane at the very edge of the content
content.x = horizontalPadding
paddingGroup.width = content.width + horizontalPadding * 2
content.y = verticalPadding
paddingGroup.height = content.height + verticalPadding * 2
}
fun updateCulling() {
val content = actor
if (content !is Cullable) return
fun Rectangle.addInAllDirections(xDirectionIncrease: Float, yDirectionIncrease: Float): Rectangle {
x -= xDirectionIncrease
y -= yDirectionIncrease
width += xDirectionIncrease * 2
height += yDirectionIncrease * 2
return this
}
content.setCullingArea(
getViewport().addInAllDirections(extraCullingX, extraCullingY)
)
}
open fun zoom(zoomScale: Float) { open fun zoom(zoomScale: Float) {
if (zoomScale < 0.5f || zoomScale > 2f) return if (zoomScale < minZoom || zoomScale > maxZoom) return
val previousScaleX = scaleX
val previousScaleY = scaleY
setScale(zoomScale) setScale(zoomScale)
// When we scale, the width & height values stay the same. However, after scaling up/down, the width will be rendered wider/narrower than before.
// But we want to keep the size of the pane the same, so we do need to adjust the width & height: smaller if the scale increased, larger if it decreased.
val newWidth = width * previousScaleX / zoomScale
val newHeight = height * previousScaleY / zoomScale
setSize(newWidth, newHeight)
onViewportChanged()
// The size increase/decrease kept scrollX and scrollY (i.e. the top edge and left edge) the same - but changing the scale & size should have changed
// where the right and bottom edges are. This would mean our visual center moved. To correct this, we theoretically need to update the scroll position
// by half (i.e. middle) of what our size changed.
// However, we also changed the padding, which is exactly equal to half of our size change, so we actually don't need to move our center at all.
} }
fun zoomIn() { fun zoomIn() {
zoom(scaleX / 0.8f) zoom(scaleX / 0.8f)
@ -57,7 +157,12 @@ open class ZoomableScrollPane : ScrollPane(null) {
//This is mostly just Java code from the ScrollPane class reimplemented as Kotlin code //This is mostly just Java code from the ScrollPane class reimplemented as Kotlin code
//Had to change a few things to bypass private access modifiers //Had to change a few things to bypass private access modifiers
return object : ActorGestureListener() { return object : ActorGestureListener() {
private var wasPanning = false
override fun pan(event: InputEvent, x: Float, y: Float, deltaX: Float, deltaY: Float) { override fun pan(event: InputEvent, x: Float, y: Float, deltaX: Float, deltaY: Float) {
if (!wasPanning) {
wasPanning = true
onPanStartListener?.invoke()
}
setScrollbarsVisible(true) setScrollbarsVisible(true)
scrollX -= deltaX scrollX -= deltaX
scrollY += deltaY scrollY += deltaY
@ -76,6 +181,27 @@ open class ZoomableScrollPane : ScrollPane(null) {
if ((isScrollX && deltaX != 0f || isScrollY && deltaY != 0f)) cancelTouchFocus() if ((isScrollX && deltaX != 0f || isScrollY && deltaY != 0f)) cancelTouchFocus()
} }
override fun panStop(event: InputEvent?, x: Float, y: Float, pointer: Int, button: Int) {
wasPanning = false
onPanStopListener?.invoke()
}
} }
} }
/** @return the currently scrolled-to viewport of the whole scrollable area */
fun getViewport(): Rectangle {
val viewportFromLeft = scrollX
/** In the default coordinate system, the y origin is at the bottom, but scrollY is from the top, so we need to invert. */
val viewportFromBottom = maxY - scrollY
return Rectangle(
viewportFromLeft - horizontalPadding,
viewportFromBottom - verticalPadding,
width,
height)
}
private fun onViewportChanged() {
onViewportChangedListener?.invoke(maxX, maxY, getViewport())
}
} }

View File

@ -7,7 +7,11 @@ import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.g2d.Batch import com.badlogic.gdx.graphics.g2d.Batch
import com.badlogic.gdx.math.Interpolation import com.badlogic.gdx.math.Interpolation
import com.badlogic.gdx.math.Vector2 import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.* 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.InputEvent
import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.actions.Actions import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.actions.FloatAction import com.badlogic.gdx.scenes.scene2d.actions.FloatAction
import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.Table
@ -21,10 +25,14 @@ import com.unciv.logic.battle.Battle
import com.unciv.logic.battle.MapUnitCombatant import com.unciv.logic.battle.MapUnitCombatant
import com.unciv.logic.city.CityInfo import com.unciv.logic.city.CityInfo
import com.unciv.logic.civilization.CivilizationInfo import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.* import com.unciv.logic.map.MapUnit
import com.unciv.models.* import com.unciv.logic.map.TileInfo
import com.unciv.logic.map.TileMap
import com.unciv.models.AttackableTile
import com.unciv.models.UncivSound
import com.unciv.models.helpers.MapArrowType import com.unciv.models.helpers.MapArrowType
import com.unciv.models.helpers.MiscArrowTypes import com.unciv.models.helpers.MiscArrowTypes
import com.unciv.ui.UncivStage
import com.unciv.ui.audio.Sounds import com.unciv.ui.audio.Sounds
import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.launchCrashHandling
import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.crashhandling.postCrashHandlingRunnable
@ -33,11 +41,21 @@ import com.unciv.ui.map.TileGroupMap
import com.unciv.ui.tilegroups.TileGroup import com.unciv.ui.tilegroups.TileGroup
import com.unciv.ui.tilegroups.TileSetStrings import com.unciv.ui.tilegroups.TileSetStrings
import com.unciv.ui.tilegroups.WorldTileGroup import com.unciv.ui.tilegroups.WorldTileGroup
import com.unciv.ui.utils.* import com.unciv.ui.utils.UnitGroup
import com.unciv.ui.utils.ZoomableScrollPane
import com.unciv.ui.utils.center
import com.unciv.ui.utils.colorFromRGB
import com.unciv.ui.utils.darken
import com.unciv.ui.utils.onClick
import com.unciv.ui.utils.surroundWithCircle
import com.unciv.ui.utils.toLabel
import com.unciv.utils.Log import com.unciv.utils.Log
class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap: TileMap): ZoomableScrollPane() { class WorldMapHolder(
internal val worldScreen: WorldScreen,
internal val tileMap: TileMap
) : ZoomableScrollPane(20f, 20f) {
internal var selectedTile: TileInfo? = null internal var selectedTile: TileInfo? = null
val tileGroups = HashMap<TileInfo, List<WorldTileGroup>>() val tileGroups = HashMap<TileInfo, List<WorldTileGroup>>()
@ -50,19 +68,40 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap
private val unitMovementPaths: HashMap<MapUnit, ArrayList<TileInfo>> = HashMap() private val unitMovementPaths: HashMap<MapUnit, ArrayList<TileInfo>> = HashMap()
private var maxWorldZoomOut = 2f private lateinit var tileGroupMap: TileGroupMap<WorldTileGroup>
private var minZoomScale = 0.5f
init { init {
if (Gdx.app.type == Application.ApplicationType.Desktop) this.setFlingTime(0f) if (Gdx.app.type == Application.ApplicationType.Desktop) this.setFlingTime(0f)
continuousScrollingX = tileMap.mapParameters.worldWrap continuousScrollingX = tileMap.mapParameters.worldWrap
reloadMaxZoom() reloadMaxZoom()
disablePointerEventsAndActionsOnPan()
}
/**
* When scrolling the world map, there are two unnecessary (at least currently) things happening that take a decent amount of time:
*
* 1. Checking which [Actor]'s bounds the pointer (mouse/finger) entered+exited and sending appropriate events to these actors
* 2. Running all [Actor.act] methods of all child [Actor]s
*
* Disabling them while panning increases the frame rate while panning by approximately 100%.
*/
private fun disablePointerEventsAndActionsOnPan() {
onPanStartListener = {
Log.debug("Disable pointer enter/exit events & TileGroupMap.act()")
(stage as UncivStage).performPointerEnterExitEvents = false
tileGroupMap.shouldAct = false
}
onPanStopListener = {
Log.debug("Enable pointer enter/exit events & TileGroupMap.act()")
(stage as UncivStage).performPointerEnterExitEvents = true
tileGroupMap.shouldAct = true
}
} }
internal fun reloadMaxZoom() { internal fun reloadMaxZoom() {
maxWorldZoomOut = UncivGame.Current.settings.maxWorldZoomOut maxZoom = UncivGame.Current.settings.maxWorldZoomOut
minZoomScale = 1f / maxWorldZoomOut minZoom = 1f / maxZoom
if (scaleX < minZoomScale) zoom(1f) // since normally min isn't reached exactly, only powers of 0.8 if (scaleX < minZoom) zoom(1f) // since normally min isn't reached exactly, only powers of 0.8
} }
// Interface for classes that contain the data required to draw a button // Interface for classes that contain the data required to draw a button
@ -75,10 +114,8 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap
internal fun addTiles() { internal fun addTiles() {
val tileSetStrings = TileSetStrings() val tileSetStrings = TileSetStrings()
val daTileGroups = tileMap.values.map { WorldTileGroup(worldScreen, it, tileSetStrings) } val daTileGroups = tileMap.values.map { WorldTileGroup(worldScreen, it, tileSetStrings) }
val tileGroupMap = TileGroupMap( tileGroupMap = TileGroupMap(
daTileGroups, daTileGroups,
worldScreen.stage.width * maxWorldZoomOut / 2,
worldScreen.stage.height * maxWorldZoomOut / 2,
continuousScrollingX) continuousScrollingX)
val mirrorTileGroups = tileGroupMap.getMirrorTiles() val mirrorTileGroups = tileGroupMap.getMirrorTiles()
@ -125,12 +162,9 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap
actor = tileGroupMap actor = tileGroupMap
setSize(worldScreen.stage.width * maxWorldZoomOut, worldScreen.stage.height * maxWorldZoomOut) setSize(worldScreen.stage.width, worldScreen.stage.height)
setOrigin(width / 2, height / 2)
center(worldScreen.stage)
layout() // Fit the scroll pane to the contents - otherwise, setScroll won't work! layout() // Fit the scroll pane to the contents - otherwise, setScroll won't work!
} }
private fun onTileClicked(tileInfo: TileInfo) { private fun onTileClicked(tileInfo: TileInfo) {
@ -684,12 +718,10 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap
val originalScrollX = scrollX val originalScrollX = scrollX
val originalScrollY = scrollY val originalScrollY = scrollY
// We want to center on the middle of the TileGroup (TG.getX()+TG.getWidth()/2) val finalScrollX = tileGroup.x + tileGroup.width / 2
// and so the scroll position (== filter the screen starts) needs to be half the ScrollMap away
val finalScrollX = tileGroup.x + tileGroup.width / 2 - width / 2
// Here it's the same, only the Y axis is inverted - when at 0 we're at the top, not bottom - so we invert it back. /** The Y axis of [scrollY] is inverted - when at 0 we're at the top, not bottom - so we invert it back. */
val finalScrollY = maxY - (tileGroup.y + tileGroup.width / 2 - height / 2) val finalScrollY = maxY - (tileGroup.y + tileGroup.width / 2)
if (finalScrollX == originalScrollX && finalScrollY == originalScrollY) return false if (finalScrollX == originalScrollX && finalScrollY == originalScrollY) return false
@ -723,20 +755,29 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap
} }
override fun zoom(zoomScale: Float) { override fun zoom(zoomScale: Float) {
if (zoomScale < minZoomScale || zoomScale > 2f) return super.zoom(zoomScale)
setScale(zoomScale)
val scale = 1 / scaleX // don't use zoomScale itself, in case it was out of bounds and not applied clampCityButtonSize()
if (scale >= 1) }
for (tileGroup in allWorldTileGroups)
/** We don't want the city buttons becoming too large when zooming out */
private fun clampCityButtonSize() {
// use scaleX instead of zoomScale itself, because zoomScale might have been outside minZoom..maxZoom and thus not applied
val clampedCityButtonZoom = 1 / scaleX
if (clampedCityButtonZoom >= 1) {
for (tileGroup in allWorldTileGroups) {
tileGroup.cityButtonLayerGroup.isTransform = false // to save on rendering time to improve framerate tileGroup.cityButtonLayerGroup.isTransform = false // to save on rendering time to improve framerate
if (scale < 1 && scale >= minZoomScale) }
}
if (clampedCityButtonZoom < 1 && clampedCityButtonZoom >= minZoom) {
for (tileGroup in allWorldTileGroups) { for (tileGroup in allWorldTileGroups) {
// ONLY set those groups that have active city buttons as transformable! // ONLY set those groups that have active city buttons as transformable!
// This is massively framerate-improving! // This is massively framerate-improving!
if (tileGroup.cityButtonLayerGroup.hasChildren()) if (tileGroup.cityButtonLayerGroup.hasChildren())
tileGroup.cityButtonLayerGroup.isTransform = true tileGroup.cityButtonLayerGroup.isTransform = true
tileGroup.cityButtonLayerGroup.setScale(scale) tileGroup.cityButtonLayerGroup.setScale(clampedCityButtonZoom)
} }
}
} }
fun removeUnitActionOverlay() { fun removeUnitActionOverlay() {

View File

@ -615,10 +615,12 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
// This is not the case if you have a multiplayer game where you play as 2 civs // This is not the case if you have a multiplayer game where you play as 2 civs
if (newWorldScreen.viewingCiv.civName == viewingCiv.civName) { if (newWorldScreen.viewingCiv.civName == viewingCiv.civName) {
newWorldScreen.mapHolder.scrollX = mapHolder.scrollX newWorldScreen.mapHolder.width = mapHolder.width
newWorldScreen.mapHolder.scrollY = mapHolder.scrollY newWorldScreen.mapHolder.height = mapHolder.height
newWorldScreen.mapHolder.scaleX = mapHolder.scaleX newWorldScreen.mapHolder.scaleX = mapHolder.scaleX
newWorldScreen.mapHolder.scaleY = mapHolder.scaleY newWorldScreen.mapHolder.scaleY = mapHolder.scaleY
newWorldScreen.mapHolder.scrollX = mapHolder.scrollX
newWorldScreen.mapHolder.scrollY = mapHolder.scrollY
newWorldScreen.mapHolder.updateVisualScroll() newWorldScreen.mapHolder.updateVisualScroll()
} }
@ -850,7 +852,6 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
} }
// topBar.selectedCivLabel.setText(Gdx.graphics.framesPerSecond) // for framerate testing // topBar.selectedCivLabel.setText(Gdx.graphics.framesPerSecond) // for framerate testing
minimapWrapper.minimap.updateScrollPosition()
super.render(delta) super.render(delta)
} }

View File

@ -10,7 +10,6 @@ import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.MapShape import com.unciv.logic.map.MapShape
import com.unciv.logic.map.MapSize import com.unciv.logic.map.MapSize
import com.unciv.ui.images.ClippingImage import com.unciv.ui.images.ClippingImage
import com.unciv.ui.utils.*
import com.unciv.ui.images.ImageGetter import com.unciv.ui.images.ImageGetter
import com.unciv.ui.worldscreen.WorldMapHolder import com.unciv.ui.worldscreen.WorldMapHolder
import kotlin.math.max import kotlin.math.max
@ -56,6 +55,8 @@ class Minimap(val mapHolder: WorldMapHolder, minimapSize: Int) : Group() {
setSize(tileLayer.width, tileLayer.height) setSize(tileLayer.width, tileLayer.height)
addActor(tileLayer) addActor(tileLayer)
mapHolder.onViewportChangedListener = ::updateScrollPosition
} }
private fun calcTileSize(minimapSize: Int): Float { private fun calcTileSize(minimapSize: Int): Float {
@ -96,30 +97,20 @@ class Minimap(val mapHolder: WorldMapHolder, minimapSize: Int) : Group() {
} }
/**### Transform and set coordinates for the scrollPositionIndicator. /**### 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. * Requires [scrollPositionIndicator] to be a [ClippingImage] to keep the displayed portion of the indicator within the bounds of the minimap.
*/ */
fun updateScrollPosition() { private fun updateScrollPosition(worldWidth: Float, worldHeight: Float, worldViewport: Rectangle) {
// 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) 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) { fun Actor.setViewport(rect: Rectangle) {
x = rect.x; y = rect.y; width = rect.width; height = rect.height x = rect.x;
y = rect.y;
width = rect.width;
height = rect.height
} }
val worldToMiniFactor = Vector2(tileLayer.width / mapHolder.maxX, tileLayer.height / mapHolder.maxY) val worldToMiniFactor = Vector2(tileLayer.width / worldWidth, tileLayer.height / worldHeight)
val worldVisibleArea = Vector2(mapHolder.width / 2 / mapHolder.scaleX, mapHolder.height / 2 / mapHolder.scaleY) val miniViewport = worldViewport * worldToMiniFactor
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 // 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) scrollPositionIndicators[0].setViewport(miniViewport)