Refactor the "loading image" from Multiplayer into a reusable Widget (#10262)

* Refactor the "loading image" from Multiplayer into a reusable Widget

* Loading indicator - don't talk about Multiplatypus, box params in a Style
This commit is contained in:
SomeTroglodyte
2023-11-07 09:40:44 +01:00
committed by GitHub
parent 12a4d129e7
commit 7e35e5668b
2 changed files with 257 additions and 54 deletions

View File

@ -0,0 +1,245 @@
@file:OptIn(ExperimentalTime::class)
package com.unciv.ui.components.widgets
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.actions.TemporalAction
import com.badlogic.gdx.scenes.scene2d.ui.CheckBox
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup
import com.badlogic.gdx.utils.Align
import com.badlogic.gdx.utils.Disposable
import com.unciv.GUI
import com.unciv.ui.components.extensions.center
import com.unciv.ui.components.extensions.setSize
import com.unciv.ui.components.input.onChange
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.widgets.LoadingImage.Style
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import kotlin.math.absoluteValue
import kotlin.time.ExperimentalTime
import kotlin.time.TimeMark
import kotlin.time.TimeSource
/** Animated "double arrow" loading icon.
*
* * By default, shows an empty transparent square, or a circle and/or an "idle" icon.
* * When [show] is called, the double-arrow loading icon is faded in and rotates.
* * When [hide] is called, the double-arrow fades out.
* * When [Style.minShowTime] is set, [hide] will make sure the "busy status" can be seen even if it was very short.
* * When GameSettings.continuousRendering is off, fade and rotation animations are disabled.
* * [animated] is public and can be used to override the 'continuousRendering' setting.
*
* @param size Fixed forced size: prefWidth, minWidth, maxWidth and height counterparts will all return this.
* @param style Contains the visual and behavioral parameters
*/
//region fields
class LoadingImage(
private val size: Float = 40f,
private val style: Style = Style(),
) : WidgetGroup(), Disposable {
// Note: Somewhat similar to IconCircleGroup - different alpha handling
// Also similar to Stack, but since size is fixed, done much simpler
private val circle: Image?
private val idleIcon: Image?
private val loadingIcon: Image
var animated = GUI.getSettings().continuousRendering
private var loadingStarted: TimeMark? = null
//endregion
data class Style(
/** If not CLEAR, a Circle with this Color is layered at the bottom and the icons are resized to [innerSizeFactor] * `size` */
val circleColor: Color = Color.CLEAR,
/** Color for the animated "Loading" icon (drawn on top) */
val loadingColor: Color = Color.WHITE,
/** If not CLEAR, another icon is layered between circle and loading, e.g. symbolizing 'idle' or 'done' */
val idleIconColor: Color = Color.CLEAR,
/** Minimum shown time in ms including fades */
val minShowTime: Int = 0,
/** Texture name for the circle */
val circleImageName: String = "OtherIcons/Circle",
/** Texture name for the idle icon */
val idleImageName: String = "OtherIcons/whiteDot",
/** Texture name for the loading icon */
val loadingImageName: String = "OtherIcons/Loading",
/** Size scale for icons when a Circle is used */
val innerSizeFactor: Float = 0.75f,
/** duration of fade-in and fade-out in seconds */
val fadeDuration: Float = 0.2f,
/** duration of rotation - seconds per revolution */
val rotationDuration: Float = 4f,
/** While loading is shown, the idle icon is semitransparent */
val idleIconHiddenAlpha: Float = 0.4f
)
init {
isTransform = false
setSize(size, size)
val innerSize: Float
if (style.circleColor == Color.CLEAR) {
circle = null
innerSize = size
} else {
circle = ImageGetter.getImage(style.circleImageName)
circle.color = style.circleColor
circle.setSize(size)
addActor(circle)
innerSize = size * style.innerSizeFactor
}
if (style.idleIconColor == Color.CLEAR) {
idleIcon = null
} else {
idleIcon = ImageGetter.getImage(style.idleImageName)
idleIcon.color = style.idleIconColor
idleIcon.setSize(innerSize)
idleIcon.center(this)
addActor(idleIcon)
}
loadingIcon = ImageGetter.getImage(style.loadingImageName).apply {
color = style.loadingColor
color.a = 0f
setSize(innerSize)
setOrigin(Align.center)
isVisible = false
}
loadingIcon.center(this)
addActor(loadingIcon)
}
fun show() {
loadingStarted = TimeSource.Monotonic.markNow()
loadingIcon.isVisible = true
actions.clear()
if (animated) {
actions.add(FadeoverAction(1f, 0f), SpinAction())
} else {
loadingIcon.color.a = 1f
idleIcon?.color?.a = style.idleIconHiddenAlpha
}
}
fun hide(onComplete: (() -> Unit)? = null) =
if (animated) hideAnimated(onComplete)
else hideDelayed(onComplete)
//region Hiding helpers
private fun hideAnimated(onComplete: (() -> Unit)?) {
actions.clear()
actions.add(FadeoverAction(0f, getWaitDuration() - 2 * style.fadeDuration, onComplete))
}
private fun hideDelayed(onComplete: (() -> Unit)?) {
val waitDuration = getWaitDuration()
if (waitDuration == 0f) return setHidden()
actions.clear()
actions.add(Actions.delay(waitDuration, Actions.run {
setHidden()
onComplete?.invoke()
}))
}
private fun setHidden() {
actions.clear()
loadingIcon.isVisible = false
loadingIcon.color.a = 0f
idleIcon?.color?.a = 1f
}
private fun getWaitDuration(): Float {
val elapsed = loadingStarted?.elapsedNow()?.inWholeMilliseconds ?: 0
if (elapsed >= style.minShowTime) return 0f
return (style.minShowTime - elapsed) * 0.001f
}
//endregion
//region Widget API
override fun getPrefWidth() = size
override fun getPrefHeight() = size
override fun getMaxWidth() = size
override fun getMaxHeight() = size
//endregion
override fun dispose() {
clearActions()
}
private inner class FadeoverAction(
private val endAlpha: Float,
delay: Float,
private val onComplete: (() -> Unit)? = null
) : TemporalAction(style.fadeDuration) {
private var startAlpha = 0f
private var totalChange = 1f
init {
if (delay > 0f) time = -delay
}
override fun update(percent: Float) {
if (percent < 0f) return
val alpha = startAlpha + percent * totalChange
loadingIcon.color.a = alpha
if (idleIcon == null) return
idleIcon.color.a = (1f - alpha) * (1f - style.idleIconHiddenAlpha) + style.idleIconHiddenAlpha
}
override fun begin() {
startAlpha = loadingIcon.color.a
totalChange = endAlpha - startAlpha
duration = style.fadeDuration * totalChange.absoluteValue
}
override fun end() {
if (endAlpha == 0f) setHidden()
onComplete?.invoke()
}
}
private inner class SpinAction : TemporalAction(style.rotationDuration) {
override fun update(percent: Float) {
// The arrows point clockwise, but Actor.rotation is counterclockwise: negate.
// Mapping to the 0..360 range is defensive, Actor itself doesn't care.
loadingIcon.rotation = 360f * (1f - percent)
}
override fun end() {
restart()
}
}
@Suppress("unused") // Used only temporarily for FasterUIDevelopment.DevElement
object Testing {
fun getFasterUIDevelopmentTester() = Table().apply {
val testee = LoadingImage(52f, Style(
circleColor = Color.NAVY,
loadingColor = Color.SCARLET,
idleIconColor = Color.CYAN,
idleImageName = "OtherIcons/Multiplayer",
minShowTime = 1500))
defaults().pad(10f).center()
add(testee)
add(TextButton("Start", BaseScreen.skin).onClick {
testee.show()
})
add(TextButton("Stop", BaseScreen.skin).onClick {
testee.hide()
})
row()
val check = CheckBox(" animated ", BaseScreen.skin)
check.isChecked = testee.animated
check.onChange { testee.animated = check.isChecked }
add(check).colspan(3)
pack()
}
}
}

View File

@ -2,14 +2,11 @@ package com.unciv.ui.screens.worldscreen.status
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.ui.Button
import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.Stack
import com.badlogic.gdx.utils.Align
import com.badlogic.gdx.utils.Disposable
import com.unciv.UncivGame
import com.unciv.logic.event.EventBus
@ -20,35 +17,35 @@ import com.unciv.logic.multiplayer.MultiplayerGameUpdateStarted
import com.unciv.logic.multiplayer.MultiplayerGameUpdated
import com.unciv.logic.multiplayer.OnlineMultiplayerGame
import com.unciv.logic.multiplayer.isUsersTurn
import com.unciv.ui.components.extensions.setSize
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.widgets.LoadingImage
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.extensions.setSize
import com.unciv.utils.Concurrency
import com.unciv.utils.launchOnGLThread
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import java.time.Duration
import java.time.Instant
class MultiplayerStatusButton(
screen: BaseScreen,
curGame: OnlineMultiplayerGame?
) : Button(BaseScreen.skin), Disposable {
private var curGameName = curGame?.name
private val multiplayerImage = createMultiplayerImage()
private val loadingImage = createLoadingImage()
private val loadingImage = LoadingImage(style = LoadingImage.Style(
idleImageName = "OtherIcons/Multiplayer",
idleIconColor = Color.WHITE,
minShowTime = 500
))
private val turnIndicator = TurnIndicator()
private val turnIndicatorCell: Cell<Actor>
private val gameNamesWithCurrentTurn = getInitialGamesWithCurrentTurn()
private var loadingStarted: Instant? = null
private val events = EventBus.EventReceiver()
private var loadStopJob: Job? = null
init {
turnIndicatorCell = add().padTop(10f).padBottom(10f)
add(Stack(multiplayerImage, loadingImage)).pad(5f)
add(loadingImage).pad(5f)
updateTurnIndicator(flash = false) // no flash since this is just the initial construction
events.receive(MultiplayerGameUpdated::class) {
@ -77,35 +74,11 @@ class MultiplayerStatusButton(
}
private fun startLoading() {
loadingStarted = Instant.now()
if (UncivGame.Current.settings.continuousRendering) {
loadingImage.clearActions()
loadingImage.addAction(Actions.forever(Actions.rotateBy(-90f, 1f)))
}
loadingImage.isVisible = true
multiplayerImage.color.a = 0.4f
loadingImage.show()
}
private fun stopLoading() {
val loadingTime = Duration.between(loadingStarted ?: Instant.now(), Instant.now())
val waitFor = if (loadingTime.toMillis() < 500) {
// Some servers might reply almost instantly. That's nice and all, but the user will just see a blinking icon in that case
// and won't be able to make out what it was. So we just show the loading indicator a little longer even though it's already done.
Duration.ofMillis(500 - loadingTime.toMillis())
} else {
Duration.ZERO
}
loadStopJob = Concurrency.run("Hide loading indicator") {
delay(waitFor.toMillis())
launchOnGLThread {
loadingImage.clearActions()
loadingImage.isVisible = false
multiplayerImage.color.a = 1f
}
}
loadingImage.hide()
}
private fun getInitialGamesWithCurrentTurn(): MutableSet<String> {
@ -121,21 +94,6 @@ class MultiplayerStatusButton(
.toMutableSet()
}
private fun createMultiplayerImage(): Image {
val img = ImageGetter.getImage("OtherIcons/Multiplayer")
img.setSize(40f)
return img
}
private fun createLoadingImage(): Image {
val img = ImageGetter.getImage("OtherIcons/Loading")
img.setSize(40f)
img.isVisible = false
img.setOrigin(Align.center)
return img
}
private fun updateTurnIndicator(flash: Boolean = true) {
if (gameNamesWithCurrentTurn.size == 0) {
turnIndicatorCell.clearActor()
@ -153,7 +111,7 @@ class MultiplayerStatusButton(
override fun dispose() {
events.stopReceiving()
turnIndicator.dispose()
loadStopJob?.cancel()
loadingImage.dispose()
}
}