mirror of
https://github.com/yairm210/Unciv.git
synced 2025-07-11 00:08:58 +07:00
* Handle subclassing of Events properly Previously, you could only listen to the exact class * Add relevant parent classes for the multiplayer events * Refactor: use the old name as the main name in MultiplayerGameNameChanged event * Add being able to stop listening to events in the EventBus * Add tests for EventBus * Refactor: Extract GameList into standalone file * Refactor: safeUpdateIf to more generic throttle function * Refactor: Extract multiplayer UI helper functions into separate file * Refactor: Extract load/download multiplayer game into logic class from UI * Make loading a multiplayer game automatically update the in-memory game in OnlineMultiplayer * Refactor: Extract multiplayer settings into separate object * Add multiplayer status display * Fix error with multiplayer games not correctly being cleaned up after successful update * Prevent loadLatestMultiplayerState() while next turn update is running * Show "Working..." while waiting for next turn calculations instead of "Waiting for [civ]..." * Fix race condition while updating online game state
This commit is contained in:
BIN
android/Images/OtherIcons/Loading.png
Normal file
BIN
android/Images/OtherIcons/Loading.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Before Width: | Height: | Size: 1009 KiB After Width: | Height: | Size: 1008 KiB |
@ -542,6 +542,7 @@ Username =
|
||||
Multiplayer =
|
||||
Could not download game! =
|
||||
Could not upload game! =
|
||||
Retry =
|
||||
Join game =
|
||||
Invalid game ID! =
|
||||
Copy user ID =
|
||||
@ -577,6 +578,7 @@ You can only resign if it's your turn =
|
||||
[civName] resigned and is now controlled by AI =
|
||||
Last refresh: [time] [timeUnit] ago =
|
||||
Current Turn: [civName] since [time] [timeUnit] ago =
|
||||
Seconds =
|
||||
Minutes =
|
||||
Hours =
|
||||
Days =
|
||||
@ -1346,7 +1348,7 @@ Choose name for [unitName] =
|
||||
# Multiplayer Turn Checker Service
|
||||
|
||||
Enable out-of-game turn notifications =
|
||||
Time between turn checks out-of-game (in minutes) =
|
||||
Out-of-game, update status of all games every: =
|
||||
Show persistent notification for turn notifier service =
|
||||
Take user ID from clipboard =
|
||||
Doing this will reset your current user ID to the clipboard contents - are you sure? =
|
||||
@ -1355,6 +1357,9 @@ Invalid ID! =
|
||||
|
||||
# Multiplayer options menu
|
||||
|
||||
Enable multiplayer status button in singleplayer games =
|
||||
Update status of currently played game every: =
|
||||
In-game, update status of all games every: =
|
||||
Server address =
|
||||
Reset to Dropbox =
|
||||
Check connection to server =
|
||||
|
@ -69,9 +69,10 @@ open class AndroidLauncher : AndroidApplication() {
|
||||
override fun onPause() {
|
||||
if (UncivGame.isCurrentInitialized()
|
||||
&& UncivGame.Current.isGameInfoInitialized()
|
||||
&& UncivGame.Current.settings.multiplayerTurnCheckerEnabled
|
||||
&& UncivGame.Current.settings.multiplayer.turnCheckerEnabled
|
||||
&& UncivGame.Current.gameSaver.getMultiplayerSaves().any()) {
|
||||
MultiplayerTurnCheckWorker.startTurnChecker(applicationContext, UncivGame.Current.gameSaver, UncivGame.Current.gameInfo, UncivGame.Current.settings)
|
||||
MultiplayerTurnCheckWorker.startTurnChecker(applicationContext, UncivGame.Current.gameSaver,
|
||||
UncivGame.Current.gameInfo, UncivGame.Current.settings.multiplayer)
|
||||
}
|
||||
super.onPause()
|
||||
}
|
||||
|
@ -24,11 +24,13 @@ import com.unciv.logic.GameSaver
|
||||
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
|
||||
import com.unciv.models.metadata.GameSettings
|
||||
import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver
|
||||
import com.unciv.models.metadata.GameSettingsMultiplayer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.io.Writer
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@ -59,8 +61,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
|
||||
private const val PERSISTENT_NOTIFICATION_ENABLED = "PERSISTENT_NOTIFICATION_ENABLED"
|
||||
private const val FILE_STORAGE = "FILE_STORAGE"
|
||||
|
||||
fun enqueue(appContext: Context,
|
||||
delayInMinutes: Int, inputData: Data) {
|
||||
fun enqueue(appContext: Context, delay: Duration, inputData: Data) {
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
// If no internet is available, worker waits before becoming active.
|
||||
@ -69,7 +70,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
|
||||
|
||||
val checkTurnWork = OneTimeWorkRequestBuilder<MultiplayerTurnCheckWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setInitialDelay(delayInMinutes.toLong(), TimeUnit.MINUTES)
|
||||
.setInitialDelay(delay.seconds, TimeUnit.SECONDS)
|
||||
.addTag(WORK_TAG)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
@ -123,7 +124,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
|
||||
* The persistent notification is purely for informational reasons.
|
||||
* It is not technically necessary for the Worker, since it is not a Service.
|
||||
*/
|
||||
fun showPersistentNotification(appContext: Context, lastTimeChecked: String, checkPeriod: String) {
|
||||
fun showPersistentNotification(appContext: Context, lastTimeChecked: String, checkPeriod: Duration) {
|
||||
val flags = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) FLAG_IMMUTABLE else 0) or
|
||||
FLAG_UPDATE_CURRENT
|
||||
val pendingIntent: PendingIntent =
|
||||
@ -136,7 +137,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
|
||||
.setContentTitle(appContext.resources.getString(R.string.Notify_Persist_Short) + " " + lastTimeChecked)
|
||||
.setStyle(NotificationCompat.BigTextStyle()
|
||||
.bigText(appContext.resources.getString(R.string.Notify_Persist_Long_P1) + " " +
|
||||
appContext.resources.getString(R.string.Notify_Persist_Long_P2) + " " + checkPeriod + " "
|
||||
appContext.resources.getString(R.string.Notify_Persist_Long_P2) + " " + checkPeriod.seconds / 60f + " "
|
||||
+ appContext.resources.getString(R.string.Notify_Persist_Long_P3)
|
||||
+ " " + appContext.resources.getString(R.string.Notify_Persist_Long_P4)))
|
||||
.setSmallIcon(R.drawable.uncivnotification)
|
||||
@ -180,7 +181,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
|
||||
}
|
||||
}
|
||||
|
||||
fun startTurnChecker(applicationContext: Context, gameSaver: GameSaver, currentGameInfo: GameInfo, settings: GameSettings) {
|
||||
fun startTurnChecker(applicationContext: Context, gameSaver: GameSaver, currentGameInfo: GameInfo, settings: GameSettingsMultiplayer) {
|
||||
Log.i(LOG_TAG, "startTurnChecker")
|
||||
val gameFiles = gameSaver.getMultiplayerSaves()
|
||||
val gameIds = Array(gameFiles.count()) {""}
|
||||
@ -211,17 +212,16 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
|
||||
}
|
||||
} else {
|
||||
val inputData = workDataOf(Pair(FAIL_COUNT, 0), Pair(GAME_ID, gameIds), Pair(GAME_NAME, gameNames),
|
||||
Pair(USER_ID, settings.userId), Pair(CONFIGURED_DELAY, settings.multiplayerTurnCheckerDelayInMinutes),
|
||||
Pair(PERSISTENT_NOTIFICATION_ENABLED, settings.multiplayerTurnCheckerPersistentNotificationEnabled),
|
||||
Pair(FILE_STORAGE, settings.multiplayerServer))
|
||||
Pair(USER_ID, settings.userId), Pair(CONFIGURED_DELAY, settings.turnCheckerDelay.seconds),
|
||||
Pair(PERSISTENT_NOTIFICATION_ENABLED, settings.turnCheckerPersistentNotificationEnabled),
|
||||
Pair(FILE_STORAGE, settings.server))
|
||||
|
||||
if (settings.multiplayerTurnCheckerPersistentNotificationEnabled) {
|
||||
showPersistentNotification(applicationContext,
|
||||
"—", settings.multiplayerTurnCheckerDelayInMinutes.toString())
|
||||
if (settings.turnCheckerPersistentNotificationEnabled) {
|
||||
showPersistentNotification(applicationContext, "—", settings.turnCheckerDelay)
|
||||
}
|
||||
Log.d(LOG_TAG, "startTurnChecker enqueue")
|
||||
// Initial check always happens after a minute, ignoring delay config. Better user experience this way.
|
||||
enqueue(applicationContext, 1, inputData)
|
||||
enqueue(applicationContext, Duration.ofMinutes(1), inputData)
|
||||
}
|
||||
}
|
||||
|
||||
@ -247,6 +247,11 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguredDelay(inputData: Data): Duration {
|
||||
val delay = inputData.getLong(CONFIGURED_DELAY, Duration.ofMinutes(5).seconds)
|
||||
return Duration.ofSeconds(delay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -270,7 +275,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
|
||||
override fun doWork(): Result = runBlocking {
|
||||
Log.i(LOG_TAG, "doWork")
|
||||
val showPersistNotific = inputData.getBoolean(PERSISTENT_NOTIFICATION_ENABLED, true)
|
||||
val configuredDelay = inputData.getInt(CONFIGURED_DELAY, 5)
|
||||
val configuredDelay = getConfiguredDelay(inputData)
|
||||
val fileStorage = inputData.getString(FILE_STORAGE)
|
||||
|
||||
try {
|
||||
@ -350,13 +355,12 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
|
||||
}
|
||||
return@runBlocking Result.failure()
|
||||
} else {
|
||||
if (showPersistNotific) { showPersistentNotification(applicationContext,
|
||||
applicationContext.resources.getString(R.string.Notify_Error_Retrying), configuredDelay.toString()) }
|
||||
if (showPersistNotific) { showPersistentNotification(applicationContext, applicationContext.resources.getString(R.string.Notify_Error_Retrying), configuredDelay) }
|
||||
// If check fails, retry in one minute.
|
||||
// Makes sense, since checks only happen if Internet is available in principle.
|
||||
// Therefore a failure means either a problem with the GameInfo or with Dropbox.
|
||||
val inputDataFailIncrease = Data.Builder().putAll(inputData).putInt(FAIL_COUNT, failCount + 1).build()
|
||||
enqueue(applicationContext, 1, inputDataFailIncrease)
|
||||
enqueue(applicationContext, Duration.ofMinutes(1), inputDataFailIncrease)
|
||||
}
|
||||
} catch (outOfMemory: OutOfMemoryError){ // no point in trying multiple times if this was an oom error
|
||||
return@runBlocking Result.failure()
|
||||
@ -379,8 +383,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
|
||||
}
|
||||
val displayTime = "$hour:$minute"
|
||||
|
||||
showPersistentNotification(applicationContext, displayTime,
|
||||
inputData.getInt(CONFIGURED_DELAY, 5).toString())
|
||||
showPersistentNotification(applicationContext, displayTime, getConfiguredDelay(inputData))
|
||||
}
|
||||
|
||||
private fun showErrorNotification(stackTraceString: String) {
|
||||
|
@ -121,6 +121,7 @@ project(":core") {
|
||||
dependencies {
|
||||
"implementation"("com.badlogicgames.gdx:gdx:$gdxVersion")
|
||||
"implementation"("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1")
|
||||
"implementation"("org.jetbrains.kotlin:kotlin-reflect:${com.unciv.build.BuildConfig.kotlinVersion}")
|
||||
}
|
||||
|
||||
|
||||
|
@ -201,16 +201,23 @@ class MainMenuScreen: BaseScreen() {
|
||||
return@launchCrashHandling
|
||||
}
|
||||
|
||||
if (savedGame.gameParameters.isOnlineMultiplayer) {
|
||||
try {
|
||||
game.onlineMultiplayer.loadGame(savedGame)
|
||||
} catch (oom: OutOfMemoryError) {
|
||||
outOfMemory()
|
||||
}
|
||||
} else {
|
||||
postCrashHandlingRunnable { /// ... and load it into the screen on main thread for GL context
|
||||
try {
|
||||
game.loadGame(savedGame)
|
||||
dispose()
|
||||
} catch (oom: OutOfMemoryError) {
|
||||
outOfMemory()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun quickstartNewGame() {
|
||||
ToastPopup("Working...", this)
|
||||
|
@ -13,19 +13,20 @@ import com.unciv.models.metadata.GameSettings
|
||||
import com.unciv.models.ruleset.RulesetCache
|
||||
import com.unciv.models.tilesets.TileSetCache
|
||||
import com.unciv.models.translations.Translations
|
||||
import com.unciv.ui.LanguagePickerScreen
|
||||
import com.unciv.ui.audio.MusicController
|
||||
import com.unciv.ui.audio.MusicMood
|
||||
import com.unciv.ui.utils.*
|
||||
import com.unciv.ui.worldscreen.PlayerReadyScreen
|
||||
import com.unciv.ui.worldscreen.WorldScreen
|
||||
import com.unciv.logic.multiplayer.OnlineMultiplayer
|
||||
import com.unciv.ui.LanguagePickerScreen
|
||||
import com.unciv.ui.audio.Sounds
|
||||
import com.unciv.ui.crashhandling.closeExecutors
|
||||
import com.unciv.ui.crashhandling.launchCrashHandling
|
||||
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.multiplayer.LoadDeepLinkScreen
|
||||
import com.unciv.ui.multiplayer.MultiplayerHelpers
|
||||
import com.unciv.ui.popup.Popup
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.*
|
||||
@ -128,8 +129,8 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
|
||||
translations.loadPercentageCompleteOfLanguages()
|
||||
TileSetCache.loadTileSetConfigs(printOutput = true)
|
||||
|
||||
if (settings.userId.isEmpty()) { // assign permanent user id
|
||||
settings.userId = UUID.randomUUID().toString()
|
||||
if (settings.multiplayer.userId.isEmpty()) { // assign permanent user id
|
||||
settings.multiplayer.userId = UUID.randomUUID().toString()
|
||||
settings.save()
|
||||
}
|
||||
|
||||
@ -201,7 +202,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
|
||||
val mainMenu = MainMenuScreen()
|
||||
setScreen(mainMenu)
|
||||
val popup = Popup(mainMenu)
|
||||
popup.addGoodSizedLabel("Failed to load multiplayer game: ${ex.message ?: ex::class.simpleName}")
|
||||
popup.addGoodSizedLabel(MultiplayerHelpers.getLoadExceptionMessage(ex))
|
||||
popup.row()
|
||||
popup.addCloseButton()
|
||||
popup.open()
|
||||
|
16
core/src/com/unciv/json/DurationSerializer.kt
Normal file
16
core/src/com/unciv/json/DurationSerializer.kt
Normal file
@ -0,0 +1,16 @@
|
||||
package com.unciv.json
|
||||
|
||||
import com.badlogic.gdx.utils.Json
|
||||
import com.badlogic.gdx.utils.Json.Serializer
|
||||
import com.badlogic.gdx.utils.JsonValue
|
||||
import java.time.Duration
|
||||
|
||||
class DurationSerializer : Serializer<Duration> {
|
||||
override fun write(json: Json, duration: Duration, knownType: Class<*>?) {
|
||||
json.writeValue(duration.toString())
|
||||
}
|
||||
|
||||
override fun read(json: Json, jsonData: JsonValue, type: Class<*>?): Duration {
|
||||
return Duration.parse(jsonData.asString())
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@ package com.unciv.json
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.files.FileHandle
|
||||
import com.badlogic.gdx.utils.Json
|
||||
import java.time.Duration
|
||||
|
||||
|
||||
/**
|
||||
@ -13,6 +14,7 @@ fun json() = Json().apply {
|
||||
ignoreUnknownFields = true
|
||||
|
||||
setSerializer(HashMapVector2.getSerializerClass(), HashMapVector2.createSerializer())
|
||||
setSerializer(Duration::class.java, DurationSerializer())
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -103,7 +103,7 @@ class GameInfo {
|
||||
|
||||
fun getPlayerToViewAs(): CivilizationInfo {
|
||||
if (!gameParameters.isOnlineMultiplayer) return currentPlayerCiv // non-online, play as human player
|
||||
val userId = UncivGame.Current.settings.userId
|
||||
val userId = UncivGame.Current.settings.multiplayer.userId
|
||||
|
||||
// Iterating on all civs, starting from the the current player, gives us the one that will have the next turn
|
||||
// This allows multiple civs from the same UserID
|
||||
@ -227,7 +227,7 @@ class GameInfo {
|
||||
|| turns < simulateMaxTurns && simulateUntilWin
|
||||
// For multiplayer, if there are 3+ players and one is defeated or spectator,
|
||||
// we'll want to skip over their turn
|
||||
|| gameParameters.isOnlineMultiplayer && (thisPlayer.isDefeated() || thisPlayer.isSpectator() && thisPlayer.playerId != UncivGame.Current.settings.userId)
|
||||
|| gameParameters.isOnlineMultiplayer && (thisPlayer.isDefeated() || thisPlayer.isSpectator() && thisPlayer.playerId != UncivGame.Current.settings.multiplayer.userId)
|
||||
) {
|
||||
if (!thisPlayer.isDefeated() || thisPlayer.isBarbarian()) {
|
||||
NextTurnAutomation.automateCivMoves(thisPlayer)
|
||||
|
@ -3,10 +3,13 @@ package com.unciv.logic
|
||||
import com.badlogic.gdx.Files
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.files.FileHandle
|
||||
import com.badlogic.gdx.utils.JsonReader
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.json.fromJsonFile
|
||||
import com.unciv.json.json
|
||||
import com.unciv.models.metadata.GameSettings
|
||||
import com.unciv.models.metadata.doMigrations
|
||||
import com.unciv.models.metadata.isMigrationNecessary
|
||||
import com.unciv.ui.crashhandling.launchCrashHandling
|
||||
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
|
||||
import com.unciv.ui.saves.Gzip
|
||||
@ -164,22 +167,23 @@ class GameSaver(
|
||||
|
||||
fun getGeneralSettings(): GameSettings {
|
||||
val settingsFile = getGeneralSettingsFile()
|
||||
val settings: GameSettings =
|
||||
if (!settingsFile.exists())
|
||||
GameSettings().apply { isFreshlyCreated = true }
|
||||
else try {
|
||||
json().fromJson(GameSettings::class.java, settingsFile)
|
||||
var settings: GameSettings? = null
|
||||
if (settingsFile.exists()) {
|
||||
try {
|
||||
settings = json().fromJson(GameSettings::class.java, settingsFile)
|
||||
if (settings.isMigrationNecessary()) {
|
||||
settings.doMigrations(JsonReader().parse(settingsFile))
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
// I'm not sure of the circumstances,
|
||||
// but some people were getting null settings, even though the file existed??? Very odd.
|
||||
// ...Json broken or otherwise unreadable is the only possible reason.
|
||||
println("Error reading settings file: ${ex.localizedMessage}")
|
||||
println(" cause: ${ex.cause}")
|
||||
GameSettings().apply { isFreshlyCreated = true }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return settings
|
||||
return settings ?: GameSettings().apply { isFreshlyCreated = true }
|
||||
}
|
||||
|
||||
fun setGeneralSettings(gameSettings: GameSettings) {
|
||||
|
@ -11,10 +11,10 @@ import kotlin.reflect.KClass
|
||||
* **Do not use this for every communication between modules**. Only use it for events that might be relevant for a wide variety of modules or
|
||||
* significantly affect the game state, i.e. buildings being created, units dying, new multiplayer data available, etc.
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST") // Through using the "map by KClass", we ensure all methods get called with correct argument type
|
||||
@Suppress("UNCHECKED_CAST") // Through using the "map by KClass" pattern, we ensure all methods get called with correct argument type
|
||||
object EventBus {
|
||||
// This is one of the simplest implementations possible. If it is ever useful, this could be changed to
|
||||
private val receivers = mutableMapOf<KClass<*>, MutableList<EventListener<*>>>()
|
||||
|
||||
private val listeners = mutableMapOf<KClass<*>, MutableList<EventListenerWeakReference<*>>>()
|
||||
|
||||
/**
|
||||
* Only use this from the render thread. For example, in coroutines launched by [com.unciv.ui.crashhandling.launchCrashHandling]
|
||||
@ -24,29 +24,65 @@ object EventBus {
|
||||
* but doing it like this makes debugging slightly easier.
|
||||
*/
|
||||
fun <T : Event> send(event: T) {
|
||||
val listeners = receivers[event::class]
|
||||
if (listeners == null) return
|
||||
val iterator = listeners.listIterator()
|
||||
while (iterator.hasNext()) {
|
||||
val listener = iterator.next() as EventListener<T>
|
||||
val eventHandler = listener.eventHandler.get()
|
||||
if (eventHandler == null) {
|
||||
// eventHandler got garbage collected, prevent WeakListener memory leak
|
||||
iterator.remove()
|
||||
continue
|
||||
}
|
||||
val filter = listener.filter.get()
|
||||
val eventListeners = getListeners(event::class) as Set<EventListener<T>>
|
||||
for (listener in eventListeners) {
|
||||
val filter = listener.filter
|
||||
if (filter == null || filter(event)) {
|
||||
eventHandler(event)
|
||||
listener.eventHandler(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T: Event> receive(eventClass: KClass<T>, filter: ((T) -> Boolean)? = null, eventHandler: (T) -> Unit) {
|
||||
if (receivers[eventClass] == null) {
|
||||
receivers[eventClass] = mutableListOf()
|
||||
private fun <T : Event> getListeners(eventClass: KClass<T>): Set<EventListener<*>> {
|
||||
val classesToListenTo = getClassesToListenTo(eventClass) // This is always a KClass
|
||||
// Set because we don't want to notify the same listener multiple times
|
||||
return buildSet {
|
||||
for (classToListenTo in classesToListenTo) {
|
||||
addAll(updateActiveListeners(classToListenTo))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** To be able to listen to an event class and get notified even when child classes are sent as an event */
|
||||
private fun <T : Event> getClassesToListenTo(eventClass: KClass<T>): List<KClass<*>> {
|
||||
val superClasses = eventClass.supertypes.map { it.classifier as KClass<*> }.filter { it != Any::class }
|
||||
return superClasses + eventClass
|
||||
}
|
||||
|
||||
/** Removes all listeners whose WeakReference got collected and returns the ones that are still active */
|
||||
private fun updateActiveListeners(eventClass: KClass<*>): List<EventListener<*>> {
|
||||
return buildList {
|
||||
val listenersWeak = listeners[eventClass] ?: return listOf()
|
||||
val iterator = listenersWeak.listIterator()
|
||||
while (iterator.hasNext()) {
|
||||
val listener = iterator.next()
|
||||
val eventHandler = listener.eventHandler.get()
|
||||
if (eventHandler == null) {
|
||||
// eventHandler got garbage collected, prevent WeakListener memory leak
|
||||
iterator.remove()
|
||||
} else {
|
||||
add(EventListener(eventHandler, listener.filter.get()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun <T: Event> receive(eventClass: KClass<T>, filter: ((T) -> Boolean)? = null, eventHandler: (T) -> Unit) {
|
||||
if (listeners[eventClass] == null) {
|
||||
listeners[eventClass] = mutableListOf()
|
||||
}
|
||||
listeners[eventClass]!!.add(EventListenerWeakReference(eventHandler, filter))
|
||||
}
|
||||
|
||||
private fun cleanUp(eventHandlers: Map<KClass<*>, MutableList<Any>>) {
|
||||
for ((kClass, toRemove) in eventHandlers) {
|
||||
val registeredListeners = listeners.get(kClass)
|
||||
registeredListeners?.removeIf {
|
||||
val eventHandler = it.eventHandler.get()
|
||||
eventHandler == null || (eventHandler as Any) in toRemove
|
||||
}
|
||||
}
|
||||
receivers[eventClass]!!.add(EventListener(eventHandler, filter))
|
||||
}
|
||||
|
||||
/**
|
||||
@ -63,19 +99,29 @@ object EventBus {
|
||||
* // do something when the event is received.
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Optional
|
||||
* cleanup() {
|
||||
* events.stopReceiving()
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The [stopReceiving] call is optional. Event listeners will be automatically garbage collected. However, garbage collection is non-deterministic, so it's
|
||||
* possible that the events keep being received for quite a while even after a class is unused. [stopReceiving] immediately cleans up all listeners.
|
||||
*
|
||||
* To have event listeners automatically garbage collected, we need to use [WeakReference]s in the event bus. For that to work, though, the class
|
||||
* that wants to receive events needs to hold references to its own event listeners. [EventReceiver] allows to do that while also providing the
|
||||
* interface to start receiving events.
|
||||
*/
|
||||
class EventReceiver {
|
||||
|
||||
val eventHandlers: MutableList<Any> = mutableListOf()
|
||||
val eventHandlers = mutableMapOf<KClass<*>, MutableList<Any>>()
|
||||
val filters: MutableList<Any> = mutableListOf()
|
||||
|
||||
/**
|
||||
* Listen to the event with the given [eventClass] and all events that subclass it. Use [stopReceiving] to stop listening to all events.
|
||||
*
|
||||
* The listeners will always be called on the main GDX render thread.
|
||||
*
|
||||
* @param T The event class holding the data of the event, or simply [Event].
|
||||
@ -84,14 +130,34 @@ object EventBus {
|
||||
if (filter != null) {
|
||||
filters.add(filter)
|
||||
}
|
||||
eventHandlers.add(eventHandler)
|
||||
if (eventHandlers[eventClass] == null) {
|
||||
eventHandlers[eventClass] = mutableListOf()
|
||||
}
|
||||
eventHandlers[eventClass]!!.add(eventHandler)
|
||||
|
||||
EventBus.receive(eventClass, filter, eventHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops receiving all events, cleaning up all event listeners.
|
||||
*/
|
||||
fun stopReceiving() {
|
||||
cleanUp(eventHandlers)
|
||||
eventHandlers.clear()
|
||||
filters.clear()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/** Exists so that eventHandlers and filters do not get garbage-collected *while* we are passing them around in here,
|
||||
* otherwise we would only need [EventListenerWeakReference] */
|
||||
private class EventListener<T>(
|
||||
val eventHandler: (T) -> Unit,
|
||||
val filter: ((T) -> Boolean)? = null
|
||||
)
|
||||
|
||||
private class EventListenerWeakReference<T>(
|
||||
eventHandler: (T) -> Unit,
|
||||
filter: ((T) -> Boolean)? = null
|
||||
) {
|
||||
|
@ -1,11 +1,9 @@
|
||||
package com.unciv.logic.multiplayer
|
||||
|
||||
import com.badlogic.gdx.files.FileHandle
|
||||
import com.unciv.Constants
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.logic.GameInfo
|
||||
import com.unciv.logic.GameInfoPreview
|
||||
import com.unciv.logic.GameSaver
|
||||
import com.unciv.logic.civilization.PlayerType
|
||||
import com.unciv.logic.event.EventBus
|
||||
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
|
||||
@ -14,48 +12,65 @@ import com.unciv.ui.crashhandling.CRASH_HANDLING_DAEMON_SCOPE
|
||||
import com.unciv.ui.crashhandling.launchCrashHandling
|
||||
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
|
||||
import com.unciv.ui.utils.isLargerThan
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.FileNotFoundException
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
|
||||
/** @see getRefreshInterval */
|
||||
private const val CUSTOM_SERVER_REFRESH_INTERVAL = 30L
|
||||
|
||||
/**
|
||||
* How often files can be checked for new multiplayer games (could be that the user modified their file system directly). More checks within this time period
|
||||
* will do nothing.
|
||||
*/
|
||||
private val FILE_UPDATE_THROTTLE_INTERVAL = Duration.ofSeconds(60)
|
||||
private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60)
|
||||
|
||||
/**
|
||||
* Provides multiplayer functionality to the rest of the game.
|
||||
*
|
||||
* See the file of [com.unciv.logic.multiplayer.MultiplayerGameAdded] for all available [EventBus] events.
|
||||
*/
|
||||
class OnlineMultiplayer() {
|
||||
class OnlineMultiplayer {
|
||||
private val gameSaver = UncivGame.Current.gameSaver
|
||||
private val onlineGameSaver = OnlineMultiplayerGameSaver()
|
||||
|
||||
private val savedGames: MutableMap<FileHandle, OnlineMultiplayerGame> = Collections.synchronizedMap(mutableMapOf())
|
||||
private var lastFileUpdate: AtomicReference<Instant?> = AtomicReference()
|
||||
|
||||
private val lastFileUpdate: AtomicReference<Instant?> = AtomicReference()
|
||||
private val lastAllGamesRefresh: AtomicReference<Instant?> = AtomicReference()
|
||||
private val lastCurGameRefresh: AtomicReference<Instant?> = AtomicReference()
|
||||
|
||||
val games: Set<OnlineMultiplayerGame> get() = savedGames.values.toSet()
|
||||
|
||||
init {
|
||||
flow<Unit> {
|
||||
while (true) {
|
||||
delay(getRefreshInterval().toMillis())
|
||||
delay(500)
|
||||
|
||||
// TODO will be used later
|
||||
// requestUpdate()
|
||||
val currentGame = getCurrentGame()
|
||||
val multiplayerSettings = UncivGame.Current.settings.multiplayer
|
||||
if (currentGame != null) {
|
||||
throttle(lastCurGameRefresh, multiplayerSettings.currentGameRefreshDelay, {}) { currentGame.requestUpdate() }
|
||||
}
|
||||
|
||||
val doNotUpdate = if (currentGame == null) listOf() else listOf(currentGame)
|
||||
throttle(lastAllGamesRefresh, multiplayerSettings.allGameRefreshDelay, {}) { requestUpdate(doNotUpdate = doNotUpdate) }
|
||||
}
|
||||
}.launchIn(CRASH_HANDLING_DAEMON_SCOPE)
|
||||
}
|
||||
|
||||
private fun getCurrentGame(): OnlineMultiplayerGame? {
|
||||
if (UncivGame.isCurrentInitialized() && UncivGame.Current.isGameInfoInitialized()) {
|
||||
return getGameByGameId(UncivGame.Current.gameInfo.gameId)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests an update of all multiplayer game state. Does automatic throttling to try to prevent hitting rate limits.
|
||||
*
|
||||
@ -63,26 +78,24 @@ class OnlineMultiplayer() {
|
||||
*
|
||||
* Fires: [MultiplayerGameUpdateStarted], [MultiplayerGameUpdated], [MultiplayerGameUpdateUnchanged], [MultiplayerGameUpdateFailed]
|
||||
*/
|
||||
fun requestUpdate(forceUpdate: Boolean = false) = launchCrashHandling("Update all multiplayer games") {
|
||||
fun alwaysUpdate(instant: Instant?): Boolean = true
|
||||
|
||||
safeUpdateIf(lastFileUpdate, if (forceUpdate) ::alwaysUpdate else ::fileUpdateNeeded, ::updateSavesFromFiles, {}) {
|
||||
// only happens if the files can't be listed, should basically never happen
|
||||
throw it
|
||||
}
|
||||
fun requestUpdate(forceUpdate: Boolean = false, doNotUpdate: List<OnlineMultiplayerGame> = listOf()) {
|
||||
launchCrashHandling("Update all multiplayer games") {
|
||||
val fileThrottleInterval = if (forceUpdate) Duration.ZERO else FILE_UPDATE_THROTTLE_PERIOD
|
||||
// An exception only happens here if the files can't be listed, should basically never happen
|
||||
throttle(lastFileUpdate, fileThrottleInterval, {}, action = ::updateSavesFromFiles)
|
||||
|
||||
for (game in savedGames.values) {
|
||||
if (game in doNotUpdate) continue
|
||||
launch {
|
||||
game.requestUpdate(forceUpdate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fileUpdateNeeded(it: Instant?) = it == null || Duration.between(it, Instant.now()).isLargerThan(FILE_UPDATE_THROTTLE_INTERVAL)
|
||||
}
|
||||
|
||||
private fun updateSavesFromFiles() {
|
||||
val saves = gameSaver.getMultiplayerSaves()
|
||||
val removedSaves = savedGames.keys - saves
|
||||
val removedSaves = savedGames.keys - saves.toSet()
|
||||
removedSaves.forEach(savedGames::remove)
|
||||
val newSaves = saves - savedGames.keys
|
||||
for (saveFile in newSaves) {
|
||||
@ -98,12 +111,8 @@ class OnlineMultiplayer() {
|
||||
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
|
||||
*/
|
||||
suspend fun createGame(newGame: GameInfo) {
|
||||
OnlineMultiplayerGameSaver().tryUploadGame(newGame, withPreview = true)
|
||||
val newGamePreview = newGame.asPreview()
|
||||
val file = gameSaver.saveGame(newGamePreview, newGamePreview.gameId)
|
||||
val onlineMultiplayerGame = OnlineMultiplayerGame(file, newGamePreview, Instant.now())
|
||||
savedGames[file] = onlineMultiplayerGame
|
||||
postCrashHandlingRunnable { EventBus.send(MultiplayerGameAdded(onlineMultiplayerGame.name)) }
|
||||
onlineGameSaver.tryUploadGame(newGame, withPreview = true)
|
||||
addGame(newGame)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -117,16 +126,23 @@ class OnlineMultiplayer() {
|
||||
suspend fun addGame(gameId: String, gameName: String? = null): String {
|
||||
val saveFileName = if (gameName.isNullOrBlank()) gameId else gameName
|
||||
var gamePreview: GameInfoPreview
|
||||
var fileHandle: FileHandle
|
||||
try {
|
||||
gamePreview = OnlineMultiplayerGameSaver().tryDownloadGamePreview(gameId)
|
||||
fileHandle = gameSaver.saveGame(gamePreview, saveFileName)
|
||||
gamePreview = onlineGameSaver.tryDownloadGamePreview(gameId)
|
||||
} catch (ex: FileNotFoundException) {
|
||||
// Game is so old that a preview could not be found on dropbox lets try the real gameInfo instead
|
||||
gamePreview = OnlineMultiplayerGameSaver().tryDownloadGame(gameId).asPreview()
|
||||
fileHandle = gameSaver.saveGame(gamePreview, saveFileName)
|
||||
gamePreview = onlineGameSaver.tryDownloadGame(gameId).asPreview()
|
||||
}
|
||||
val game = OnlineMultiplayerGame(fileHandle, gamePreview, Instant.now())
|
||||
return addGame(gamePreview, saveFileName)
|
||||
}
|
||||
|
||||
private fun addGame(newGame: GameInfo) {
|
||||
val newGamePreview = newGame.asPreview()
|
||||
addGame(newGamePreview, newGamePreview.gameId)
|
||||
}
|
||||
|
||||
private fun addGame(preview: GameInfoPreview, saveFileName: String): String {
|
||||
val fileHandle = gameSaver.saveGame(preview, saveFileName)
|
||||
val game = OnlineMultiplayerGame(fileHandle, preview, Instant.now())
|
||||
savedGames[fileHandle] = game
|
||||
postCrashHandlingRunnable { EventBus.send(MultiplayerGameAdded(game.name)) }
|
||||
return saveFileName
|
||||
@ -136,8 +152,12 @@ class OnlineMultiplayer() {
|
||||
return savedGames.values.firstOrNull { it.name == name }
|
||||
}
|
||||
|
||||
fun getGameByGameId(gameId: String): OnlineMultiplayerGame? {
|
||||
return savedGames.values.firstOrNull { it.preview?.gameId == gameId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resigns from the given multiplayer [gameId]. Can only resign if it's currently the user's turn,
|
||||
* Resigns from the given multiplayer [game]. Can only resign if it's currently the user's turn,
|
||||
* to ensure that no one else can upload the game in the meantime.
|
||||
*
|
||||
* Fires [MultiplayerGameUpdated]
|
||||
@ -147,12 +167,9 @@ class OnlineMultiplayer() {
|
||||
* @return false if it's not the user's turn and thus resigning did not happen
|
||||
*/
|
||||
suspend fun resign(game: OnlineMultiplayerGame): Boolean {
|
||||
val preview = game.preview
|
||||
if (preview == null) {
|
||||
throw game.error!!
|
||||
}
|
||||
val preview = game.preview ?: throw game.error!!
|
||||
// download to work with the latest game state
|
||||
val gameInfo = OnlineMultiplayerGameSaver().tryDownloadGame(preview.gameId)
|
||||
val gameInfo = onlineGameSaver.tryDownloadGame(preview.gameId)
|
||||
val playerCiv = gameInfo.currentPlayerCiv
|
||||
|
||||
if (!gameInfo.isUsersTurn()) {
|
||||
@ -174,9 +191,8 @@ class OnlineMultiplayer() {
|
||||
|
||||
val newPreview = gameInfo.asPreview()
|
||||
gameSaver.saveGame(newPreview, game.fileHandle)
|
||||
OnlineMultiplayerGameSaver().tryUploadGame(gameInfo, withPreview = true)
|
||||
onlineGameSaver.tryUploadGame(gameInfo, withPreview = true)
|
||||
game.doManualUpdate(newPreview)
|
||||
postCrashHandlingRunnable { EventBus.send(MultiplayerGameUpdated(game.name, newPreview)) }
|
||||
return true
|
||||
}
|
||||
|
||||
@ -185,10 +201,7 @@ class OnlineMultiplayer() {
|
||||
* @throws FileNotFoundException if the file can't be found
|
||||
*/
|
||||
suspend fun loadGame(game: OnlineMultiplayerGame) {
|
||||
val preview = game.preview
|
||||
if (preview == null) {
|
||||
throw game.error!!
|
||||
}
|
||||
val preview = game.preview ?: throw game.error!!
|
||||
loadGame(preview.gameId)
|
||||
}
|
||||
|
||||
@ -197,9 +210,40 @@ class OnlineMultiplayer() {
|
||||
* @throws FileNotFoundException if the file can't be found
|
||||
*/
|
||||
suspend fun loadGame(gameId: String) {
|
||||
val gameInfo = OnlineMultiplayerGameSaver().tryDownloadGame(gameId)
|
||||
val gameInfo = downloadGame(gameId)
|
||||
val preview = gameInfo.asPreview()
|
||||
val onlineGame = getGameByGameId(gameId)
|
||||
val onlinePreview = onlineGame?.preview
|
||||
if (onlineGame == null) {
|
||||
createGame(gameInfo)
|
||||
} else if (onlinePreview != null && hasNewerGameState(preview, onlinePreview)){
|
||||
onlineGame.doManualUpdate(preview)
|
||||
}
|
||||
postCrashHandlingRunnable { UncivGame.Current.loadGame(gameInfo) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given game is current and loads it, otherwise loads the game from the server
|
||||
*/
|
||||
suspend fun loadGame(gameInfo: GameInfo) {
|
||||
val gameId = gameInfo.gameId
|
||||
val preview = onlineGameSaver.tryDownloadGamePreview(gameId)
|
||||
if (hasLatestGameState(gameInfo, preview)) {
|
||||
gameInfo.isUpToDate = true
|
||||
postCrashHandlingRunnable { UncivGame.Current.loadGame(gameInfo) }
|
||||
} else {
|
||||
loadGame(gameId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
|
||||
* @throws FileNotFoundException if the file can't be found
|
||||
*/
|
||||
suspend fun downloadGame(gameId: String): GameInfo {
|
||||
val latestGame = onlineGameSaver.tryDownloadGame(gameId)
|
||||
latestGame.isUpToDate = true
|
||||
return latestGame
|
||||
}
|
||||
|
||||
/**
|
||||
@ -217,10 +261,7 @@ class OnlineMultiplayer() {
|
||||
* Fires [MultiplayerGameNameChanged]
|
||||
*/
|
||||
fun changeGameName(game: OnlineMultiplayerGame, newName: String) {
|
||||
val oldPreview = game.preview
|
||||
if (oldPreview == null) {
|
||||
throw game.error!!
|
||||
}
|
||||
val oldPreview = game.preview ?: throw game.error!!
|
||||
val oldLastUpdate = game.lastUpdate
|
||||
val oldName = game.name
|
||||
|
||||
@ -230,53 +271,92 @@ class OnlineMultiplayer() {
|
||||
|
||||
val newGame = OnlineMultiplayerGame(newFileHandle, oldPreview, oldLastUpdate)
|
||||
savedGames[newFileHandle] = newGame
|
||||
EventBus.send(MultiplayerGameNameChanged(newName, oldName))
|
||||
EventBus.send(MultiplayerGameNameChanged(oldName, newName))
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time
|
||||
* @throws FileNotFoundException if the file can't be found
|
||||
*/
|
||||
suspend fun updateGame(gameInfo: GameInfo) {
|
||||
onlineGameSaver.tryUploadGame(gameInfo, withPreview = true)
|
||||
val game = getGameByGameId(gameInfo.gameId)
|
||||
if (game == null) {
|
||||
addGame(gameInfo)
|
||||
} else {
|
||||
game.doManualUpdate(gameInfo.asPreview())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if [gameInfo] and [preview] are up-to-date with each other.
|
||||
*/
|
||||
fun hasLatestGameState(gameInfo: GameInfo, preview: GameInfoPreview): Boolean {
|
||||
// TODO look into how to maybe extract interfaces to not make this take two different methods
|
||||
return gameInfo.currentPlayer == preview.currentPlayer
|
||||
&& gameInfo.turns == preview.turns
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if [preview1] has a more recent game state than [preview2]
|
||||
*/
|
||||
private fun hasNewerGameState(preview1: GameInfoPreview, preview2: GameInfoPreview): Boolean {
|
||||
return preview1.turns > preview2.turns
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the given [updateFun] only when [shouldUpdate] called with the current value of [lastUpdate] returns true.
|
||||
* Calls the given [action] when [lastSuccessfulExecution] lies further in the past than [throttleInterval].
|
||||
*
|
||||
* Also updates [lastUpdate] to [Instant.now], but only when [updateFun] did not result in an exception.
|
||||
* Also updates [lastSuccessfulExecution] to [Instant.now], but only when [action] did not result in an exception.
|
||||
*
|
||||
* Any exception thrown by [updateFun] is propagated.
|
||||
* Any exception thrown by [action] is propagated.
|
||||
*
|
||||
* @return true if the update happened
|
||||
*/
|
||||
suspend fun <T> safeUpdateIf(
|
||||
lastUpdate: AtomicReference<Instant?>,
|
||||
shouldUpdate: (Instant?) -> Boolean,
|
||||
updateFun: suspend () -> T,
|
||||
onUnchanged: () -> T,
|
||||
onFailed: (Exception) -> T
|
||||
suspend fun <T> throttle(
|
||||
lastSuccessfulExecution: AtomicReference<Instant?>,
|
||||
throttleInterval: Duration,
|
||||
onNoExecution: () -> T,
|
||||
onFailed: (Exception) -> T = { throw it },
|
||||
action: suspend () -> T
|
||||
): T {
|
||||
val lastUpdateTime = lastUpdate.get()
|
||||
val lastExecution = lastSuccessfulExecution.get()
|
||||
val now = Instant.now()
|
||||
if (shouldUpdate(lastUpdateTime) && lastUpdate.compareAndSet(lastUpdateTime, now)) {
|
||||
try {
|
||||
return updateFun()
|
||||
} catch (e: Exception) {
|
||||
lastUpdate.compareAndSet(now, lastUpdateTime)
|
||||
return onFailed(e)
|
||||
}
|
||||
val shouldRunAction = lastExecution == null || Duration.between(lastExecution, now).isLargerThan(throttleInterval)
|
||||
return if (shouldRunAction) {
|
||||
attemptAction(lastSuccessfulExecution, onNoExecution, onFailed, action)
|
||||
} else {
|
||||
return onUnchanged()
|
||||
onNoExecution()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun GameInfoPreview.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.userId
|
||||
fun GameInfo.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.userId
|
||||
|
||||
/**
|
||||
* How often all multiplayer games are refreshed in the background
|
||||
* Attempts to run the [action], changing [lastSuccessfulExecution], but only if no other thread changed [lastSuccessfulExecution] in the meantime
|
||||
* and [action] did not throw an exception.
|
||||
*/
|
||||
private fun getRefreshInterval(): Duration {
|
||||
val settings = UncivGame.Current.settings
|
||||
val isDropbox = settings.multiplayerServer == Constants.dropboxMultiplayerServer
|
||||
return if (isDropbox) {
|
||||
Duration.ofMinutes(settings.multiplayerTurnCheckerDelayInMinutes.toLong())
|
||||
suspend fun <T> attemptAction(
|
||||
lastSuccessfulExecution: AtomicReference<Instant?>,
|
||||
onNoExecution: () -> T,
|
||||
onFailed: (Exception) -> T = { throw it },
|
||||
action: suspend () -> T
|
||||
): T {
|
||||
val lastExecution = lastSuccessfulExecution.get()
|
||||
val now = Instant.now()
|
||||
return if (lastSuccessfulExecution.compareAndSet(lastExecution, now)) {
|
||||
try {
|
||||
action()
|
||||
} catch (e: Exception) {
|
||||
lastSuccessfulExecution.compareAndSet(now, lastExecution)
|
||||
onFailed(e)
|
||||
}
|
||||
} else {
|
||||
Duration.ofSeconds(CUSTOM_SERVER_REFRESH_INTERVAL)
|
||||
onNoExecution()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun GameInfoPreview.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.multiplayer.userId
|
||||
fun GameInfo.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.multiplayer.userId
|
||||
|
||||
|
@ -3,52 +3,62 @@ package com.unciv.logic.multiplayer
|
||||
import com.unciv.logic.GameInfoPreview
|
||||
import com.unciv.logic.event.Event
|
||||
|
||||
interface HasMultiplayerGameName {
|
||||
val name: String
|
||||
}
|
||||
|
||||
interface MultiplayerGameUpdateEnded : Event, HasMultiplayerGameName
|
||||
interface MultiplayerGameUpdateSucceeded : Event, HasMultiplayerGameName {
|
||||
val preview: GameInfoPreview
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets sent when a game was added.
|
||||
*/
|
||||
class MultiplayerGameAdded(
|
||||
val name: String
|
||||
) : Event
|
||||
override val name: String
|
||||
) : Event, HasMultiplayerGameName
|
||||
/**
|
||||
* Gets sent when a game successfully updated
|
||||
*/
|
||||
class MultiplayerGameUpdated(
|
||||
val name: String,
|
||||
val preview: GameInfoPreview,
|
||||
) : Event
|
||||
override val name: String,
|
||||
override val preview: GameInfoPreview,
|
||||
) : MultiplayerGameUpdateEnded, MultiplayerGameUpdateSucceeded
|
||||
|
||||
/**
|
||||
* Gets sent when a game errored while updating
|
||||
*/
|
||||
class MultiplayerGameUpdateFailed(
|
||||
val name: String,
|
||||
override val name: String,
|
||||
val error: Exception
|
||||
) : Event
|
||||
) : MultiplayerGameUpdateEnded
|
||||
/**
|
||||
* Gets sent when a game updated successfully, but nothing changed
|
||||
*/
|
||||
class MultiplayerGameUpdateUnchanged(
|
||||
val name: String
|
||||
) : Event
|
||||
override val name: String,
|
||||
override val preview: GameInfoPreview
|
||||
) : MultiplayerGameUpdateEnded, MultiplayerGameUpdateSucceeded
|
||||
|
||||
/**
|
||||
* Gets sent when a game starts updating
|
||||
*/
|
||||
class MultiplayerGameUpdateStarted(
|
||||
val name: String
|
||||
) : Event
|
||||
override val name: String
|
||||
) : Event, HasMultiplayerGameName
|
||||
|
||||
/**
|
||||
* Gets sent when a game's name got changed
|
||||
*/
|
||||
class MultiplayerGameNameChanged(
|
||||
val name: String,
|
||||
val oldName: String
|
||||
) : Event
|
||||
override val name: String,
|
||||
val newName: String
|
||||
) : Event, HasMultiplayerGameName
|
||||
|
||||
/**
|
||||
* Gets sent when a game is deleted
|
||||
*/
|
||||
class MultiplayerGameDeleted(
|
||||
val name: String
|
||||
) : Event
|
||||
override val name: String
|
||||
) : Event, HasMultiplayerGameName
|
||||
|
@ -16,9 +16,9 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
|
||||
/** @see getUpdateThrottleInterval */
|
||||
private const val DROPBOX_THROTTLE_INTERVAL = 8L
|
||||
private const val DROPBOX_THROTTLE_PERIOD = 8L
|
||||
/** @see getUpdateThrottleInterval */
|
||||
private const val CUSTOM_SERVER_THROTTLE_INTERVAL = 1L
|
||||
private const val CUSTOM_SERVER_THROTTLE_PERIOD = 1L
|
||||
|
||||
class OnlineMultiplayerGame(
|
||||
val fileHandle: FileHandle,
|
||||
@ -55,8 +55,7 @@ class OnlineMultiplayerGame(
|
||||
return previewFromFile
|
||||
}
|
||||
|
||||
private fun shouldUpdate(lastUpdateTime: Instant?): Boolean =
|
||||
preview == null || error != null || lastUpdateTime == null || Duration.between(lastUpdateTime, Instant.now()).isLargerThan(getUpdateThrottleInterval())
|
||||
private fun needsUpdate(): Boolean = preview == null || error != null
|
||||
|
||||
/**
|
||||
* Fires: [MultiplayerGameUpdateStarted], [MultiplayerGameUpdated], [MultiplayerGameUpdateUnchanged], [MultiplayerGameUpdateFailed]
|
||||
@ -65,15 +64,18 @@ class OnlineMultiplayerGame(
|
||||
* @throws FileNotFoundException if the file can't be found
|
||||
*/
|
||||
suspend fun requestUpdate(forceUpdate: Boolean = false) {
|
||||
fun alwaysUpdate(instant: Instant?): Boolean = true
|
||||
val shouldUpdateFun = if (forceUpdate) ::alwaysUpdate else ::shouldUpdate
|
||||
val onUnchanged = { GameUpdateResult.UNCHANGED }
|
||||
val onError = { e: Exception ->
|
||||
error = e
|
||||
GameUpdateResult.FAILURE
|
||||
}
|
||||
postCrashHandlingRunnable { EventBus.send(MultiplayerGameUpdateStarted(name)) }
|
||||
val updateResult = safeUpdateIf(lastOnlineUpdate, shouldUpdateFun, ::update, onUnchanged, onError)
|
||||
val throttleInterval = if (forceUpdate) Duration.ZERO else getUpdateThrottleInterval()
|
||||
val updateResult = if (forceUpdate || needsUpdate()) {
|
||||
attemptAction(lastOnlineUpdate, onUnchanged, onError, ::update)
|
||||
} else {
|
||||
throttle(lastOnlineUpdate, throttleInterval, onUnchanged, onError, ::update)
|
||||
}
|
||||
when (updateResult) {
|
||||
GameUpdateResult.UNCHANGED, GameUpdateResult.CHANGED -> error = null
|
||||
else -> {}
|
||||
@ -81,7 +83,7 @@ class OnlineMultiplayerGame(
|
||||
val updateEvent = when (updateResult) {
|
||||
GameUpdateResult.CHANGED -> MultiplayerGameUpdated(name, preview!!)
|
||||
GameUpdateResult.FAILURE -> MultiplayerGameUpdateFailed(name, error!!)
|
||||
GameUpdateResult.UNCHANGED -> MultiplayerGameUpdateUnchanged(name)
|
||||
GameUpdateResult.UNCHANGED -> MultiplayerGameUpdateUnchanged(name, preview!!)
|
||||
}
|
||||
postCrashHandlingRunnable { EventBus.send(updateEvent) }
|
||||
}
|
||||
@ -99,6 +101,7 @@ class OnlineMultiplayerGame(
|
||||
lastOnlineUpdate.set(Instant.now())
|
||||
error = null
|
||||
preview = gameInfo
|
||||
postCrashHandlingRunnable { EventBus.send(MultiplayerGameUpdated(name, gameInfo)) }
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is OnlineMultiplayerGame && fileHandle == other.fileHandle
|
||||
@ -113,6 +116,6 @@ private enum class GameUpdateResult {
|
||||
* How often games can be checked for remote updates. More attempted checks within this time period will do nothing.
|
||||
*/
|
||||
private fun getUpdateThrottleInterval(): Duration {
|
||||
val isDropbox = UncivGame.Current.settings.multiplayerServer == Constants.dropboxMultiplayerServer
|
||||
return Duration.ofSeconds(if (isDropbox) DROPBOX_THROTTLE_INTERVAL else CUSTOM_SERVER_THROTTLE_INTERVAL)
|
||||
val isDropbox = UncivGame.Current.settings.multiplayer.server == Constants.dropboxMultiplayerServer
|
||||
return Duration.ofSeconds(if (isDropbox) DROPBOX_THROTTLE_PERIOD else CUSTOM_SERVER_THROTTLE_PERIOD)
|
||||
}
|
||||
|
@ -21,21 +21,25 @@ class OnlineMultiplayerGameSaver(
|
||||
private var fileStorageIdentifier: String? = null
|
||||
) {
|
||||
fun fileStorage(): FileStorage {
|
||||
val identifier = if (fileStorageIdentifier == null) UncivGame.Current.settings.multiplayerServer else fileStorageIdentifier
|
||||
val identifier = if (fileStorageIdentifier == null) UncivGame.Current.settings.multiplayer.server else fileStorageIdentifier
|
||||
|
||||
return if (identifier == Constants.dropboxMultiplayerServer) DropBox else UncivServerFileStorage(identifier!!)
|
||||
}
|
||||
|
||||
/** @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time */
|
||||
suspend fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) {
|
||||
// We upload the gamePreview before we upload the game as this
|
||||
// seems to be necessary for the kick functionality
|
||||
val zippedGameInfo = GameSaver.gameInfoToString(gameInfo, forceZip = true)
|
||||
fileStorage().saveFileData(gameInfo.gameId, zippedGameInfo, true)
|
||||
|
||||
// We upload the preview after the game because otherwise the following race condition will happen:
|
||||
// Current player ends turn -> Uploads Game Preview
|
||||
// Other player checks for updates -> Downloads Game Preview
|
||||
// Current player starts game upload
|
||||
// Other player sees update in preview -> Downloads game, gets old state
|
||||
// Current player finishes uploading game
|
||||
if (withPreview) {
|
||||
tryUploadGamePreview(gameInfo.asPreview())
|
||||
}
|
||||
|
||||
val zippedGameInfo = GameSaver.gameInfoToString(gameInfo, forceZip = true)
|
||||
fileStorage().saveFileData(gameInfo.gameId, zippedGameInfo, true)
|
||||
}
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
|
@ -6,6 +6,7 @@ import com.unciv.Constants
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.ui.utils.Fonts
|
||||
import java.text.Collator
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import kotlin.collections.HashSet
|
||||
|
||||
@ -43,10 +44,6 @@ class GameSettings {
|
||||
var showPixelUnits: Boolean = true
|
||||
var showPixelImprovements: Boolean = true
|
||||
var continuousRendering = false
|
||||
var userId = ""
|
||||
var multiplayerTurnCheckerEnabled = true
|
||||
var multiplayerTurnCheckerPersistentNotificationEnabled = true
|
||||
var multiplayerTurnCheckerDelayInMinutes = 5
|
||||
var orderTradeOffersByAmount = true
|
||||
var confirmNextTurn = false
|
||||
var windowState = WindowState()
|
||||
@ -54,9 +51,7 @@ class GameSettings {
|
||||
var visualMods = HashSet<String>()
|
||||
var useDemographics: Boolean = false
|
||||
|
||||
|
||||
var multiplayerServer = Constants.dropboxMultiplayerServer
|
||||
|
||||
var multiplayer = GameSettingsMultiplayer()
|
||||
|
||||
var showExperimentalWorldWrap = false // We're keeping this as a config due to ANR problems on Android phones for people who don't know what they're doing :/
|
||||
|
||||
@ -72,10 +67,13 @@ class GameSettings {
|
||||
/** Maximum zoom-out of the map - performance heavy */
|
||||
var maxWorldZoomOut = 2f
|
||||
|
||||
/** used to migrate from older versions of the settings */
|
||||
var version: Int? = null
|
||||
|
||||
init {
|
||||
// 26 = Android Oreo. Versions below may display permanent icon in notification bar.
|
||||
if (Gdx.app?.type == Application.ApplicationType.Android && Gdx.app.version < 26) {
|
||||
multiplayerTurnCheckerPersistentNotificationEnabled = false
|
||||
multiplayer.turnCheckerPersistentNotificationEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,3 +154,14 @@ enum class LocaleCode(var language: String, var country: String) {
|
||||
Ukrainian("uk", "UA"),
|
||||
Vietnamese("vi", "VN"),
|
||||
}
|
||||
|
||||
class GameSettingsMultiplayer {
|
||||
var userId = ""
|
||||
var server = Constants.dropboxMultiplayerServer
|
||||
var turnCheckerEnabled = true
|
||||
var turnCheckerPersistentNotificationEnabled = true
|
||||
var turnCheckerDelay = Duration.ofMinutes(5)
|
||||
var statusButtonInSinglePlayer = false
|
||||
var currentGameRefreshDelay = Duration.ofSeconds(10)
|
||||
var allGameRefreshDelay = Duration.ofMinutes(5)
|
||||
}
|
||||
|
40
core/src/com/unciv/models/metadata/GameSettingsMigrations.kt
Normal file
40
core/src/com/unciv/models/metadata/GameSettingsMigrations.kt
Normal file
@ -0,0 +1,40 @@
|
||||
package com.unciv.models.metadata
|
||||
|
||||
import com.badlogic.gdx.utils.JsonValue
|
||||
import java.time.Duration
|
||||
|
||||
private const val CURRENT_VERSION = 1
|
||||
|
||||
fun GameSettings.doMigrations(json: JsonValue) {
|
||||
if (version == null) {
|
||||
migrateMultiplayerSettings(json)
|
||||
version = 1
|
||||
}
|
||||
}
|
||||
|
||||
fun GameSettings.isMigrationNecessary(): Boolean {
|
||||
return version != CURRENT_VERSION
|
||||
}
|
||||
|
||||
private fun GameSettings.migrateMultiplayerSettings(json: JsonValue) {
|
||||
val userId = json.get("userId")
|
||||
if (userId != null && userId.isString) {
|
||||
multiplayer.userId = userId.asString()
|
||||
}
|
||||
val server = json.get("multiplayerServer")
|
||||
if (server != null && server.isString) {
|
||||
multiplayer.server = server.asString()
|
||||
}
|
||||
val enabled = json.get("multiplayerTurnCheckerEnabled")
|
||||
if (enabled != null && enabled.isBoolean) {
|
||||
multiplayer.turnCheckerEnabled = enabled.asBoolean()
|
||||
}
|
||||
val notification = json.get("multiplayerTurnCheckerPersistentNotificationEnabled")
|
||||
if (notification != null && notification.isBoolean) {
|
||||
multiplayer.turnCheckerPersistentNotificationEnabled = notification.asBoolean()
|
||||
}
|
||||
val delayInMinutes = json.get("multiplayerTurnCheckerDelayInMinutes")
|
||||
if (delayInMinutes != null && delayInMinutes.isNumber) {
|
||||
multiplayer.turnCheckerDelay = Duration.ofMinutes(delayInMinutes.asLong())
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@ import com.unciv.logic.IdChecker
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.crashhandling.launchCrashHandling
|
||||
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
|
||||
import com.unciv.ui.multiplayer.MultiplayerHelpers
|
||||
import com.unciv.ui.pickerscreens.PickerScreen
|
||||
import com.unciv.ui.popup.Popup
|
||||
import com.unciv.ui.popup.ToastPopup
|
||||
@ -60,7 +61,7 @@ class AddMultiplayerGameScreen(backScreen: MultiplayerScreen) : PickerScreen() {
|
||||
game.setScreen(backScreen)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
val message = backScreen.getLoadExceptionMessage(ex)
|
||||
val message = MultiplayerHelpers.getLoadExceptionMessage(ex)
|
||||
postCrashHandlingRunnable {
|
||||
popup.reuseWith(message, true)
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import com.unciv.ui.pickerscreens.PickerScreen
|
||||
import com.unciv.ui.utils.*
|
||||
import com.unciv.ui.crashhandling.launchCrashHandling
|
||||
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
|
||||
import com.unciv.ui.multiplayer.MultiplayerHelpers
|
||||
import com.unciv.ui.popup.Popup
|
||||
import com.unciv.ui.popup.ToastPopup
|
||||
import com.unciv.ui.popup.YesNoPopup
|
||||
@ -96,7 +97,7 @@ class EditMultiplayerGameInfoScreen(val multiplayerGame: OnlineMultiplayerGame,
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
val message = backScreen.getLoadExceptionMessage(ex)
|
||||
val message = MultiplayerHelpers.getLoadExceptionMessage(ex)
|
||||
postCrashHandlingRunnable {
|
||||
popup.reuseWith(message, true)
|
||||
}
|
||||
|
146
core/src/com/unciv/ui/multiplayer/GameList.kt
Normal file
146
core/src/com/unciv/ui/multiplayer/GameList.kt
Normal file
@ -0,0 +1,146 @@
|
||||
package com.unciv.ui.multiplayer
|
||||
|
||||
import com.badlogic.gdx.scenes.scene2d.Actor
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Container
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.VerticalGroup
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.logic.GameInfoPreview
|
||||
import com.unciv.logic.event.EventBus
|
||||
import com.unciv.logic.multiplayer.HasMultiplayerGameName
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameAdded
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameDeleted
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameNameChanged
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameUpdateEnded
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameUpdateFailed
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameUpdateStarted
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameUpdateSucceeded
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameUpdateUnchanged
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameUpdated
|
||||
import com.unciv.logic.multiplayer.isUsersTurn
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.utils.BaseScreen
|
||||
import com.unciv.ui.utils.onClick
|
||||
import com.unciv.ui.utils.setSize
|
||||
|
||||
class GameList(
|
||||
onSelected: (String) -> Unit
|
||||
) : VerticalGroup() {
|
||||
|
||||
private val gameDisplays = mutableMapOf<String, GameDisplay>()
|
||||
|
||||
private val events = EventBus.EventReceiver()
|
||||
|
||||
init {
|
||||
padTop(10f)
|
||||
padBottom(10f)
|
||||
|
||||
events.receive(MultiplayerGameAdded::class) {
|
||||
val multiplayerGame = UncivGame.Current.onlineMultiplayer.getGameByName(it.name)
|
||||
if (multiplayerGame == null) return@receive
|
||||
addGame(it.name, multiplayerGame.preview, multiplayerGame.error, onSelected)
|
||||
}
|
||||
events.receive(MultiplayerGameNameChanged::class) {
|
||||
val gameDisplay = gameDisplays.remove(it.name)
|
||||
if (gameDisplay == null) return@receive
|
||||
gameDisplay.changeName(it.newName)
|
||||
gameDisplays[it.newName] = gameDisplay
|
||||
children.sort()
|
||||
}
|
||||
events.receive(MultiplayerGameDeleted::class) {
|
||||
val gameDisplay = gameDisplays.remove(it.name)
|
||||
if (gameDisplay == null) return@receive
|
||||
gameDisplay.remove()
|
||||
}
|
||||
|
||||
for (game in UncivGame.Current.onlineMultiplayer.games) {
|
||||
addGame(game.name, game.preview, game.error, onSelected)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addGame(name: String, preview: GameInfoPreview?, error: Exception?, onSelected: (String) -> Unit) {
|
||||
val gameDisplay = GameDisplay(name, preview, error, onSelected)
|
||||
gameDisplays[name] = gameDisplay
|
||||
addActor(gameDisplay)
|
||||
children.sort()
|
||||
}
|
||||
}
|
||||
|
||||
private class GameDisplay(
|
||||
multiplayerGameName: String,
|
||||
preview: GameInfoPreview?,
|
||||
error: Exception?,
|
||||
private val onSelected: (String) -> Unit
|
||||
) : Table(), Comparable<GameDisplay> {
|
||||
var gameName: String = multiplayerGameName
|
||||
private set
|
||||
val gameButton = TextButton(gameName, BaseScreen.skin)
|
||||
val turnIndicator = createIndicator("OtherIcons/ExclamationMark")
|
||||
val errorIndicator = createIndicator("StatIcons/Malcontent")
|
||||
val refreshIndicator = createIndicator("EmojiIcons/Turn")
|
||||
val statusIndicators = HorizontalGroup()
|
||||
|
||||
val events = EventBus.EventReceiver()
|
||||
|
||||
init {
|
||||
padBottom(5f)
|
||||
|
||||
updateTurnIndicator(preview)
|
||||
updateErrorIndicator(error != null)
|
||||
add(statusIndicators)
|
||||
add(gameButton)
|
||||
onClick { onSelected(gameName) }
|
||||
|
||||
val isOurGame: (HasMultiplayerGameName) -> Boolean = { it.name == gameName }
|
||||
events.receive(MultiplayerGameUpdateStarted::class, isOurGame, {
|
||||
statusIndicators.addActor(refreshIndicator)
|
||||
})
|
||||
events.receive(MultiplayerGameUpdateEnded::class, isOurGame) {
|
||||
refreshIndicator.remove()
|
||||
}
|
||||
events.receive(MultiplayerGameUpdated::class, isOurGame) {
|
||||
updateTurnIndicator(it.preview)
|
||||
}
|
||||
events.receive(MultiplayerGameUpdateSucceeded::class, isOurGame) {
|
||||
updateErrorIndicator(false)
|
||||
}
|
||||
events.receive(MultiplayerGameUpdateFailed::class, isOurGame) {
|
||||
updateErrorIndicator(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeName(newName: String) {
|
||||
gameName = newName
|
||||
gameButton.setText(newName)
|
||||
}
|
||||
|
||||
private fun updateTurnIndicator(preview: GameInfoPreview?) {
|
||||
if (preview?.isUsersTurn() == true) {
|
||||
statusIndicators.addActor(turnIndicator)
|
||||
} else {
|
||||
turnIndicator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateErrorIndicator(hasError: Boolean) {
|
||||
if (hasError) {
|
||||
statusIndicators.addActor(errorIndicator)
|
||||
} else {
|
||||
errorIndicator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createIndicator(imagePath: String): Actor {
|
||||
val image = ImageGetter.getImage(imagePath)
|
||||
image.setSize(50f)
|
||||
val container = Container(image)
|
||||
container.padRight(5f)
|
||||
return container
|
||||
}
|
||||
|
||||
override fun compareTo(other: GameDisplay): Int = gameName.compareTo(other.gameName)
|
||||
override fun equals(other: Any?): Boolean = (other is GameDisplay) && (gameName == other.gameName)
|
||||
override fun hashCode(): Int = gameName.hashCode()
|
||||
}
|
70
core/src/com/unciv/ui/multiplayer/MultiplayerHelpers.kt
Normal file
70
core/src/com/unciv/ui/multiplayer/MultiplayerHelpers.kt
Normal file
@ -0,0 +1,70 @@
|
||||
package com.unciv.ui.multiplayer
|
||||
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.logic.UncivShowableException
|
||||
import com.unciv.logic.multiplayer.OnlineMultiplayerGame
|
||||
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.crashhandling.launchCrashHandling
|
||||
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
|
||||
import com.unciv.ui.popup.Popup
|
||||
import com.unciv.ui.utils.BaseScreen
|
||||
import java.io.FileNotFoundException
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
object MultiplayerHelpers {
|
||||
fun getLoadExceptionMessage(ex: Throwable) = when (ex) {
|
||||
is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds"
|
||||
is FileNotFoundException -> "File could not be found on the multiplayer server"
|
||||
is UncivShowableException -> ex.message!! // some of these seem to be translated already, but not all
|
||||
else -> "Unhandled problem, [${ex::class.simpleName}] ${ex.message}"
|
||||
}
|
||||
|
||||
fun loadMultiplayerGame(screen: BaseScreen, selectedGame: OnlineMultiplayerGame) {
|
||||
val loadingGamePopup = Popup(screen)
|
||||
loadingGamePopup.addGoodSizedLabel("Loading latest game state...")
|
||||
loadingGamePopup.open()
|
||||
|
||||
launchCrashHandling("JoinMultiplayerGame") {
|
||||
try {
|
||||
UncivGame.Current.onlineMultiplayer.loadGame(selectedGame)
|
||||
} catch (ex: Exception) {
|
||||
val message = getLoadExceptionMessage(ex)
|
||||
postCrashHandlingRunnable {
|
||||
loadingGamePopup.reuseWith(message, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildDescriptionText(multiplayerGame: OnlineMultiplayerGame): StringBuilder {
|
||||
val descriptionText = StringBuilder()
|
||||
val ex = multiplayerGame.error
|
||||
if (ex != null) {
|
||||
descriptionText.append("Error while refreshing:".tr()).append(' ')
|
||||
val message = getLoadExceptionMessage(ex)
|
||||
descriptionText.appendLine(message.tr())
|
||||
}
|
||||
val lastUpdate = multiplayerGame.lastUpdate
|
||||
descriptionText.appendLine("Last refresh: ${formattedElapsedTime(lastUpdate)} ago".tr())
|
||||
val preview = multiplayerGame.preview
|
||||
if (preview?.currentPlayer != null) {
|
||||
val currentTurnStartTime = Instant.ofEpochMilli(preview.currentTurnStartTime)
|
||||
descriptionText.appendLine("Current Turn: [${preview.currentPlayer}] since ${formattedElapsedTime(currentTurnStartTime)} ago".tr())
|
||||
}
|
||||
return descriptionText
|
||||
}
|
||||
|
||||
private fun formattedElapsedTime(lastUpdate: Instant): String {
|
||||
val durationToNow = Duration.between(lastUpdate, Instant.now())
|
||||
val elapsedMinutes = durationToNow.toMinutes()
|
||||
if (elapsedMinutes < 120) return "[$elapsedMinutes] [Minutes]"
|
||||
val elapsedHours = durationToNow.toHours()
|
||||
if (elapsedHours < 48) {
|
||||
return "[${elapsedHours}] [Hours]"
|
||||
} else {
|
||||
return "[${durationToNow.toDays()}] [Days]"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,24 +1,22 @@
|
||||
package com.unciv.ui.multiplayer
|
||||
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.scenes.scene2d.Actor
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.*
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.logic.*
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
|
||||
import com.unciv.logic.event.EventBus
|
||||
import com.unciv.logic.multiplayer.*
|
||||
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameDeleted
|
||||
import com.unciv.logic.multiplayer.OnlineMultiplayerGame
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.multiplayer.GameList
|
||||
import com.unciv.ui.multiplayer.MultiplayerHelpers
|
||||
import com.unciv.ui.pickerscreens.PickerScreen
|
||||
import com.unciv.ui.utils.*
|
||||
import com.unciv.ui.crashhandling.launchCrashHandling
|
||||
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.popup.Popup
|
||||
import com.unciv.ui.popup.ToastPopup
|
||||
import java.io.FileNotFoundException
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import com.unciv.ui.utils.BaseScreen
|
||||
import com.unciv.ui.utils.disable
|
||||
import com.unciv.ui.utils.enable
|
||||
import com.unciv.ui.utils.onClick
|
||||
import com.unciv.ui.utils.toTextButton
|
||||
import com.unciv.ui.utils.AutoScrollPane as ScrollPane
|
||||
|
||||
class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
|
||||
@ -55,7 +53,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
|
||||
|
||||
setupRightSideButton()
|
||||
|
||||
events.receive(MultiplayerGameDeleted::class, {it.name == selectedGame?.name}) {
|
||||
events.receive(MultiplayerGameDeleted::class, { it.name == selectedGame?.name }) {
|
||||
unselectGame()
|
||||
}
|
||||
|
||||
@ -64,7 +62,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
|
||||
|
||||
private fun setupRightSideButton() {
|
||||
rightSideButton.setText("Join game".tr())
|
||||
rightSideButton.onClick { joinMultiplayerGame(selectedGame!!) }
|
||||
rightSideButton.onClick { MultiplayerHelpers.loadMultiplayerGame(this, selectedGame!!) }
|
||||
}
|
||||
|
||||
private fun createRightSideTable(): Table {
|
||||
@ -117,7 +115,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
|
||||
private fun createCopyUserIdButton(): TextButton {
|
||||
val btn = copyUserIdText.toTextButton()
|
||||
btn.onClick {
|
||||
Gdx.app.clipboard.contents = game.settings.userId
|
||||
Gdx.app.clipboard.contents = game.settings.multiplayer.userId
|
||||
ToastPopup("UserID copied to clipboard", this)
|
||||
}
|
||||
return btn
|
||||
@ -159,23 +157,6 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
|
||||
stage.addActor(tab)
|
||||
}
|
||||
|
||||
fun joinMultiplayerGame(selectedGame: OnlineMultiplayerGame) {
|
||||
val loadingGamePopup = Popup(this)
|
||||
loadingGamePopup.addGoodSizedLabel("Loading latest game state...")
|
||||
loadingGamePopup.open()
|
||||
|
||||
launchCrashHandling("JoinMultiplayerGame") {
|
||||
try {
|
||||
game.onlineMultiplayer.loadGame(selectedGame)
|
||||
} catch (ex: Exception) {
|
||||
val message = getLoadExceptionMessage(ex)
|
||||
postCrashHandlingRunnable {
|
||||
loadingGamePopup.reuseWith(message, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unselectGame() {
|
||||
selectedGame = null
|
||||
|
||||
@ -203,161 +184,6 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() {
|
||||
editButton.enable()
|
||||
rightSideButton.enable()
|
||||
|
||||
descriptionLabel.setText(buildDescriptionText(multiplayerGame))
|
||||
}
|
||||
|
||||
private fun buildDescriptionText(multiplayerGame: OnlineMultiplayerGame): StringBuilder {
|
||||
val descriptionText = StringBuilder()
|
||||
val ex = multiplayerGame.error
|
||||
if (ex != null) {
|
||||
descriptionText.append("Error while refreshing:".tr()).append(' ')
|
||||
val message = getLoadExceptionMessage(ex)
|
||||
descriptionText.appendLine(message.tr())
|
||||
}
|
||||
val lastUpdate = multiplayerGame.lastUpdate
|
||||
descriptionText.appendLine("Last refresh: ${formattedElapsedTime(lastUpdate)} ago".tr())
|
||||
val preview = multiplayerGame.preview
|
||||
if (preview?.currentPlayer != null) {
|
||||
val currentTurnStartTime = Instant.ofEpochMilli(preview.currentTurnStartTime)
|
||||
descriptionText.appendLine("Current Turn: [${preview.currentPlayer}] since ${formattedElapsedTime(currentTurnStartTime)} ago".tr())
|
||||
}
|
||||
return descriptionText
|
||||
}
|
||||
|
||||
private fun formattedElapsedTime(lastUpdate: Instant): String {
|
||||
val durationToNow = Duration.between(lastUpdate, Instant.now())
|
||||
val elapsedMinutes = durationToNow.toMinutes()
|
||||
if (elapsedMinutes < 120) return "[$elapsedMinutes] [Minutes]"
|
||||
val elapsedHours = durationToNow.toHours()
|
||||
if (elapsedHours < 48) {
|
||||
return "[${elapsedHours}] [Hours]"
|
||||
} else {
|
||||
return "[${durationToNow.toDays()}] [Days]"
|
||||
}
|
||||
}
|
||||
|
||||
fun getLoadExceptionMessage(ex: Exception) = when (ex) {
|
||||
is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds"
|
||||
is FileNotFoundException -> "File could not be found on the multiplayer server"
|
||||
is UncivShowableException -> ex.message!! // some of these seem to be translated already, but not all
|
||||
else -> "Unhandled problem, [${ex::class.simpleName}] ${ex.message}"
|
||||
descriptionLabel.setText(MultiplayerHelpers.buildDescriptionText(multiplayerGame))
|
||||
}
|
||||
}
|
||||
|
||||
private class GameList(
|
||||
onSelected: (String) -> Unit
|
||||
) : VerticalGroup() {
|
||||
|
||||
private val gameDisplays = mutableMapOf<String, GameDisplay>()
|
||||
|
||||
private val events = EventBus.EventReceiver()
|
||||
|
||||
init {
|
||||
padTop(10f)
|
||||
padBottom(10f)
|
||||
|
||||
events.receive(MultiplayerGameAdded::class) {
|
||||
val multiplayerGame = UncivGame.Current.onlineMultiplayer.getGameByName(it.name)
|
||||
if (multiplayerGame == null) return@receive
|
||||
addGame(it.name, multiplayerGame.preview, multiplayerGame.error, onSelected)
|
||||
}
|
||||
events.receive(MultiplayerGameNameChanged::class) {
|
||||
val gameDisplay = gameDisplays.remove(it.oldName)
|
||||
if (gameDisplay == null) return@receive
|
||||
gameDisplay.changeName(it.name)
|
||||
gameDisplays[it.name] = gameDisplay
|
||||
children.sort()
|
||||
}
|
||||
events.receive(MultiplayerGameDeleted::class) {
|
||||
val gameDisplay = gameDisplays.remove(it.name)
|
||||
if (gameDisplay == null) return@receive
|
||||
gameDisplay.remove()
|
||||
}
|
||||
for (game in UncivGame.Current.onlineMultiplayer.games) {
|
||||
addGame(game.name, game.preview, game.error, onSelected)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addGame(name: String, preview: GameInfoPreview?, error: Exception?, onSelected: (String) -> Unit) {
|
||||
val gameDisplay = GameDisplay(name, preview, error, onSelected)
|
||||
gameDisplays[name] = gameDisplay
|
||||
addActor(gameDisplay)
|
||||
children.sort()
|
||||
}
|
||||
}
|
||||
|
||||
private class GameDisplay(
|
||||
multiplayerGameName: String,
|
||||
preview: GameInfoPreview?,
|
||||
error: Exception?,
|
||||
private val onSelected: (String) -> Unit
|
||||
) : Table(), Comparable<GameDisplay> {
|
||||
var gameName: String = multiplayerGameName
|
||||
private set
|
||||
val gameButton = TextButton(gameName, BaseScreen.skin)
|
||||
val turnIndicator = createIndicator("OtherIcons/ExclamationMark")
|
||||
val errorIndicator = createIndicator("StatIcons/Malcontent")
|
||||
val refreshIndicator = createIndicator("EmojiIcons/Turn")
|
||||
val statusIndicators = HorizontalGroup()
|
||||
|
||||
val events = EventBus.EventReceiver()
|
||||
|
||||
init {
|
||||
padBottom(5f)
|
||||
|
||||
updateTurnIndicator(preview)
|
||||
updateErrorIndicator(error != null)
|
||||
add(statusIndicators)
|
||||
add(gameButton)
|
||||
onClick { onSelected(gameName) }
|
||||
|
||||
events.receive(MultiplayerGameUpdateStarted::class, { it.name == gameName }, {
|
||||
statusIndicators.addActor(refreshIndicator)
|
||||
})
|
||||
events.receive(MultiplayerGameUpdateUnchanged::class, { it.name == gameName }, {
|
||||
refreshIndicator.remove()
|
||||
})
|
||||
events.receive(MultiplayerGameUpdated::class, { it.name == gameName }) {
|
||||
updateTurnIndicator(it.preview)
|
||||
updateErrorIndicator(false)
|
||||
refreshIndicator.remove()
|
||||
}
|
||||
events.receive(MultiplayerGameUpdateFailed::class, { it.name == gameName }) {
|
||||
updateErrorIndicator(true)
|
||||
refreshIndicator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeName(newName: String) {
|
||||
gameName = newName
|
||||
gameButton.setText(newName)
|
||||
}
|
||||
|
||||
private fun updateTurnIndicator(preview: GameInfoPreview?) {
|
||||
if (preview?.isUsersTurn() == true) {
|
||||
statusIndicators.addActor(turnIndicator)
|
||||
} else {
|
||||
turnIndicator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateErrorIndicator(hasError: Boolean) {
|
||||
if (hasError) {
|
||||
statusIndicators.addActor(errorIndicator)
|
||||
} else {
|
||||
errorIndicator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createIndicator(imagePath: String): Actor {
|
||||
val image = ImageGetter.getImage(imagePath)
|
||||
image.setSize(50f)
|
||||
val container = Container(image)
|
||||
container.padRight(5f)
|
||||
return container
|
||||
}
|
||||
|
||||
override fun compareTo(other: GameDisplay): Int = gameName.compareTo(other.gameName)
|
||||
override fun equals(other: Any?): Boolean = (other is GameDisplay) && (gameName == other.gameName)
|
||||
override fun hashCode(): Int = gameName.hashCode()
|
||||
}
|
||||
|
@ -73,7 +73,7 @@ class NewGameScreen(
|
||||
rightSideButton.setText("Start game!".tr())
|
||||
rightSideButton.onClick {
|
||||
if (gameSetupInfo.gameParameters.isOnlineMultiplayer) {
|
||||
val isDropbox = UncivGame.Current.settings.multiplayerServer == Constants.dropboxMultiplayerServer
|
||||
val isDropbox = UncivGame.Current.settings.multiplayer.server == Constants.dropboxMultiplayerServer
|
||||
if (!checkConnectionToMultiplayerServer()) {
|
||||
val noInternetConnectionPopup = Popup(this)
|
||||
val label = if (isDropbox) "Couldn't connect to Dropbox!" else "Couldn't connect to Multiplayer Server!"
|
||||
@ -100,7 +100,7 @@ class NewGameScreen(
|
||||
it.playerType == PlayerType.Human &&
|
||||
// do not allow multiplayer with only remote spectator(s) and AI(s) - non-MP that works
|
||||
!(it.chosenCiv == Constants.spectator && gameSetupInfo.gameParameters.isOnlineMultiplayer &&
|
||||
it.playerId != UncivGame.Current.settings.userId)
|
||||
it.playerId != UncivGame.Current.settings.multiplayer.userId)
|
||||
}) {
|
||||
val noHumanPlayersPopup = Popup(this)
|
||||
noHumanPlayersPopup.addGoodSizedLabel("No human players selected!".tr()).row()
|
||||
@ -212,9 +212,9 @@ class NewGameScreen(
|
||||
}
|
||||
|
||||
private fun checkConnectionToMultiplayerServer(): Boolean {
|
||||
val isDropbox = UncivGame.Current.settings.multiplayerServer == Constants.dropboxMultiplayerServer
|
||||
val isDropbox = UncivGame.Current.settings.multiplayer.server == Constants.dropboxMultiplayerServer
|
||||
return try {
|
||||
val multiplayerServer = UncivGame.Current.settings.multiplayerServer
|
||||
val multiplayerServer = UncivGame.Current.settings.multiplayer.server
|
||||
val u = URL(if (isDropbox) "https://content.dropboxapi.com" else multiplayerServer)
|
||||
val con = u.openConnection()
|
||||
con.connectTimeout = 3000
|
||||
|
@ -176,7 +176,7 @@ class PlayerPickerTable(
|
||||
}
|
||||
|
||||
playerIdTextField.addListener { onPlayerIdTextUpdated(); true }
|
||||
val currentUserId = UncivGame.Current.settings.userId
|
||||
val currentUserId = UncivGame.Current.settings.multiplayer.userId
|
||||
val setCurrentUserButton = "Set current user".toTextButton()
|
||||
setCurrentUserButton.onClick {
|
||||
playerIdTextField.text = currentUserId
|
||||
|
@ -162,7 +162,7 @@ private fun addSetUserId(table: Table, settings: GameSettings, screen: BaseScree
|
||||
YesNoPopup(
|
||||
"Doing this will reset your current user ID to the clipboard contents - are you sure?",
|
||||
{
|
||||
settings.userId = clipboardContents
|
||||
settings.multiplayer.userId = clipboardContents
|
||||
settings.save()
|
||||
idSetLabel.setFontColor(Color.WHITE).setText("ID successfully set!".tr())
|
||||
},
|
||||
|
@ -2,6 +2,7 @@ package com.unciv.ui.options
|
||||
|
||||
import com.badlogic.gdx.Application
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Label
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.SelectBox
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.TextField
|
||||
@ -9,10 +10,13 @@ import com.badlogic.gdx.utils.Array
|
||||
import com.unciv.Constants
|
||||
import com.unciv.logic.multiplayer.storage.SimpleHttp
|
||||
import com.unciv.models.metadata.GameSettings
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.crashhandling.launchCrashHandling
|
||||
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
|
||||
import com.unciv.ui.popup.Popup
|
||||
import com.unciv.ui.utils.*
|
||||
import java.time.Duration
|
||||
import kotlin.reflect.KMutableProperty0
|
||||
|
||||
fun multiplayerTab(
|
||||
optionsPopup: OptionsPopup
|
||||
@ -22,31 +26,45 @@ fun multiplayerTab(
|
||||
|
||||
val settings = optionsPopup.settings
|
||||
|
||||
optionsPopup.addCheckbox(this, "Enable multiplayer status button in singleplayer games",
|
||||
settings.multiplayer.statusButtonInSinglePlayer, updateWorld = true
|
||||
) {
|
||||
settings.multiplayer.statusButtonInSinglePlayer = it
|
||||
settings.save()
|
||||
}
|
||||
|
||||
val curRefreshSelect = addRefreshSelect(this, settings, settings.multiplayer::currentGameRefreshDelay,
|
||||
"Update status of currently played game every:".toLabel(), curRefreshDropboxOptions, curRefreshCustomServerOptions)
|
||||
val allRefreshSelect = addRefreshSelect(this, settings, settings.multiplayer::allGameRefreshDelay,
|
||||
"In-game, update status of all games every:".toLabel(), allRefreshDropboxOptions, allRefreshCustomServerOptions)
|
||||
|
||||
var turnCheckerSelect: SelectBox<RefreshOptions>? = null
|
||||
// at the moment the notification service only exists on Android
|
||||
if (Gdx.app.type == Application.ApplicationType.Android) {
|
||||
optionsPopup.addCheckbox(
|
||||
this, "Enable out-of-game turn notifications",
|
||||
settings.multiplayerTurnCheckerEnabled
|
||||
settings.multiplayer.turnCheckerEnabled
|
||||
) {
|
||||
settings.multiplayerTurnCheckerEnabled = it
|
||||
settings.multiplayer.turnCheckerEnabled = it
|
||||
settings.save()
|
||||
}
|
||||
|
||||
if (settings.multiplayerTurnCheckerEnabled) {
|
||||
addMultiplayerTurnCheckerDelayBox(this, settings)
|
||||
if (settings.multiplayer.turnCheckerEnabled) {
|
||||
turnCheckerSelect = addRefreshSelect(this, settings, settings.multiplayer::turnCheckerDelay,
|
||||
"Out-of-game, update status of all games every:".toLabel(), turnCheckerDropboxOptions, turnCheckerCustomServerOptions)
|
||||
|
||||
optionsPopup.addCheckbox(
|
||||
this, "Show persistent notification for turn notifier service",
|
||||
settings.multiplayerTurnCheckerPersistentNotificationEnabled
|
||||
settings.multiplayer.turnCheckerPersistentNotificationEnabled
|
||||
)
|
||||
{ settings.multiplayerTurnCheckerPersistentNotificationEnabled = it }
|
||||
{ settings.multiplayer.turnCheckerPersistentNotificationEnabled = it }
|
||||
}
|
||||
}
|
||||
|
||||
val connectionToServerButton = "Check connection to server".toTextButton()
|
||||
|
||||
val textToShowForMultiplayerAddress =
|
||||
if (settings.multiplayerServer != Constants.dropboxMultiplayerServer) settings.multiplayerServer
|
||||
if (!usesDropbox(settings)) settings.multiplayer.server
|
||||
else "https://..."
|
||||
val multiplayerServerTextField = TextField(textToShowForMultiplayerAddress, BaseScreen.skin)
|
||||
multiplayerServerTextField.setTextFieldFilter { _, c -> c !in " \r\n\t\\" }
|
||||
@ -57,13 +75,21 @@ fun multiplayerTab(
|
||||
multiplayerServerTextField.text = Gdx.app.clipboard.contents
|
||||
}).row()
|
||||
multiplayerServerTextField.onChange {
|
||||
connectionToServerButton.isEnabled = multiplayerServerTextField.text != Constants.dropboxMultiplayerServer
|
||||
if (connectionToServerButton.isEnabled) {
|
||||
val isCustomServer = multiplayerServerTextField.text != Constants.dropboxMultiplayerServer
|
||||
connectionToServerButton.isEnabled = isCustomServer
|
||||
|
||||
updateRefreshSelectOptions(curRefreshSelect, isCustomServer, curRefreshDropboxOptions, curRefreshCustomServerOptions)
|
||||
updateRefreshSelectOptions(allRefreshSelect, isCustomServer, allRefreshDropboxOptions, allRefreshCustomServerOptions)
|
||||
if (turnCheckerSelect != null) {
|
||||
updateRefreshSelectOptions(turnCheckerSelect, isCustomServer, allRefreshDropboxOptions, allRefreshCustomServerOptions)
|
||||
}
|
||||
|
||||
if (isCustomServer) {
|
||||
fixTextFieldUrlOnType(multiplayerServerTextField)
|
||||
// we can't trim on 'fixTextFieldUrlOnType' for reasons
|
||||
settings.multiplayerServer = multiplayerServerTextField.text.trimEnd('/')
|
||||
settings.multiplayer.server = multiplayerServerTextField.text.trimEnd('/')
|
||||
} else {
|
||||
settings.multiplayerServer = multiplayerServerTextField.text
|
||||
settings.multiplayer.server = multiplayerServerTextField.text
|
||||
}
|
||||
settings.save()
|
||||
}
|
||||
@ -74,6 +100,16 @@ fun multiplayerTab(
|
||||
|
||||
add("Reset to Dropbox".toTextButton().onClick {
|
||||
multiplayerServerTextField.text = Constants.dropboxMultiplayerServer
|
||||
if (allRefreshDropboxOptions.size != allRefreshSelect.items.size) {
|
||||
allRefreshSelect.items = allRefreshDropboxOptions
|
||||
}
|
||||
if (curRefreshDropboxOptions.size != curRefreshSelect.items.size) {
|
||||
curRefreshSelect.items = curRefreshDropboxOptions
|
||||
}
|
||||
if (turnCheckerSelect != null && turnCheckerDropboxOptions.size != turnCheckerSelect.items.size) {
|
||||
turnCheckerSelect.items = turnCheckerDropboxOptions
|
||||
}
|
||||
settings.save()
|
||||
}).row()
|
||||
|
||||
add(connectionToServerButton.onClick {
|
||||
@ -91,7 +127,7 @@ fun multiplayerTab(
|
||||
|
||||
private fun successfullyConnectedToServer(settings: GameSettings, action: (Boolean, String, Int?) -> Unit) {
|
||||
launchCrashHandling("TestIsAlive") {
|
||||
SimpleHttp.sendGetRequest("${settings.multiplayerServer}/isalive") {
|
||||
SimpleHttp.sendGetRequest("${settings.multiplayer.server}/isalive") {
|
||||
success, result, code ->
|
||||
postCrashHandlingRunnable {
|
||||
action(success, result, code)
|
||||
@ -134,19 +170,86 @@ private fun fixTextFieldUrlOnType(TextField: TextField) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMultiplayerTurnCheckerDelayBox(table: Table, settings: GameSettings) {
|
||||
table.add("Time between turn checks out-of-game (in minutes)".toLabel()).left().fillX()
|
||||
private class RefreshOptions(val delay: Duration, val label: String) {
|
||||
override fun toString(): String = label
|
||||
override fun equals(other: Any?): Boolean = other is RefreshOptions && delay == other.delay
|
||||
override fun hashCode(): Int = delay.hashCode()
|
||||
}
|
||||
|
||||
val checkDelaySelectBox = SelectBox<Int>(table.skin)
|
||||
val possibleDelaysArray = Array<Int>()
|
||||
possibleDelaysArray.addAll(1, 2, 5, 15)
|
||||
checkDelaySelectBox.items = possibleDelaysArray
|
||||
checkDelaySelectBox.selected = settings.multiplayerTurnCheckerDelayInMinutes
|
||||
|
||||
table.add(checkDelaySelectBox).pad(10f).row()
|
||||
private val curRefreshDropboxOptions =
|
||||
(listOf<Long>(10, 20, 30, 60).map { RefreshOptions(Duration.ofSeconds(it), "$it " + "Seconds".tr()) }).toGdxArray()
|
||||
|
||||
checkDelaySelectBox.onChange {
|
||||
settings.multiplayerTurnCheckerDelayInMinutes = checkDelaySelectBox.selected
|
||||
private val curRefreshCustomServerOptions =
|
||||
(listOf<Long>(3, 5).map { RefreshOptions(Duration.ofSeconds(it), "$it " + "Seconds".tr()) } + curRefreshDropboxOptions).toGdxArray()
|
||||
|
||||
private val allRefreshDropboxOptions =
|
||||
(listOf<Long>(1, 2, 5, 15).map { RefreshOptions(Duration.ofMinutes(it), "$it " + "Minutes".tr()) }).toGdxArray()
|
||||
|
||||
private val allRefreshCustomServerOptions =
|
||||
(listOf<Long>(15, 30).map { RefreshOptions(Duration.ofSeconds(it), "$it " + "Seconds".tr()) } + allRefreshDropboxOptions).toGdxArray()
|
||||
|
||||
private val turnCheckerDropboxOptions =
|
||||
(listOf<Long>(1, 2, 5, 15).map { RefreshOptions(Duration.ofMinutes(it), "$it " + "Minutes".tr()) }).toGdxArray()
|
||||
|
||||
private val turnCheckerCustomServerOptions =
|
||||
(listOf<Long>(30).map { RefreshOptions(Duration.ofSeconds(it), "$it " + "Seconds".tr()) } + allRefreshDropboxOptions).toGdxArray()
|
||||
|
||||
private fun <T> List<T>.toGdxArray(): Array<T> {
|
||||
val arr = Array<T>(size)
|
||||
for (it in this) {
|
||||
arr.add(it)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
private fun usesDropbox(settings: GameSettings) = settings.multiplayer.server == Constants.dropboxMultiplayerServer
|
||||
|
||||
private fun addRefreshSelect(
|
||||
table: Table,
|
||||
settings: GameSettings,
|
||||
settingsProperty: KMutableProperty0<Duration>,
|
||||
label: Label,
|
||||
dropboxOptions: Array<RefreshOptions>,
|
||||
customServerOptions: Array<RefreshOptions>
|
||||
): SelectBox<RefreshOptions> {
|
||||
table.add(label).left()
|
||||
|
||||
val refreshSelectBox = SelectBox<RefreshOptions>(table.skin)
|
||||
val options = if (usesDropbox(settings)) {
|
||||
dropboxOptions
|
||||
} else {
|
||||
customServerOptions
|
||||
}
|
||||
refreshSelectBox.items = options
|
||||
|
||||
refreshSelectBox.selected = options.firstOrNull() { it.delay == settingsProperty.get() } ?: options.first()
|
||||
|
||||
table.add(refreshSelectBox).pad(10f).row()
|
||||
|
||||
refreshSelectBox.onChange {
|
||||
settingsProperty.set(refreshSelectBox.selected.delay)
|
||||
settings.save()
|
||||
}
|
||||
|
||||
return refreshSelectBox
|
||||
}
|
||||
|
||||
private fun updateRefreshSelectOptions(
|
||||
selectBox: SelectBox<RefreshOptions>,
|
||||
isCustomServer: Boolean,
|
||||
dropboxOptions: Array<RefreshOptions>,
|
||||
customServerOptions: Array<RefreshOptions>
|
||||
) {
|
||||
fun replaceItems(selectBox: SelectBox<RefreshOptions>, options: Array<RefreshOptions>) {
|
||||
val prev = selectBox.selected
|
||||
selectBox.items = options
|
||||
selectBox.selected = prev
|
||||
}
|
||||
|
||||
if (isCustomServer && selectBox.items.size != customServerOptions.size) {
|
||||
replaceItems(selectBox, customServerOptions)
|
||||
} else if (!isCustomServer && selectBox.items.size != dropboxOptions.size) {
|
||||
replaceItems(selectBox, dropboxOptions)
|
||||
}
|
||||
}
|
||||
|
@ -14,14 +14,16 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
|
||||
import com.badlogic.gdx.utils.Align
|
||||
import com.unciv.Constants
|
||||
import com.unciv.MainMenuScreen
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.logic.GameInfo
|
||||
import com.unciv.logic.civilization.CivilizationInfo
|
||||
import com.unciv.logic.civilization.ReligionState
|
||||
import com.unciv.logic.civilization.diplomacy.DiplomaticStatus
|
||||
import com.unciv.logic.event.EventBus
|
||||
import com.unciv.logic.map.MapVisualization
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameUpdated
|
||||
import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached
|
||||
import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver
|
||||
import com.unciv.logic.trade.TradeEvaluation
|
||||
import com.unciv.models.Tutorial
|
||||
import com.unciv.models.UncivSound
|
||||
@ -30,12 +32,19 @@ import com.unciv.models.ruleset.unique.UniqueType
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.cityscreen.CityScreen
|
||||
import com.unciv.ui.civilopedia.CivilopediaScreen
|
||||
import com.unciv.ui.crashhandling.CRASH_HANDLING_DAEMON_SCOPE
|
||||
import com.unciv.ui.crashhandling.launchCrashHandling
|
||||
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.multiplayer.MultiplayerHelpers
|
||||
import com.unciv.ui.overviewscreen.EmpireOverviewScreen
|
||||
import com.unciv.ui.pickerscreens.*
|
||||
import com.unciv.ui.pickerscreens.DiplomaticVotePickerScreen
|
||||
import com.unciv.ui.pickerscreens.DiplomaticVoteResultScreen
|
||||
import com.unciv.ui.pickerscreens.GreatPersonPickerScreen
|
||||
import com.unciv.ui.pickerscreens.PantheonPickerScreen
|
||||
import com.unciv.ui.pickerscreens.PolicyPickerScreen
|
||||
import com.unciv.ui.pickerscreens.ReligiousBeliefsPickerScreen
|
||||
import com.unciv.ui.pickerscreens.TechButton
|
||||
import com.unciv.ui.pickerscreens.TechPickerScreen
|
||||
import com.unciv.ui.popup.ExitGamePopup
|
||||
import com.unciv.ui.popup.Popup
|
||||
import com.unciv.ui.popup.ToastPopup
|
||||
@ -44,23 +53,32 @@ import com.unciv.ui.popup.hasOpenPopups
|
||||
import com.unciv.ui.saves.LoadGameScreen
|
||||
import com.unciv.ui.saves.SaveGameScreen
|
||||
import com.unciv.ui.trade.DiplomacyScreen
|
||||
import com.unciv.ui.utils.*
|
||||
import com.unciv.ui.utils.BaseScreen
|
||||
import com.unciv.ui.utils.Fonts
|
||||
import com.unciv.ui.utils.KeyCharAndCode
|
||||
import com.unciv.ui.utils.UncivDateFormat.formatDate
|
||||
import com.unciv.ui.utils.centerX
|
||||
import com.unciv.ui.utils.colorFromRGB
|
||||
import com.unciv.ui.utils.darken
|
||||
import com.unciv.ui.utils.disable
|
||||
import com.unciv.ui.utils.enable
|
||||
import com.unciv.ui.utils.isEnabled
|
||||
import com.unciv.ui.utils.onClick
|
||||
import com.unciv.ui.utils.setFontSize
|
||||
import com.unciv.ui.utils.toLabel
|
||||
import com.unciv.ui.utils.toTextButton
|
||||
import com.unciv.ui.victoryscreen.VictoryScreen
|
||||
import com.unciv.ui.worldscreen.bottombar.BattleTable
|
||||
import com.unciv.ui.worldscreen.bottombar.TileInfoTable
|
||||
import com.unciv.ui.worldscreen.minimap.MinimapHolder
|
||||
import com.unciv.ui.worldscreen.status.MultiplayerStatusButton
|
||||
import com.unciv.ui.worldscreen.status.NextTurnAction
|
||||
import com.unciv.ui.worldscreen.status.NextTurnButton
|
||||
import com.unciv.ui.worldscreen.status.StatusButtons
|
||||
import com.unciv.ui.worldscreen.unit.UnitActionsTable
|
||||
import com.unciv.ui.worldscreen.unit.UnitTable
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import java.util.*
|
||||
import kotlin.concurrent.timerTask
|
||||
|
||||
/**
|
||||
* Unciv's world screen
|
||||
@ -99,19 +117,20 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
|
||||
private val diplomacyButtonHolder = Table()
|
||||
private val fogOfWarButton = createFogOfWarButton()
|
||||
private val nextTurnButton = NextTurnButton(keyPressDispatcher)
|
||||
private val statusButtons = StatusButtons(nextTurnButton)
|
||||
private val tutorialTaskTable = Table().apply { background = ImageGetter.getBackground(
|
||||
ImageGetter.getBlue().darken(0.5f)) }
|
||||
|
||||
private val notificationsScroll: NotificationsScroll
|
||||
var shouldUpdate = false
|
||||
|
||||
private var nextTurnUpdateJob: Job? = null
|
||||
|
||||
private val events = EventBus.EventReceiver()
|
||||
|
||||
companion object {
|
||||
/** Switch for console logging of next turn duration */
|
||||
private const val consoleLog = false
|
||||
|
||||
private lateinit var multiPlayerRefresher: Flow<Unit>
|
||||
// this object must not be created multiple times
|
||||
private var multiPlayerRefresherJob: Job? = null
|
||||
}
|
||||
|
||||
init {
|
||||
@ -158,7 +177,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
|
||||
stage.addActor(notificationsScroll) // very low in z-order, so we're free to let it extend _below_ tile info and minimap if we want
|
||||
stage.addActor(minimapWrapper)
|
||||
stage.addActor(topBar)
|
||||
stage.addActor(nextTurnButton)
|
||||
stage.addActor(statusButtons)
|
||||
stage.addActor(techPolicyAndVictoryHolder)
|
||||
stage.addActor(tutorialTaskTable)
|
||||
|
||||
@ -198,17 +217,16 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
|
||||
if (gameInfo.gameParameters.isOnlineMultiplayer && !gameInfo.isUpToDate)
|
||||
isPlayersTurn = false // until we're up to date, don't let the player do anything
|
||||
|
||||
if (gameInfo.gameParameters.isOnlineMultiplayer && !isPlayersTurn) {
|
||||
// restart the timer
|
||||
stopMultiPlayerRefresher()
|
||||
|
||||
multiPlayerRefresher = flow {
|
||||
while (true) {
|
||||
if (gameInfo.gameParameters.isOnlineMultiplayer) {
|
||||
val gameId = gameInfo.gameId
|
||||
events.receive(MultiplayerGameUpdated::class, { it.preview.gameId == gameId }) {
|
||||
if (isNextTurnUpdateRunning() || game.onlineMultiplayer.hasLatestGameState(gameInfo, it.preview)) {
|
||||
return@receive
|
||||
}
|
||||
launchCrashHandling("Load latest multiplayer state") {
|
||||
loadLatestMultiplayerState()
|
||||
delay(10000)
|
||||
}
|
||||
}
|
||||
multiPlayerRefresherJob = multiPlayerRefresher.launchIn(CRASH_HANDLING_DAEMON_SCOPE)
|
||||
}
|
||||
|
||||
// don't run update() directly, because the UncivGame.worldScreen should be set so that the city buttons and tile groups
|
||||
@ -216,10 +234,9 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
|
||||
shouldUpdate = true
|
||||
}
|
||||
|
||||
private fun stopMultiPlayerRefresher() {
|
||||
if (multiPlayerRefresherJob != null) {
|
||||
multiPlayerRefresherJob?.cancel()
|
||||
}
|
||||
override fun dispose() {
|
||||
super.dispose()
|
||||
events.stopReceiving()
|
||||
}
|
||||
|
||||
private fun addKeyboardPresses() {
|
||||
@ -287,7 +304,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
|
||||
keyPressDispatcher[KeyCharAndCode.ctrl('O')] = { // Game Options
|
||||
this.openOptionsPopup(onClose = {
|
||||
mapHolder.reloadMaxZoom()
|
||||
nextTurnButton.update(hasOpenPopups(), isPlayersTurn, waitingForAutosave)
|
||||
nextTurnButton.update(hasOpenPopups(), isPlayersTurn, waitingForAutosave, isNextTurnUpdateRunning())
|
||||
})
|
||||
}
|
||||
keyPressDispatcher[KeyCharAndCode.ctrl('S')] = { game.setScreen(SaveGameScreen(gameInfo)) } // Save
|
||||
@ -353,53 +370,37 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
|
||||
}
|
||||
|
||||
private suspend fun loadLatestMultiplayerState() {
|
||||
// Since we're on a background thread, all the UI calls in this func need to run from the
|
||||
// main thread which has a GL context
|
||||
val loadingGamePopup = Popup(this)
|
||||
postCrashHandlingRunnable {
|
||||
loadingGamePopup.add("Loading latest game state...".tr())
|
||||
loadingGamePopup.addGoodSizedLabel("Loading latest game state...")
|
||||
loadingGamePopup.open()
|
||||
}
|
||||
|
||||
try {
|
||||
val latestGame = OnlineMultiplayerGameSaver().tryDownloadGame(gameInfo.gameId)
|
||||
|
||||
// if we find the current player didn't change, don't update
|
||||
// Additionally, check if we are the current player, and in that case always stop
|
||||
// This fixes a bug where for some reason players were waiting for themselves.
|
||||
if (gameInfo.currentPlayer == latestGame.currentPlayer
|
||||
&& gameInfo.turns == latestGame.turns
|
||||
&& latestGame.currentPlayer != gameInfo.getPlayerToViewAs().civName
|
||||
) {
|
||||
postCrashHandlingRunnable { loadingGamePopup.close() }
|
||||
shouldUpdate = true
|
||||
return
|
||||
} else { // if the game updated, even if it's not our turn, reload the world -
|
||||
// stuff has changed and the "waiting for X" will now show the correct civ
|
||||
stopMultiPlayerRefresher()
|
||||
latestGame.isUpToDate = true
|
||||
val latestGame = game.onlineMultiplayer.downloadGame(gameInfo.gameId)
|
||||
if (viewingCiv.civName == latestGame.currentPlayer || viewingCiv.civName == Constants.spectator) {
|
||||
game.platformSpecificHelper?.notifyTurnStarted()
|
||||
}
|
||||
postCrashHandlingRunnable { createNewWorldScreen(latestGame) }
|
||||
}
|
||||
|
||||
} catch (ex: FileStorageRateLimitReached) {
|
||||
postCrashHandlingRunnable {
|
||||
loadingGamePopup.reuseWith("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds", true)
|
||||
loadingGamePopup.close()
|
||||
if (game.gameInfo.gameId == gameInfo.gameId) { // game could've been changed during download
|
||||
createNewWorldScreen(latestGame)
|
||||
}
|
||||
}
|
||||
// stop refresher to not spam user with "Server limit reached!"
|
||||
// popups and restart after limit timer is over
|
||||
stopMultiPlayerRefresher()
|
||||
val restartAfter : Long = ex.limitRemainingSeconds.toLong() * 1000
|
||||
|
||||
Timer("RestartTimerTimer", true).schedule(timerTask {
|
||||
multiPlayerRefresherJob = multiPlayerRefresher.launchIn(CRASH_HANDLING_DAEMON_SCOPE)
|
||||
}, restartAfter)
|
||||
} catch (ex: Throwable) {
|
||||
postCrashHandlingRunnable {
|
||||
loadingGamePopup.reuseWith("Couldn't download the latest game state!", true)
|
||||
loadingGamePopup.addAction(Actions.delay(5f, Actions.run { loadingGamePopup.close() }))
|
||||
val message = MultiplayerHelpers.getLoadExceptionMessage(ex)
|
||||
loadingGamePopup.innerTable.clear()
|
||||
loadingGamePopup.addGoodSizedLabel("Couldn't download the latest game state!").colspan(2).row()
|
||||
loadingGamePopup.addGoodSizedLabel(message).colspan(2).row()
|
||||
loadingGamePopup.addButtonInRow("Retry") {
|
||||
launchCrashHandling("Load latest multiplayer state after error") {
|
||||
loadLatestMultiplayerState()
|
||||
}
|
||||
}.right()
|
||||
loadingGamePopup.addButtonInRow("Main menu") {
|
||||
game.setScreen(MainMenuScreen())
|
||||
}.left()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -494,9 +495,9 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
|
||||
}
|
||||
}
|
||||
}
|
||||
updateNextTurnButton(hasOpenPopups()) // This must be before the notifications update, since its position is based on it
|
||||
updateGameplayButtons()
|
||||
notificationsScroll.update(viewingCiv.notifications, bottomTileInfoTable.height)
|
||||
notificationsScroll.setTopRight(stage.width - 10f, nextTurnButton.y - 5f)
|
||||
notificationsScroll.setTopRight(stage.width - 10f, statusButtons.y - 5f)
|
||||
}
|
||||
|
||||
private fun getCurrentTutorialTask(): String {
|
||||
@ -658,7 +659,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
|
||||
shouldUpdate = true
|
||||
|
||||
// on a separate thread so the user can explore their world while we're passing the turn
|
||||
launchCrashHandling("NextTurn", runAsDaemon = false) {
|
||||
nextTurnUpdateJob = launchCrashHandling("NextTurn", runAsDaemon = false) {
|
||||
if (consoleLog)
|
||||
println("\nNext turn starting " + Date().formatDate())
|
||||
val startTime = System.currentTimeMillis()
|
||||
@ -670,7 +671,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
|
||||
|
||||
if (originalGameInfo.gameParameters.isOnlineMultiplayer) {
|
||||
try {
|
||||
OnlineMultiplayerGameSaver().tryUploadGame(gameInfoClone, withPreview = true)
|
||||
game.onlineMultiplayer.updateGame(gameInfoClone)
|
||||
} catch (ex: Exception) {
|
||||
val message = when (ex) {
|
||||
is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds"
|
||||
@ -738,14 +739,34 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNextTurnButton(isSomethingOpen: Boolean) {
|
||||
nextTurnButton.update(isSomethingOpen, isPlayersTurn, waitingForAutosave, getNextTurnAction())
|
||||
nextTurnButton.setPosition(stage.width - nextTurnButton.width - 10f, topBar.y - nextTurnButton.height - 10f)
|
||||
private fun isNextTurnUpdateRunning(): Boolean {
|
||||
val job = nextTurnUpdateJob
|
||||
return job != null && job.isActive
|
||||
}
|
||||
|
||||
private fun updateGameplayButtons() {
|
||||
nextTurnButton.update(hasOpenPopups(), isPlayersTurn, waitingForAutosave, isNextTurnUpdateRunning(), getNextTurnAction())
|
||||
|
||||
updateMultiplayerStatusButton()
|
||||
|
||||
statusButtons.pack()
|
||||
statusButtons.setPosition(stage.width - statusButtons.width - 10f, topBar.y - statusButtons.height - 10f)
|
||||
}
|
||||
|
||||
private fun updateMultiplayerStatusButton() {
|
||||
if (gameInfo.gameParameters.isOnlineMultiplayer || game.settings.multiplayer.statusButtonInSinglePlayer) {
|
||||
if (statusButtons.multiplayerStatusButton != null) return
|
||||
statusButtons.multiplayerStatusButton = MultiplayerStatusButton(this, game.onlineMultiplayer.getGameByGameId(gameInfo.gameId))
|
||||
} else {
|
||||
if (statusButtons.multiplayerStatusButton == null) return
|
||||
statusButtons.multiplayerStatusButton = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNextTurnAction(): NextTurnAction {
|
||||
return when {
|
||||
isNextTurnUpdateRunning() ->
|
||||
NextTurnAction("Working...", Color.GRAY) {}
|
||||
!isPlayersTurn && gameInfo.gameParameters.isOnlineMultiplayer ->
|
||||
NextTurnAction("Waiting for [${gameInfo.currentPlayerCiv}]...", Color.GRAY) {}
|
||||
!isPlayersTurn && !gameInfo.gameParameters.isOnlineMultiplayer ->
|
||||
|
@ -0,0 +1,185 @@
|
||||
package com.unciv.ui.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.actions.RepeatAction
|
||||
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.unciv.UncivGame
|
||||
import com.unciv.logic.event.EventBus
|
||||
import com.unciv.logic.multiplayer.HasMultiplayerGameName
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameNameChanged
|
||||
import com.unciv.logic.multiplayer.MultiplayerGameUpdateEnded
|
||||
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.crashhandling.launchCrashHandling
|
||||
import com.unciv.ui.crashhandling.postCrashHandlingRunnable
|
||||
import com.unciv.ui.images.ImageGetter
|
||||
import com.unciv.ui.utils.BaseScreen
|
||||
import com.unciv.ui.utils.onClick
|
||||
import com.unciv.ui.utils.setSize
|
||||
import kotlinx.coroutines.delay
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
class MultiplayerStatusButton(
|
||||
/*val*/ screen: BaseScreen,
|
||||
curGame: OnlineMultiplayerGame?
|
||||
) : Button(BaseScreen.skin) {
|
||||
private var curGameName = curGame?.name
|
||||
private val multiplayerImage = createMultiplayerImage()
|
||||
private val loadingImage = createLoadingImage()
|
||||
private val turnIndicator = TurnIndicator()
|
||||
private val turnIndicatorCell: Cell<Actor>
|
||||
private val gameNamesWithCurrentTurn = getInitialGamesWithCurrentTurn()
|
||||
private var loadingStarted: Instant? = null
|
||||
|
||||
private val events = EventBus.EventReceiver()
|
||||
|
||||
init {
|
||||
turnIndicatorCell = add().padTop(10f).padBottom(10f)
|
||||
add(Stack(multiplayerImage, loadingImage)).pad(5f)
|
||||
|
||||
updateTurnIndicator(flash = false) // no flash since this is just the initial construction
|
||||
events.receive(MultiplayerGameUpdated::class) {
|
||||
val shouldUpdate = if (it.preview.isUsersTurn()) {
|
||||
gameNamesWithCurrentTurn.add(it.name)
|
||||
} else {
|
||||
gameNamesWithCurrentTurn.remove(it.name)
|
||||
}
|
||||
if (shouldUpdate) postCrashHandlingRunnable {
|
||||
updateTurnIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
val curGameFilter: (HasMultiplayerGameName) -> Boolean = { it.name == curGameName }
|
||||
|
||||
events.receive(MultiplayerGameNameChanged::class, curGameFilter) {
|
||||
curGameName = it.newName
|
||||
}
|
||||
|
||||
events.receive(MultiplayerGameUpdateStarted::class, curGameFilter) { startLoading() }
|
||||
events.receive(MultiplayerGameUpdateEnded::class, curGameFilter) { stopLoading() }
|
||||
|
||||
onClick {
|
||||
MultiplayerStatusPopup(screen).open()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLoading() {
|
||||
loadingStarted = Instant.now()
|
||||
|
||||
if (UncivGame.Current.settings.continuousRendering) {
|
||||
loadingImage.clearActions()
|
||||
loadingImage.addAction(Actions.repeat(RepeatAction.FOREVER,Actions.rotateBy(-90f, 1f)))
|
||||
}
|
||||
|
||||
loadingImage.isVisible = true
|
||||
|
||||
multiplayerImage.color.a = 0.4f
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
launchCrashHandling("Hide loading indicator") {
|
||||
delay(waitFor.toMillis())
|
||||
postCrashHandlingRunnable {
|
||||
loadingImage.clearActions()
|
||||
loadingImage.isVisible = false
|
||||
multiplayerImage.color.a = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInitialGamesWithCurrentTurn(): MutableSet<String> {
|
||||
return findGamesToBeNotifiedAbout(UncivGame.Current.onlineMultiplayer.games)
|
||||
}
|
||||
|
||||
/** @return set of gameIds */
|
||||
private fun findGamesToBeNotifiedAbout(games: Iterable<OnlineMultiplayerGame>): MutableSet<String> {
|
||||
return games
|
||||
.filter { it.name != curGameName }
|
||||
.filter { it.preview?.isUsersTurn() == true }
|
||||
.map { it.name }
|
||||
.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()
|
||||
} else {
|
||||
turnIndicatorCell.setActor(turnIndicator)
|
||||
turnIndicator.update(gameNamesWithCurrentTurn.size)
|
||||
}
|
||||
|
||||
// flash so the user sees an better update
|
||||
if (flash) {
|
||||
turnIndicator.flash()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class TurnIndicator : HorizontalGroup() {
|
||||
val gameAmount = Label("2", BaseScreen.skin)
|
||||
val image: Image
|
||||
init {
|
||||
image = ImageGetter.getImage("OtherIcons/ExclamationMark")
|
||||
image.setSize(30f)
|
||||
addActor(image)
|
||||
}
|
||||
|
||||
fun update(gamesWithUpdates: Int) {
|
||||
if (gamesWithUpdates < 2) {
|
||||
gameAmount.remove()
|
||||
} else {
|
||||
gameAmount.setText(gamesWithUpdates)
|
||||
addActorAt(0, gameAmount)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
launchCrashHandling("StatusButton color flash") {
|
||||
delay(500)
|
||||
postCrashHandlingRunnable {
|
||||
flash(alternations - 1, nextColor, curColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
package com.unciv.ui.worldscreen.status
|
||||
|
||||
import com.unciv.UncivGame
|
||||
import com.unciv.logic.multiplayer.OnlineMultiplayerGame
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.multiplayer.GameList
|
||||
import com.unciv.ui.multiplayer.MultiplayerHelpers
|
||||
import com.unciv.ui.pickerscreens.PickerPane
|
||||
import com.unciv.ui.popup.Popup
|
||||
import com.unciv.ui.utils.BaseScreen
|
||||
import com.unciv.ui.utils.onClick
|
||||
|
||||
class MultiplayerStatusPopup(
|
||||
screen: BaseScreen,
|
||||
) : Popup(screen) {
|
||||
|
||||
val pickerPane = PickerPane()
|
||||
var selectedGame: OnlineMultiplayerGame? = null
|
||||
|
||||
init {
|
||||
val pickerCell = add()
|
||||
.width(700f).fillX().expandX()
|
||||
.minHeight(screen.stage.height * 0.5f)
|
||||
.maxHeight(screen.stage.height * 0.8f)
|
||||
|
||||
val gameList = GameList(::gameSelected)
|
||||
pickerPane.topTable.add(gameList)
|
||||
pickerPane.rightSideButton.setText("Load game".tr())
|
||||
pickerPane.closeButton.onClick(::close)
|
||||
pickerCell.setActor<PickerPane>(pickerPane)
|
||||
pickerPane.rightSideButton.onClick {
|
||||
close()
|
||||
val game = selectedGame
|
||||
if (game != null) {
|
||||
MultiplayerHelpers.loadMultiplayerGame(screen, game)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun gameSelected(gameName: String) {
|
||||
val multiplayerGame = UncivGame.Current.onlineMultiplayer.getGameByName(gameName)!!
|
||||
selectedGame = multiplayerGame
|
||||
pickerPane.setRightSideButtonEnabled(true)
|
||||
pickerPane.rightSideButton.setText("Load [$gameName]".tr())
|
||||
pickerPane.descriptionLabel.setText(MultiplayerHelpers.buildDescriptionText(multiplayerGame))
|
||||
}
|
||||
|
||||
}
|
@ -19,7 +19,11 @@ class NextTurnButton(
|
||||
keyPressDispatcher['n'] = action
|
||||
}
|
||||
|
||||
fun update(isSomethingOpen: Boolean, isPlayersTurn: Boolean, waitingForAutosave: Boolean, nextTurnAction: NextTurnAction? = null) {
|
||||
fun update(isSomethingOpen: Boolean,
|
||||
isPlayersTurn: Boolean,
|
||||
waitingForAutosave: Boolean,
|
||||
isNextTurnUpdateRunning: Boolean,
|
||||
nextTurnAction: NextTurnAction? = null) {
|
||||
if (nextTurnAction != null) {
|
||||
this.nextTurnAction = nextTurnAction
|
||||
setText(nextTurnAction.text.tr())
|
||||
@ -27,7 +31,7 @@ class NextTurnButton(
|
||||
pack()
|
||||
}
|
||||
|
||||
isEnabled = !isSomethingOpen && isPlayersTurn && !waitingForAutosave
|
||||
isEnabled = !isSomethingOpen && isPlayersTurn && !waitingForAutosave && !isNextTurnUpdateRunning
|
||||
}
|
||||
}
|
||||
|
||||
|
26
core/src/com/unciv/ui/worldscreen/status/StatusButtons.kt
Normal file
26
core/src/com/unciv/ui/worldscreen/status/StatusButtons.kt
Normal file
@ -0,0 +1,26 @@
|
||||
package com.unciv.ui.worldscreen.status
|
||||
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup
|
||||
|
||||
class StatusButtons(
|
||||
nextTurnButton: NextTurnButton,
|
||||
multiplayerStatusButton: MultiplayerStatusButton? = null
|
||||
) : HorizontalGroup() {
|
||||
var multiplayerStatusButton: MultiplayerStatusButton? = multiplayerStatusButton
|
||||
set(button) {
|
||||
multiplayerStatusButton?.remove()
|
||||
field = button
|
||||
if (button != null) {
|
||||
addActorAt(0, button)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
space(10f)
|
||||
right()
|
||||
if (multiplayerStatusButton != null) {
|
||||
addActor(multiplayerStatusButton)
|
||||
}
|
||||
addActor(nextTurnButton)
|
||||
}
|
||||
}
|
46
tests/src/com/unciv/logic/event/EventBusTest.kt
Normal file
46
tests/src/com/unciv/logic/event/EventBusTest.kt
Normal file
@ -0,0 +1,46 @@
|
||||
package com.unciv.logic.event
|
||||
|
||||
import org.hamcrest.CoreMatchers.`is`
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.junit.Test
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
class EventBusTest {
|
||||
open class Parent : Event
|
||||
class Child : Parent()
|
||||
|
||||
@Test
|
||||
fun `should receive parent event once when receiving child event`() {
|
||||
val events = EventBus.EventReceiver()
|
||||
var callCount = 0
|
||||
events.receive(Parent::class) { ++callCount }
|
||||
|
||||
EventBus.send(Child())
|
||||
|
||||
assertThat(callCount, `is`(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not receive parent event when listening to child event`() {
|
||||
val events = EventBus.EventReceiver()
|
||||
var callCount = 0
|
||||
events.receive(Child::class) { callCount++ }
|
||||
|
||||
EventBus.send(Parent())
|
||||
|
||||
assertThat(callCount, `is`(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should stop listening to events when requested`() {
|
||||
val events = EventBus.EventReceiver()
|
||||
var callCount = 0
|
||||
events.receive(Child::class) { callCount++ }
|
||||
|
||||
EventBus.send(Child())
|
||||
events.stopReceiving()
|
||||
EventBus.send(Child())
|
||||
|
||||
assertThat(callCount, `is`(1))
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user