Merge 63755fd8db79b13f7f12c27b66983f86181a0b50 into d51ef24c205b6b05330b3c4d7ce79c402db44447

This commit is contained in:
Md. Touhidur Rahman 2025-09-19 18:12:48 +00:00 committed by GitHub
commit af1d890725
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 188 additions and 48 deletions

View File

@ -0,0 +1,52 @@
package com.unciv.logic
import com.badlogic.gdx.Gdx
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(
private val name: String,
private val originalState: () -> Unit,
private val alternateState: () -> 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) {
Gdx.app.postRunnable(alternateState)
isAlternateState = false
} else {
Gdx.app.postRunnable(originalState)
isAlternateState = true
}
if (Clock.System.now() - startTime >= duration) {
Gdx.app.postRunnable(originalState)
return@run
}
delay(interval)
}
}
}
fun stop() {
job?.cancel()
Gdx.app.postRunnable(originalState)
}
}

View File

@ -1,7 +1,9 @@
package com.unciv.logic.multiplayer.chat
import com.badlogic.gdx.Gdx
import com.unciv.UncivGame
import com.unciv.ui.screens.worldscreen.chat.ChatPopup
import com.unciv.utils.toUUIDOrNull
import java.util.Collections.synchronizedMap
import java.util.LinkedList
import java.util.Queue
@ -10,6 +12,8 @@ import java.util.UUID
data class Chat(
val gameId: UUID,
) {
var unreadCount = 0
// <civName, message> pairs
private val messages: MutableList<Pair<String, String>> = mutableListOf(INITIAL_MESSAGE)
@ -52,6 +56,8 @@ object ChatStore {
*/
var chatPopup: ChatPopup? = null
var hasGlobalMessage = false
private var gameIdToChat: MutableMap<UUID, Chat> = synchronizedMap(mutableMapOf())
/** When no [ChatPopup] is open to receive these oddities, we keep them here.
@ -72,21 +78,36 @@ object ChatStore {
globalMessages = LinkedList()
}
fun relayChatMessage(chat: Response.Chat) {
fun relayChatMessage(incomingChatMsg: Response.Chat) {
Gdx.app.postRunnable {
if (chat.gameId == null || chat.gameId.isBlank()) {
relayGlobalMessage(chat.message, chat.civName)
if (incomingChatMsg.gameId == null || incomingChatMsg.gameId.isBlank()) {
relayGlobalMessage(incomingChatMsg.message, incomingChatMsg.civName)
} else {
val gameId = try {
UUID.fromString(chat.gameId)
UUID.fromString(incomingChatMsg.gameId)
} catch (_: Throwable) {
// Discard messages with invalid UUID
return@postRunnable
}
getChatByGameId(gameId).addMessage(chat.civName, chat.message)
if (chatPopup?.chat?.gameId == gameId) {
chatPopup?.addMessage(chat.civName, chat.message)
val chat = chatPopup?.chat ?: getChatByGameId(gameId)
chat.addMessage(incomingChatMsg.civName, incomingChatMsg.message)
if (gameId.equals(chatPopup?.chat?.gameId)) {
chatPopup?.addMessage(incomingChatMsg.civName, incomingChatMsg.message)
}
if (chatPopup == null && incomingChatMsg.civName != "System") {
if (gameId.equals(UncivGame.Current.worldScreen?.gameInfo?.gameId?.toUUIDOrNull())) {
// ensures that you are not getting notified for your own messages
if (UncivGame.Current.worldScreen?.gameInfo?.currentPlayer != incomingChatMsg.civName) {
chat.unreadCount++
UncivGame.Current.worldScreen?.chatButton?.triggerChatIndication()
}
} else {
// user is out of world screen or
// some other game not currently on screen has a message
chat.unreadCount++
}
}
}
}
@ -103,7 +124,13 @@ object ChatStore {
fun relayGlobalMessage(message: String, civName: String = "System") {
Gdx.app.postRunnable {
chatPopup?.addMessage(civName, message, suffix = "one time") ?: globalMessages.add(Pair(civName, message))
if (civName != "System") {
hasGlobalMessage = chatPopup == null
if (hasGlobalMessage) UncivGame.Current.worldScreen?.chatButton?.triggerChatIndication()
}
chatPopup?.addMessage(civName, message, suffix = "one time")
?: globalMessages.add(Pair(civName, message))
}
}
}

View File

@ -7,8 +7,8 @@ import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.ui.components.fonts.Fonts
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.fonts.Fonts
import com.unciv.ui.screens.basescreen.BaseScreen
/**
@ -23,10 +23,11 @@ open class IconTextButton(
text: String,
val icon: Actor? = null,
fontSize: Int = Constants.defaultFontSize,
fontColor: Color = Color.WHITE
): Button(BaseScreen.skin) {
val fontColor: Color = Color.WHITE
) : Button(BaseScreen.skin) {
/** [Label] instance produced by, and with content and formatting as specified in [String.toLabel]. */
val label = text.toLabel(fontColor, fontSize, hideIcons = true) // Since by definition we already have an icon
/** Table cell containing the [icon] if any, or `null` (that is, when no [icon] was supplied, the Cell will exist but have no Actor). */
val iconCell: Cell<Actor> =
if (icon != null) {
@ -37,6 +38,7 @@ open class IconTextButton(
} else {
add().padRight(fontSize / 2f)
}
/** Table cell instance containing the [label]. */
val labelCell: Cell<Label> = add(label)

View File

@ -1,22 +1,80 @@
package com.unciv.ui.screens.worldscreen.chat
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame
import com.unciv.logic.AlternatingStateManager
import com.unciv.logic.multiplayer.chat.ChatStore
import com.unciv.logic.multiplayer.chat.ChatWebSocket
import com.unciv.logic.multiplayer.chat.Message
import com.unciv.ui.components.SmallButtonStyle
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.images.IconTextButton
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.worldscreen.WorldScreen
private val smallButtonStyle = SmallButtonStyle()
class ChatButton(val worldScreen: WorldScreen) : IconTextButton(
"Chat", ImageGetter.getImage("OtherIcons/Chat"), 23
) {
private val chat = ChatStore.getChatByGameId(worldScreen.gameInfo.gameId)
private val badge = "".toTextButton(smallButtonStyle).apply {
disable()
label.setColor(Color.WHITE)
label.setAlignment(Align.center)
label.setFontScale(0.2f)
}
private val flash = AlternatingStateManager(
name = "ChatButton color flash",
originalState = {
icon?.color = fontColor
label.color = fontColor
}, alternateState = {
icon?.color = Color.ORANGE
label.color = Color.ORANGE
}
)
private fun updateBadge() {
badge.height = height / 3
badge.setPosition(
width - badge.width / 1.5f,
height - badge.height / 1.5f
)
badge.isVisible = chat.unreadCount > 0 || ChatStore.hasGlobalMessage
if (badge.isVisible) {
var text = chat.unreadCount.toString()
if (ChatStore.hasGlobalMessage) {
text += '+'
}
badge.setText(text)
} else flash.stop()
}
fun triggerChatIndication() {
updateBadge()
flash.start()
}
init {
width = 95f
iconCell.pad(3f).center()
addActor(badge)
updateBadge()
onClick {
ChatPopup(worldScreen).open()
chat.unreadCount = 0
ChatStore.hasGlobalMessage = false
updateBadge()
ChatPopup(chat, worldScreen).open()
}
refreshVisibility()
@ -34,6 +92,7 @@ class ChatButton(val worldScreen: WorldScreen) : IconTextButton(
Message.Join(listOf(worldScreen.gameInfo.gameId)),
)
updatePosition()
updateBadge()
true
} else {
ChatWebSocket.stop()

View File

@ -11,6 +11,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame
import com.unciv.logic.multiplayer.chat.Chat
import com.unciv.logic.multiplayer.chat.ChatStore
import com.unciv.models.translations.tr
import com.unciv.ui.components.extensions.coerceLightnessAtLeast
@ -28,6 +29,7 @@ private val civChatColorsMap = mapOf<String, Color>(
)
class ChatPopup(
val chat: Chat,
private val worldScreen: WorldScreen,
) : Popup(screen = worldScreen, scrollable = Scrollability.None) {
companion object {
@ -35,8 +37,6 @@ class ChatPopup(
const val CIVNAME_COLOR_MIN_LIGHTNESS = 0.60f
}
val chat = ChatStore.getChatByGameId(worldScreen.gameInfo.gameId)
private val chatTable = Table(skin)
private val scrollPane = ScrollPane(chatTable, skin)
private val messageField = UncivTextField(

View File

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

View File

@ -4,12 +4,18 @@ import yairm210.purity.annotations.Pure
import java.util.UUID
/**
* Checks if a [String] is a valid UUID
* Tries to convert a [String] to a valid [UUID]
* and returns `null` upon failure
*/
@Pure
fun String.isUUID(): Boolean = try {
fun String.toUUIDOrNull(): UUID? = try {
UUID.fromString(this)
true
} catch (_: Throwable) {
false
null
}
/**
* Checks if a [String] is a valid [UUID]
*/
@Pure
fun String.isUUID(): Boolean = toUUIDOrNull() != null