mirror of
https://github.com/yairm210/Unciv.git
synced 2025-07-16 10:49:17 +07:00
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:
245
core/src/com/unciv/ui/components/widgets/LoadingImage.kt
Normal file
245
core/src/com/unciv/ui/components/widgets/LoadingImage.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user