add badge to display unread messages count, and set flash time to 5s, isolate flash logic in a new class AlternatingStateManager

This commit is contained in:
Md. Touhidur Rahman 2025-09-14 16:13:07 +06:00
parent 437f89bffc
commit 16c561b9a1
No known key found for this signature in database
GPG Key ID: 431978882FE25058
5 changed files with 151 additions and 79 deletions

View File

@ -0,0 +1,52 @@
package com.unciv.logic
import com.unciv.utils.Concurrency
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime
class AlternatingStateManager<State>(
private val name: String,
private val state: State,
private val originalState: (State) -> Unit,
private val alternateState: (State) -> Unit
) {
private var job: Job? = null
@OptIn(ExperimentalTime::class)
fun start(
duration: Duration = 5.seconds, interval: Duration = 500.milliseconds
) {
job?.cancel()
job = Concurrency.run(name) {
val startTime = Clock.System.now()
var isAlternateState = true
while (true) {
if (isAlternateState) {
alternateState(state)
isAlternateState = false
} else {
originalState(state)
isAlternateState = true
}
if (Clock.System.now() - startTime >= duration) {
originalState(state)
return@run
}
delay(interval)
}
}
}
fun stop() {
job?.cancel()
originalState(state)
}
}

View File

@ -12,7 +12,7 @@ import java.util.UUID
data class Chat( data class Chat(
val gameId: UUID, val gameId: UUID,
) { ) {
var read = true var unreadCount = 0
// <civName, message> pairs // <civName, message> pairs
private val messages: MutableList<Pair<String, String>> = mutableListOf(INITIAL_MESSAGE) private val messages: MutableList<Pair<String, String>> = mutableListOf(INITIAL_MESSAGE)
@ -100,13 +100,13 @@ object ChatStore {
if (gameId.equals(UncivGame.Current.worldScreen?.gameInfo?.gameId?.toUUIDOrNull())) { if (gameId.equals(UncivGame.Current.worldScreen?.gameInfo?.gameId?.toUUIDOrNull())) {
// ensures that you are not getting notified for your own messages // ensures that you are not getting notified for your own messages
if (UncivGame.Current.worldScreen?.gameInfo?.currentPlayer != incomingChatMsg.civName) { if (UncivGame.Current.worldScreen?.gameInfo?.currentPlayer != incomingChatMsg.civName) {
chat.read = false chat.unreadCount++
UncivGame.Current.worldScreen?.chatButton?.startFlashing() UncivGame.Current.worldScreen?.chatButton?.triggerChatIndication()
} }
} else { } else {
// user is out of world screen or // user is out of world screen or
// some other game not currently on screen has a message // some other game not currently on screen has a message
chat.read = false chat.unreadCount++
} }
} }
} }
@ -126,7 +126,7 @@ object ChatStore {
Gdx.app.postRunnable { Gdx.app.postRunnable {
if (civName != "System") { if (civName != "System") {
hasGlobalMessage = chatPopup == null hasGlobalMessage = chatPopup == null
if (hasGlobalMessage) UncivGame.Current.worldScreen?.chatButton?.startFlashing() if (hasGlobalMessage) UncivGame.Current.worldScreen?.chatButton?.triggerChatIndication()
} }
chatPopup?.addMessage(civName, message, suffix = "one time") chatPopup?.addMessage(civName, message, suffix = "one time")

View File

@ -2,36 +2,88 @@ package com.unciv.ui.screens.worldscreen.chat
import com.badlogic.gdx.Gdx import com.badlogic.gdx.Gdx
import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.utils.Disposable import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame import com.unciv.UncivGame
import com.unciv.logic.AlternatingStateManager
import com.unciv.logic.multiplayer.chat.ChatStore import com.unciv.logic.multiplayer.chat.ChatStore
import com.unciv.logic.multiplayer.chat.ChatWebSocket import com.unciv.logic.multiplayer.chat.ChatWebSocket
import com.unciv.logic.multiplayer.chat.Message import com.unciv.logic.multiplayer.chat.Message
import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.onClick import com.unciv.ui.components.input.onClick
import com.unciv.ui.images.IconTextButton import com.unciv.ui.images.IconTextButton
import com.unciv.ui.images.ImageGetter import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.worldscreen.WorldScreen import com.unciv.ui.screens.worldscreen.WorldScreen
import com.unciv.utils.Concurrency
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
class ChatButton(val worldScreen: WorldScreen) : IconTextButton( class ChatButton(val worldScreen: WorldScreen) : IconTextButton(
"Chat", ImageGetter.getImage("OtherIcons/Chat"), 23 "Chat", ImageGetter.getImage("OtherIcons/Chat"), 23
), Disposable { ) {
val chat = ChatStore.getChatByGameId(worldScreen.gameInfo.gameId) val chat = ChatStore.getChatByGameId(worldScreen.gameInfo.gameId)
val badge = "".toTextButton().apply {
disable()
setColor(Color.valueOf("da1e28"))
label.setColor(Color.WHITE)
label.setAlignment(Align.center)
label.setFontScale(0.2f)
}
val flash = AlternatingStateManager(
name = "ChatButton color flash",
state = object {
val initialColor = fontColor
val targetColor = Color.ORANGE
}, originalState = { state ->
Gdx.app.postRunnable {
icon?.color = state.initialColor
label.color = state.initialColor
}
}, alternateState = { state ->
Gdx.app.postRunnable {
icon?.color = state.targetColor
label.color = state.targetColor
}
}
)
private fun updateBadge(visible: Boolean = false) {
badge.height = height / 3
badge.setPosition(
width - badge.width / 1.5f,
height - badge.height / 1.5f
)
badge.isVisible = visible || chat.unreadCount > 0 || ChatStore.hasGlobalMessage
if (badge.isVisible) {
var text = chat.unreadCount.toString()
if (ChatStore.hasGlobalMessage) {
text += '+'
}
badge.setText(text)
}
}
fun triggerChatIndication() {
updateBadge()
flash.start()
}
init { init {
width = 95f width = 95f
iconCell.pad(3f).center() iconCell.pad(3f).center()
addActor(badge)
if (!chat.read || ChatStore.hasGlobalMessage) { if (chat.unreadCount > 0 || ChatStore.hasGlobalMessage) {
startFlashing() updateBadge(true)
flash.stop()
} }
onClick { onClick {
stopFlashing() chat.unreadCount = 0
updateBadge()
flash.stop()
ChatPopup(chat, worldScreen).open() ChatPopup(chat, worldScreen).open()
} }
@ -50,6 +102,7 @@ class ChatButton(val worldScreen: WorldScreen) : IconTextButton(
Message.Join(listOf(worldScreen.gameInfo.gameId)), Message.Join(listOf(worldScreen.gameInfo.gameId)),
) )
updatePosition() updatePosition()
updateBadge()
true true
} else { } else {
ChatWebSocket.stop() ChatWebSocket.stop()
@ -61,38 +114,4 @@ class ChatButton(val worldScreen: WorldScreen) : IconTextButton(
worldScreen.techPolicyAndDiplomacy.x.coerceAtLeast(1f), worldScreen.techPolicyAndDiplomacy.x.coerceAtLeast(1f),
worldScreen.techPolicyAndDiplomacy.y - height - 1f worldScreen.techPolicyAndDiplomacy.y - height - 1f
) )
private var flashJob: Job? = null
fun startFlashing(targetColor: Color = Color.ORANGE, interval: Duration = 500.milliseconds) {
flashJob?.cancel()
flashJob = Concurrency.run("ChatButton color flash") {
var isAlternatingColor = true
while (true) {
Gdx.app.postRunnable {
if (isAlternatingColor) {
icon?.color = targetColor
label.color = targetColor
} else {
icon?.color = fontColor
label.color = fontColor
}
isAlternatingColor = !isAlternatingColor
}
delay(interval)
}
}
}
private fun stopFlashing() {
flashJob?.cancel()
icon?.color = fontColor
label.color = fontColor
}
override fun dispose() {
flashJob?.cancel()
}
} }

View File

@ -44,7 +44,6 @@ class ChatPopup(
) )
init { init {
chat.read = true
ChatStore.chatPopup = this ChatStore.chatPopup = this
ChatStore.hasGlobalMessage = false ChatStore.hasGlobalMessage = false
chatTable.defaults().growX().pad(5f).center() chatTable.defaults().growX().pad(5f).center()

View File

@ -1,21 +1,22 @@
package com.unciv.ui.screens.worldscreen.status package com.unciv.ui.screens.worldscreen.status
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.Button import com.badlogic.gdx.scenes.scene2d.ui.Button
import com.badlogic.gdx.scenes.scene2d.ui.Cell import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup 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.Label
import com.badlogic.gdx.utils.Disposable import com.badlogic.gdx.utils.Disposable
import com.unciv.UncivGame import com.unciv.UncivGame
import com.unciv.logic.AlternatingStateManager
import com.unciv.logic.event.EventBus import com.unciv.logic.event.EventBus
import com.unciv.logic.multiplayer.HasMultiplayerGameName import com.unciv.logic.multiplayer.HasMultiplayerGameName
import com.unciv.logic.multiplayer.MultiplayerGameNameChanged import com.unciv.logic.multiplayer.MultiplayerGameNameChanged
import com.unciv.logic.multiplayer.MultiplayerGamePreview
import com.unciv.logic.multiplayer.MultiplayerGameUpdateEnded import com.unciv.logic.multiplayer.MultiplayerGameUpdateEnded
import com.unciv.logic.multiplayer.MultiplayerGameUpdateStarted import com.unciv.logic.multiplayer.MultiplayerGameUpdateStarted
import com.unciv.logic.multiplayer.MultiplayerGameUpdated import com.unciv.logic.multiplayer.MultiplayerGameUpdated
import com.unciv.logic.multiplayer.MultiplayerGamePreview
import com.unciv.logic.multiplayer.isUsersTurn import com.unciv.logic.multiplayer.isUsersTurn
import com.unciv.models.translations.tr import com.unciv.models.translations.tr
import com.unciv.ui.components.extensions.setSize import com.unciv.ui.components.extensions.setSize
@ -24,20 +25,20 @@ import com.unciv.ui.components.widgets.LoadingImage
import com.unciv.ui.images.ImageGetter import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.utils.Concurrency import com.unciv.utils.Concurrency
import com.unciv.utils.launchOnGLThread import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
class MultiplayerStatusButton( class MultiplayerStatusButton(
screen: BaseScreen, screen: BaseScreen,
curGame: MultiplayerGamePreview? curGame: MultiplayerGamePreview?
) : Button(BaseScreen.skin), Disposable { ) : Button(BaseScreen.skin), Disposable {
private var curGameName = curGame?.name private var curGameName = curGame?.name
private val loadingImage = LoadingImage(style = LoadingImage.Style( private val loadingImage = LoadingImage(
idleImageName = "OtherIcons/Multiplayer", style = LoadingImage.Style(
idleIconColor = Color.WHITE, idleImageName = "OtherIcons/Multiplayer",
minShowTime = 500 idleIconColor = Color.WHITE,
)) minShowTime = 500
)
)
private val turnIndicator = TurnIndicator() private val turnIndicator = TurnIndicator()
private val turnIndicatorCell: Cell<Actor> private val turnIndicatorCell: Cell<Actor>
private val gameNamesWithCurrentTurn = getInitialGamesWithCurrentTurn() private val gameNamesWithCurrentTurn = getInitialGamesWithCurrentTurn()
@ -96,7 +97,7 @@ class MultiplayerStatusButton(
} }
private fun updateTurnIndicator(flash: Boolean = true) { private fun updateTurnIndicator(flash: Boolean = true) {
if (gameNamesWithCurrentTurn.size == 0) { if (gameNamesWithCurrentTurn.isEmpty()) {
turnIndicatorCell.clearActor() turnIndicatorCell.clearActor()
} else { } else {
turnIndicatorCell.setActor(turnIndicator) turnIndicatorCell.setActor(turnIndicator)
@ -105,7 +106,7 @@ class MultiplayerStatusButton(
// flash so the user sees an better update // flash so the user sees an better update
if (flash) { if (flash) {
turnIndicator.flash() turnIndicator.flash.start(3.seconds)
} }
} }
@ -118,10 +119,9 @@ class MultiplayerStatusButton(
private class TurnIndicator : HorizontalGroup(), Disposable { private class TurnIndicator : HorizontalGroup(), Disposable {
val gameAmount = Label("2", BaseScreen.skin) val gameAmount = Label("2", BaseScreen.skin)
val image: Image val image = ImageGetter.getImage("OtherIcons/ExclamationMark")
private var job: Job? = null
init { init {
image = ImageGetter.getImage("OtherIcons/ExclamationMark")
image.setSize(30f) image.setSize(30f)
addActor(image) addActor(image)
} }
@ -135,23 +135,25 @@ private class TurnIndicator : HorizontalGroup(), Disposable {
} }
} }
fun flash() { val flash = AlternatingStateManager(
// using a gdx Action would be nicer, but we don't necessarily have continuousRendering on and we still want to flash name = "StatusButton color flash",
flash(6, Color.WHITE, Color.ORANGE) state = object {
} val initialColor = Color.WHITE
private fun flash(alternations: Int, curColor: Color, nextColor: Color) { val targetColor = Color.ORANGE
if (alternations == 0) return }, originalState = { state ->
gameAmount.color = nextColor Gdx.app.postRunnable {
image.color = nextColor image.color = state.initialColor
job = Concurrency.run("StatusButton color flash") { gameAmount.color = state.initialColor
delay(500) }
launchOnGLThread { }, alternateState = { state ->
flash(alternations - 1, nextColor, curColor) Gdx.app.postRunnable {
image.color = state.targetColor
gameAmount.color = state.targetColor
} }
} }
} )
override fun dispose() { override fun dispose() {
job?.cancel() flash.stop()
} }
} }