Cleaning: platform specifics and UncivGame (#8773)

* Cleanup: platform specifics + UncivGame

* Fix tests

* Fix requests not clearing

---------

Co-authored-by: vegeta1k95 <vfylfhby>
This commit is contained in:
vegeta1k95
2023-02-28 17:56:57 +01:00
committed by GitHub
parent 81c4057488
commit 494fde53cf
53 changed files with 823 additions and 873 deletions

View File

@ -18,7 +18,7 @@ import com.unciv.ui.components.Fonts
import java.util.*
import kotlin.math.abs
class FontAndroid : FontImplementation {
class AndroidFont : FontImplementation {
private val fontList by lazy {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) emptySet()

View File

@ -0,0 +1,35 @@
package com.unciv.app
import android.os.Build
import com.unciv.UncivGame
class AndroidGame(private val activity: AndroidLauncher) : UncivGame() {
/*
Sources for Info about current orientation in case need:
val windowManager = (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager)
val displayRotation = windowManager.defaultDisplay.rotation
val currentOrientation = activity.resources.configuration.orientation
const val ORIENTATION_UNDEFINED = 0
const val ORIENTATION_PORTRAIT = 1
const val ORIENTATION_LANDSCAPE = 2
*/
override fun hasDisplayCutout(): Boolean {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
activity.display?.cutout != null
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
@Suppress("DEPRECATION")
activity.windowManager.defaultDisplay.cutout != null
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ->
activity.window.decorView.rootWindowInsets.displayCutout != null
else -> false
}
}
override fun allowPortrait(allow: Boolean) {
activity.allowPortrait(allow)
}
}

View File

@ -1,8 +1,8 @@
package com.unciv.app
import android.content.Intent
import android.content.pm.ActivityInfo
import android.graphics.Rect
import android.hardware.display.DisplayManager
import android.net.Uri
import android.opengl.GLSurfaceView
import android.os.Build
@ -11,7 +11,7 @@ import android.view.Surface
import android.view.SurfaceHolder
import android.view.View
import android.view.ViewTreeObserver
import androidx.annotation.RequiresApi
import android.view.WindowManager
import androidx.core.app.NotificationManagerCompat
import androidx.work.WorkManager
import com.badlogic.gdx.Gdx
@ -20,9 +20,9 @@ import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration
import com.badlogic.gdx.backends.android.AndroidGraphics
import com.badlogic.gdx.math.Rectangle
import com.unciv.UncivGame
import com.unciv.UncivGameParameters
import com.unciv.logic.files.UncivFiles
import com.unciv.logic.event.EventBus
import com.unciv.ui.components.Fonts
import com.unciv.ui.screens.basescreen.UncivStage
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.utils.Log
@ -30,37 +30,36 @@ import com.unciv.utils.concurrency.Concurrency
import java.io.File
open class AndroidLauncher : AndroidApplication() {
private var customFileLocationHelper: CustomFileLocationHelperAndroid? = null
private var game: UncivGame? = null
private var deepLinkedMultiplayerGame: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Setup Android logging
Log.backend = AndroidLogBackend()
customFileLocationHelper = CustomFileLocationHelperAndroid(this)
// Setup Android fonts
Fonts.fontImplementation = AndroidFont()
// Setup Android custom saver-loader
UncivFiles.saverLoader = AndroidSaverLoader(this)
UncivFiles.preferExternalStorage = true
// Create notification channels for Multiplayer notificator
MultiplayerTurnCheckWorker.createNotificationChannels(applicationContext)
copyMods()
val config = AndroidApplicationConfiguration().apply {
useImmersiveMode = true
}
val config = AndroidApplicationConfiguration().apply { useImmersiveMode = true }
val settings = UncivFiles.getSettingsForPlatformLaunchers(filesDir.path)
// Manage orientation lock and display cutout
val platformSpecificHelper = PlatformSpecificHelpersAndroid(this)
platformSpecificHelper.allowPortrait(settings.allowAndroidPortrait)
// Setup orientation lock and display cutout
allowPortrait(settings.allowAndroidPortrait)
setDisplayCutout(settings.androidCutout)
platformSpecificHelper.toggleDisplayCutout(settings.androidCutout)
val androidParameters = UncivGameParameters(
crashReportSysInfo = CrashReportSysInfoAndroid,
fontImplementation = FontAndroid(),
customFileLocationHelper = customFileLocationHelper,
platformSpecificHelper = platformSpecificHelper
)
game = UncivGame(androidParameters)
game = AndroidGame(this)
initialize(game, config)
setDeepLinkedGame(intent)
@ -73,6 +72,23 @@ open class AndroidLauncher : AndroidApplication() {
addScreenRefreshRateListener(glView)
}
fun allowPortrait(allow: Boolean) {
val orientation = when {
allow -> ActivityInfo.SCREEN_ORIENTATION_USER
else -> ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
}
// Comparison ensures ActivityTaskManager.getService().setRequestedOrientation isn't called unless necessary
if (requestedOrientation != orientation) requestedOrientation = orientation
}
private fun setDisplayCutout(cutout: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
window.attributes.layoutInDisplayCutoutMode = when {
cutout -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
else -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
}
}
/** Request the best available device frame rate for
* the game, as soon as OpenGL surface is created */
private fun addScreenRefreshRateListener(surfaceView: GLSurfaceView) {
@ -196,7 +212,8 @@ open class AndroidLauncher : AndroidApplication() {
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
customFileLocationHelper?.onActivityResult(requestCode, data)
val saverLoader = UncivFiles.saverLoader as AndroidSaverLoader
saverLoader.onActivityResult(requestCode, data)
super.onActivityResult(requestCode, resultCode, data)
}
}

View File

@ -20,6 +20,13 @@ class AndroidLogBackend : LogBackend {
override fun isRelease(): Boolean {
return !BuildConfig.DEBUG
}
override fun getSystemInfo(): String {
return """
Device Model: ${Build.MODEL}
API Level: ${Build.VERSION.SDK_INT}
""".trimIndent()
}
}
private fun toAndroidTag(tag: Tag): String {

View File

@ -0,0 +1,113 @@
package com.unciv.app
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import com.unciv.logic.files.PlatformSaverLoader
import java.io.InputStream
import java.io.OutputStream
private class Request(
val onFileChosen: (Uri) -> Unit
)
class AndroidSaverLoader(private val activity: Activity) : PlatformSaverLoader {
private val contentResolver = activity.contentResolver
private val requests = HashMap<Int, Request>()
private var requestCode = 100
override fun saveGame(
data: String,
suggestedLocation: String,
onSaved: (location: String) -> Unit,
onError: (ex: Exception) -> Unit
) {
// When we loaded, we returned a "content://" URI as file location.
val suggestedUri = Uri.parse(suggestedLocation)
val fileName = getFilename(suggestedUri, suggestedLocation)
val onFileChosen = {uri: Uri ->
var stream: OutputStream? = null
try {
stream = contentResolver.openOutputStream(uri, "rwt")
stream!!.writer().use { it.write(data) }
onSaved(uri.toString())
} catch (ex: Exception) {
onError(ex)
} finally {
stream?.close()
}
}
requests[requestCode] = Request(onFileChosen)
openSaveFileChooser(fileName, suggestedUri, requestCode)
requestCode += 1
}
override fun loadGame(
onLoaded: (data: String, location: String) -> Unit,
onError: (ex: Exception) -> Unit) {
val onFileChosen = {uri: Uri ->
var stream: InputStream? = null
try {
stream = contentResolver.openInputStream(uri)
val text = stream!!.reader().use { it.readText() }
onLoaded(text, uri.toString())
} catch (ex: Exception) {
onError(ex)
} finally {
stream?.close()
}
}
requests[requestCode] = Request(onFileChosen)
openLoadFileChooser(requestCode)
requestCode += 1
}
fun onActivityResult(requestCode: Int, data: Intent?) {
val uri: Uri = data?.data ?: return
val request = requests.remove(requestCode) ?: return
request.onFileChosen(uri)
}
private fun openSaveFileChooser(fileName: String, uri: Uri, requestCode: Int) {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.type = "application/json"
intent.putExtra(Intent.EXTRA_TITLE, fileName)
if (uri.scheme == "content")
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri)
activity.startActivityForResult(intent, requestCode)
}
private fun openLoadFileChooser(requestCode: Int) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.type = "*/*"
/* It is theoretically possible to use an initial URI here,
however, the only Android URIs we have are obtained from here, so, no dice */
activity.startActivityForResult(intent, requestCode)
}
private fun getFilename(uri: Uri, suggestedLocation: String): String {
if (uri.scheme != "content")
return suggestedLocation
try {
contentResolver.query(uri, null, null, null, null).use {
if (it?.moveToFirst() == true)
return it.getString(it.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
else
return ""
}
} catch(ex: Exception) {
return suggestedLocation.split("2F").last() // I have no idea why but the content path ends with this before the filename
}
}
}

View File

@ -1,13 +0,0 @@
package com.unciv.app
import android.os.Build
import com.unciv.ui.crashhandling.CrashReportSysInfo
object CrashReportSysInfoAndroid: CrashReportSysInfo {
override fun getInfo(): String =
"""
Device Model: ${Build.MODEL}
API Level: ${Build.VERSION.SDK_INT}
""".trimIndent()
}

View File

@ -1,98 +0,0 @@
package com.unciv.app
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.annotation.GuardedBy
import com.unciv.logic.files.CustomFileLocationHelper
import java.io.InputStream
import java.io.OutputStream
class CustomFileLocationHelperAndroid(private val activity: Activity) : CustomFileLocationHelper() {
@GuardedBy("this")
private val callbacks = mutableListOf<ActivityCallback>()
@GuardedBy("this")
private var curActivityRequestCode = 100
override fun createOutputStream(suggestedLocation: String, callback: (String?, OutputStream?, Exception?) -> Unit) {
val requestCode = createActivityCallback(callback) { activity.contentResolver.openOutputStream(it, "rwt") }
// When we loaded, we returned a "content://" URI as file location.
val uri = Uri.parse(suggestedLocation)
val fileName = if (uri.scheme == "content") {
try {
val cursor = activity.contentResolver.query(uri, null, null, null, null)
cursor.use {
// we should have a direct URI to a file, so first is enough
if (it?.moveToFirst() == true) {
it.getString(it.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
} else ""
}
}
catch(ex:Exception) {
suggestedLocation.split("2F").last() // I have no idea why but the content path ends with this before the filename
}
} else {
// if we didn't load, this is some file name entered by the user
suggestedLocation
}
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = "application/json"
putExtra(Intent.EXTRA_TITLE, fileName)
if (uri.scheme == "content") {
putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri)
}
activity.startActivityForResult(this, requestCode)
}
}
override fun createInputStream(callback: (String?, InputStream?, Exception?) -> Unit) {
val callbackIndex = createActivityCallback(callback, activity.contentResolver::openInputStream)
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "*/*"
// It is theoretically possible to use an initial URI here, however, the only Android URIs we have are obtained from here, so, no dice
activity.startActivityForResult(this, callbackIndex)
}
}
private fun <T> createActivityCallback(callback: (String?, T?, Exception?) -> Unit,
createValue: (Uri) -> T): Int {
synchronized(this) {
val requestCode = curActivityRequestCode++
val activityCallback = ActivityCallback(requestCode) { uri ->
if (uri == null) {
callback(null, null, null)
return@ActivityCallback
}
try {
val outputStream = createValue(uri)
callback(uri.toString(), outputStream, null)
} catch (ex: Exception) {
callback(null, null, ex)
}
}
callbacks.add(activityCallback)
return requestCode
}
}
fun onActivityResult(requestCode: Int, data: Intent?) {
val activityCallback = synchronized(this) {
val index = callbacks.indexOfFirst { it.requestCode == requestCode }
if (index == -1) return
callbacks.removeAt(index)
}
activityCallback.callback(data?.data)
}
}
private class ActivityCallback(
val requestCode: Int,
val callback: (Uri?) -> Unit
)

View File

@ -269,7 +269,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame
val gdxFiles = DefaultAndroidFiles(applicationContext.assets, ContextWrapper(applicationContext), true)
// GDX's AndroidFileHandle uses Gdx.files internally, so we need to set that to our new instance
Gdx.files = gdxFiles
files = UncivFiles(gdxFiles, null, true)
files = UncivFiles(gdxFiles)
}
override fun doWork(): Result = runBlocking {

View File

@ -1,68 +0,0 @@
package com.unciv.app
import android.app.Activity
import android.content.pm.ActivityInfo
import android.os.Build
import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.ui.components.GeneralPlatformSpecificHelpers
import kotlin.concurrent.thread
/** See also interface [GeneralPlatformSpecificHelpers].
*
* The Android implementation (currently the only one) effectively ends up doing
* [Activity.setRequestedOrientation]
*/
class PlatformSpecificHelpersAndroid(private val activity: Activity) : GeneralPlatformSpecificHelpers {
/*
Sources for Info about current orientation in case need:
val windowManager = (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager)
val displayRotation = windowManager.defaultDisplay.rotation
val currentOrientation = activity.resources.configuration.orientation
const val ORIENTATION_UNDEFINED = 0
const val ORIENTATION_PORTRAIT = 1
const val ORIENTATION_LANDSCAPE = 2
*/
override fun allowPortrait(allow: Boolean) {
val orientation = when {
allow -> ActivityInfo.SCREEN_ORIENTATION_USER
else -> ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
}
// Comparison ensures ActivityTaskManager.getService().setRequestedOrientation isn't called unless necessary
if (activity.requestedOrientation != orientation) activity.requestedOrientation = orientation
}
override fun hasDisplayCutout() = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
activity.display?.cutout != null
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
@Suppress("DEPRECATION")
activity.windowManager.defaultDisplay.cutout != null
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ->
activity.window.decorView.rootWindowInsets.displayCutout != null
else -> false
}
override fun toggleDisplayCutout(androidCutout: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
val layoutParams = activity.window.attributes
if (androidCutout) {
layoutParams.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} else {
layoutParams.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
}
}
/**
* On Android, local is some android-internal data directory which may or may not be accessible by the user.
* External is probably on an SD-card or similar which is always accessible by the user.
*/
override fun shouldPreferExternalStorage(): Boolean = true
override fun addImprovements(textField: TextField): TextField {
return TextfieldImprovements.add(textField)
}
}

View File

@ -1,113 +0,0 @@
package com.unciv.app
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.InputEvent
import com.badlogic.gdx.scenes.scene2d.InputListener
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.badlogic.gdx.scenes.scene2d.utils.FocusListener
import com.unciv.logic.event.EventBus
import com.unciv.models.translations.tr
import com.unciv.ui.screens.basescreen.UncivStage
import com.unciv.ui.popups.Popup
import com.unciv.ui.components.KeyCharAndCode
import com.unciv.ui.components.extensions.getAscendant
import com.unciv.ui.components.scrollAscendantToTextField
import com.unciv.utils.concurrency.Concurrency
import com.unciv.utils.concurrency.withGLContext
import kotlinx.coroutines.delay
object TextfieldImprovements {
private val hideKeyboard = { Gdx.input.setOnscreenKeyboardVisible(false) }
fun add(textField: TextField): TextField {
textField.addListener(object : InputListener() {
private val events = EventBus.EventReceiver()
init {
events.receive(UncivStage.VisibleAreaChanged::class) {
if (textField.stage == null || !textField.hasKeyboardFocus()) return@receive
Concurrency.run {
// If anything resizes, it also does so with this event. So we need to wait for that to finish to update the scroll position.
delay(100)
withGLContext {
if (textField.stage == null) return@withGLContext
if (textField.scrollAscendantToTextField()) {
val scrollPane = textField.getAscendant { it is ScrollPane } as ScrollPane?
// when screen dimensions change, we don't want an animation for scrolling, just show, just show the textfield immediately
scrollPane?.updateVisualScroll()
} else {
// We can't scroll the text field into view, so we need to show a popup
TextfieldPopup(textField).open()
}
}
}
}
}
override fun touchDown(event: InputEvent, x: Float, y: Float, pointer: Int, button: Int): Boolean {
addPopupCloseListener(textField)
return false
}
})
textField.addListener(object : FocusListener() {
override fun keyboardFocusChanged(event: FocusEvent?, actor: Actor?, focused: Boolean) {
if (focused) {
addPopupCloseListener(textField)
Gdx.input.setOnscreenKeyboardVisible(true)
}
}
})
return textField
}
private fun addPopupCloseListener(textField: TextField) {
val popup = textField.getAscendant { it is Popup } as Popup?
if (popup != null && !popup.closeListeners.contains(hideKeyboard)) {
popup.closeListeners.add(hideKeyboard)
}
}
}
class TextfieldPopup(
textField: TextField
) : Popup(textField.stage) {
val popupTextfield = clone(textField)
init {
addGoodSizedLabel(popupTextfield.messageText)
.colspan(2)
.row()
add(popupTextfield)
.width(stageToShowOn.width / 2)
.colspan(2)
.row()
addCloseButton("Cancel")
.left()
addOKButton { textField.text = popupTextfield.text }
.right()
.row()
showListeners.add {
stageToShowOn.keyboardFocus = popupTextfield
}
closeListeners.add {
stageToShowOn.keyboardFocus = null
Gdx.input.setOnscreenKeyboardVisible(false)
}
}
private fun clone(textField: TextField): TextField {
@Suppress("UNCIV_RAW_TEXTFIELD") // we are copying the existing text field
val copy = TextField(textField.text, textField.style)
copy.textFieldFilter = textField.textFieldFilter
copy.messageText = textField.messageText
copy.setSelection(textField.selectionStart, textField.selection.length)
copy.cursorPosition = textField.cursorPosition
copy.alignment = textField.alignment
copy.isPasswordMode = textField.isPasswordMode
copy.onscreenKeyboard = textField.onscreenKeyboard
return copy
}
}

View File

@ -25,7 +25,7 @@ import com.unciv.ui.audio.MusicController
import com.unciv.ui.audio.MusicMood
import com.unciv.ui.audio.MusicTrackChooserFlags
import com.unciv.ui.audio.SoundPlayer
import com.unciv.ui.components.FontImplementation
import com.unciv.ui.components.Fonts
import com.unciv.ui.components.extensions.center
import com.unciv.ui.crashhandling.CrashScreen
import com.unciv.ui.crashhandling.wrapCrashHandlingUnit
@ -41,7 +41,9 @@ import com.unciv.ui.screens.worldscreen.PlayerReadyScreen
import com.unciv.ui.screens.worldscreen.WorldMapHolder
import com.unciv.ui.screens.worldscreen.WorldScreen
import com.unciv.ui.screens.worldscreen.unit.UnitTable
import com.unciv.utils.DebugUtils
import com.unciv.utils.Log
import com.unciv.utils.PlatformSpecific
import com.unciv.utils.concurrency.Concurrency
import com.unciv.utils.concurrency.launchOnGLThread
import com.unciv.utils.concurrency.withGLContext
@ -51,13 +53,10 @@ import kotlinx.coroutines.CancellationException
import java.io.PrintWriter
import java.util.*
import kotlin.collections.ArrayDeque
import kotlin.system.exitProcess
object GUI {
fun isDebugMapVisible(): Boolean {
return UncivGame.Current.viewEntireMapForDebug
}
fun setUpdateWorldOnNextRender() {
UncivGame.Current.worldScreen?.shouldUpdate = true
}
@ -74,10 +73,6 @@ object GUI {
return UncivGame.Current.settings
}
fun getFontImpl(): FontImplementation {
return UncivGame.Current.fontImplementation!!
}
fun isWorldLoaded(): Boolean {
return UncivGame.Current.worldScreen != null
}
@ -116,19 +111,11 @@ object GUI {
}
class UncivGame(parameters: UncivGameParameters) : Game() {
constructor() : this(UncivGameParameters())
val crashReportSysInfo = parameters.crashReportSysInfo
val cancelDiscordEvent = parameters.cancelDiscordEvent
var fontImplementation = parameters.fontImplementation
val consoleMode = parameters.consoleMode
private val customSaveLocationHelper = parameters.customFileLocationHelper
val platformSpecificHelper = parameters.platformSpecificHelper
private val audioExceptionHelper = parameters.audioExceptionHelper
open class UncivGame(val isConsoleMode: Boolean = false) : Game(), PlatformSpecific {
var deepLinkedMultiplayerGame: String? = null
var gameInfo: GameInfo? = null
lateinit var settings: GameSettings
lateinit var musicController: MusicController
lateinit var onlineMultiplayer: OnlineMultiplayer
@ -136,20 +123,6 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
var isTutorialTaskCollapsed = false
/**
* This exists so that when debugging we can see the entire map.
* Remember to turn this to false before commit and upload!
*/
var viewEntireMapForDebug = false
/** For when you need to test something in an advanced game and don't have time to faff around */
var superchargedForDebug = false
/** Simulate until this turn on the first "Next turn" button press.
* Does not update World View changes until finished.
* Set to 0 to disable.
*/
var simulateUntilTurnForDebug: Int = 0
var worldScreen: WorldScreen? = null
private set
@ -167,10 +140,10 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
isInitialized = false // this could be on reload, therefore we need to keep setting this to false
Gdx.input.setCatchKey(Input.Keys.BACK, true)
if (Gdx.app.type != Application.ApplicationType.Desktop) {
viewEntireMapForDebug = false
DebugUtils.VISIBLE_MAP = false
}
Current = this
files = UncivFiles(Gdx.files, customSaveLocationHelper, platformSpecificHelper?.shouldPreferExternalStorage() == true)
files = UncivFiles(Gdx.files)
// If this takes too long players, especially with older phones, get ANR problems.
// Whatever needs graphics needs to be done on the main thread,
@ -190,10 +163,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
GameSounds.init()
musicController = MusicController() // early, but at this point does only copy volume from settings
audioExceptionHelper?.installHooks(
musicController.getAudioLoopCallback(),
musicController.getAudioExceptionHandler()
)
installAudioHooks()
onlineMultiplayer = OnlineMultiplayer()
@ -230,7 +200,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
// Loading available fonts can take a long time on Android phones.
// Therefore we initialize the lazy parameters in the font implementation, while we're in another thread, to avoid ANRs on main thread
fontImplementation?.setFontFamily(settings.fontFamilyData, settings.getFontSize())
Fonts.fontImplementation.setFontFamily(settings.fontFamilyData, settings.getFontSize())
// This stuff needs to run on the main thread because it needs the GL context
launchOnGLThread {
@ -474,8 +444,6 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
override fun dispose() {
Gdx.input.inputProcessor = null // don't allow ANRs when shutting down, that's silly
cancelDiscordEvent?.invoke()
SoundPlayer.clearCache()
if (::musicController.isInitialized) musicController.gracefulShutdown() // Do allow fade-out
@ -498,7 +466,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
// On desktop this should only be this one and "DestroyJavaVM"
logRunningThreads()
System.exit(0)
exitProcess(0)
}
private fun logRunningThreads() {
@ -523,7 +491,6 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
} catch (ex: Exception) {
// ignore
}
if (platformSpecificHelper?.handleUncaughtThrowable(ex) == true) return
Gdx.app.postRunnable {
setAsRootScreen(CrashScreen(ex))
}

View File

@ -1,16 +0,0 @@
package com.unciv
import com.unciv.logic.files.CustomFileLocationHelper
import com.unciv.ui.crashhandling.CrashReportSysInfo
import com.unciv.ui.components.AudioExceptionHelper
import com.unciv.ui.components.GeneralPlatformSpecificHelpers
import com.unciv.ui.components.FontImplementation
class UncivGameParameters(val crashReportSysInfo: CrashReportSysInfo? = null,
val cancelDiscordEvent: (() -> Unit)? = null,
val fontImplementation: FontImplementation? = null,
val consoleMode: Boolean = false,
val customFileLocationHelper: CustomFileLocationHelper? = null,
val platformSpecificHelper: GeneralPlatformSpecificHelpers? = null,
val audioExceptionHelper: AudioExceptionHelper? = null
)

View File

@ -32,6 +32,7 @@ import com.unciv.models.ruleset.nation.Difficulty
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.ui.audio.MusicMood
import com.unciv.ui.audio.MusicTrackChooserFlags
import com.unciv.utils.DebugUtils
import com.unciv.utils.debug
import java.util.*
@ -253,7 +254,7 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion
//region State changing functions
// Do we automatically simulate until N turn?
fun isSimulation(): Boolean = turns < UncivGame.Current.simulateUntilTurnForDebug
fun isSimulation(): Boolean = turns < DebugUtils.SIMULATE_UNTIL_TURN
|| turns < simulateMaxTurns && simulateUntilWin
fun nextTurn() {
@ -266,7 +267,7 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion
playerIndex = (playerIndex + 1) % civilizations.size
if (playerIndex == 0) {
turns++
if (UncivGame.Current.simulateUntilTurnForDebug != 0)
if (DebugUtils.SIMULATE_UNTIL_TURN != 0)
debug("Starting simulation of turn %s", turns)
}
player = civilizations[playerIndex]
@ -311,8 +312,8 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion
setNextPlayer()
}
if (turns == UncivGame.Current.simulateUntilTurnForDebug)
UncivGame.Current.simulateUntilTurnForDebug = 0
if (turns == DebugUtils.SIMULATE_UNTIL_TURN)
DebugUtils.SIMULATE_UNTIL_TURN = 0
// We found human player, so we are making him current
currentTurnStartTime = System.currentTimeMillis()

View File

@ -17,6 +17,7 @@ import com.unciv.models.stats.Stat
import com.unciv.models.stats.StatMap
import com.unciv.models.stats.Stats
import com.unciv.ui.components.extensions.toPercent
import com.unciv.utils.DebugUtils
import kotlin.math.min
@ -464,7 +465,7 @@ class CityStats(val city: City) {
newStatsBonusTree.add(getStatsPercentBonusesFromUniquesBySource(currentConstruction))
if (UncivGame.Current.superchargedForDebug) {
if (DebugUtils.SUPERCHARGED) {
val stats = Stats()
for (stat in Stat.values()) stats[stat] = 10000f
newStatsBonusTree.addStats(stats, "Supercharged")

View File

@ -2,7 +2,6 @@ package com.unciv.logic.civilization.transients
import com.badlogic.gdx.math.Vector2
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.city.City
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.civilization.NotificationCategory
@ -19,6 +18,7 @@ import com.unciv.models.ruleset.unique.UniqueTarget
import com.unciv.models.ruleset.unique.UniqueTriggerActivation
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.ruleset.unit.BaseUnit
import com.unciv.utils.DebugUtils
/** CivInfo class was getting too crowded */
class CivInfoTransientCache(val civInfo: Civilization) {
@ -142,7 +142,7 @@ class CivInfoTransientCache(val civInfo: Civilization) {
val newViewableTiles = HashSet<Tile>()
// while spectating all map is visible
if (civInfo.isSpectator() || UncivGame.Current.viewEntireMapForDebug) {
if (civInfo.isSpectator() || DebugUtils.VISIBLE_MAP) {
val allTiles = civInfo.gameInfo.tileMap.values.toSet()
civInfo.viewableTiles = allTiles
civInfo.viewableInvisibleUnitsTiles = allTiles

View File

@ -1,95 +0,0 @@
package com.unciv.logic.files
import com.unciv.logic.files.UncivFiles.CustomLoadResult
import com.unciv.logic.files.UncivFiles.CustomSaveResult
import com.unciv.utils.concurrency.Concurrency
import java.io.InputStream
import java.io.OutputStream
/**
* Contract for platform-specific helper classes to handle saving and loading games to and from
* arbitrary external locations.
*
* Implementation note: If a game is loaded with [loadGame] and the same game is saved with [saveGame],
* the suggestedLocation in [saveGame] will be the location returned by [loadGame].
*/
abstract class CustomFileLocationHelper {
/**
* Saves a game asynchronously to a location selected by the user.
*
* Prefills their UI with a [suggestedLocation].
*
* Calls the [saveCompleteCallback] on the main thread with the save location on success or the [Exception] on error or null in both on cancel.
*/
fun saveGame(
gameData: String,
suggestedLocation: String,
saveCompleteCallback: (CustomSaveResult) -> Unit = {}
) {
createOutputStream(suggestedLocation) { location, outputStream, exception ->
if (outputStream == null) {
callSaveCallback(saveCompleteCallback, exception = exception)
return@createOutputStream
}
try {
outputStream.writer().use { it.write(gameData) }
callSaveCallback(saveCompleteCallback, location)
} catch (ex: Exception) {
callSaveCallback(saveCompleteCallback, exception = ex)
}
}
}
/**
* Loads a game asynchronously from a location selected by the user.
*
* Calls the [loadCompleteCallback] on the main thread.
*/
fun loadGame(loadCompleteCallback: (CustomLoadResult<String>) -> Unit) {
createInputStream { location, inputStream, exception ->
if (inputStream == null) {
callLoadCallback(loadCompleteCallback, exception = exception)
return@createInputStream
}
try {
val gameData = inputStream.reader().use { it.readText() }
callLoadCallback(loadCompleteCallback, location, gameData)
} catch (ex: Exception) {
callLoadCallback(loadCompleteCallback, exception = ex)
}
}
}
/**
* [callback] should be called with the actual selected location and an OutputStream to the location, or an exception if something failed.
*/
protected abstract fun createOutputStream(suggestedLocation: String, callback: (String?, OutputStream?, Exception?) -> Unit)
/**
* [callback] should be called with the actual selected location and an InputStream to read the location, or an exception if something failed.
*/
protected abstract fun createInputStream(callback: (String?, InputStream?, Exception?) -> Unit)
}
private fun callLoadCallback(loadCompleteCallback: (CustomLoadResult<String>) -> Unit,
location: String? = null,
gameData: String? = null,
exception: Exception? = null) {
val result = if (location != null && gameData != null && exception == null) {
CustomLoadResult(location to gameData)
} else {
CustomLoadResult(null, exception)
}
Concurrency.runOnGLThread {
loadCompleteCallback(result)
}
}
private fun callSaveCallback(saveCompleteCallback: (CustomSaveResult) -> Unit,
location: String? = null,
exception: Exception? = null) {
Concurrency.runOnGLThread {
saveCompleteCallback(CustomSaveResult(location, exception))
}
}

View File

@ -0,0 +1,23 @@
package com.unciv.logic.files
/**
* Contract for platform-specific helper classes to handle saving and loading games to and from
* arbitrary external locations.
*
* Implementation note: If a game is loaded with [loadGame] and the same game is saved with [saveGame],
* the suggestedLocation in [saveGame] will be the location returned by [loadGame].
*/
interface PlatformSaverLoader {
fun saveGame(
data: String, // Data to save
suggestedLocation: String, // Proposed location
onSaved: (location: String) -> Unit = {}, // On-save-complete callback
onError: (ex: Exception) -> Unit = {} // On-save-error callback
)
fun loadGame(
onLoaded: (data: String, location: String) -> Unit, // On-load-complete callback
onError: (Exception) -> Unit = {} // On-load-error callback
)
}

View File

@ -30,13 +30,11 @@ class UncivFiles(
* This is necessary because the Android turn check background worker does not hold any reference to the actual [com.badlogic.gdx.Application],
* which is normally responsible for keeping the [Gdx] static variables from being garbage collected.
*/
private val files: Files,
private val customFileLocationHelper: CustomFileLocationHelper? = null,
private val preferExternalStorage: Boolean = false
private val files: Files
) {
init {
debug("Creating UncivFiles, localStoragePath: %s, externalStoragePath: %s, preferExternalStorage: %s",
files.localStoragePath, files.externalStoragePath, preferExternalStorage)
debug("Creating UncivFiles, localStoragePath: %s, externalStoragePath: %s",
files.localStoragePath, files.externalStoragePath)
}
//region Data
@ -112,8 +110,6 @@ class UncivFiles(
return localFiles + externalFiles
}
fun canLoadFromCustomSaveLocation() = customFileLocationHelper != null
/**
* @return `true` if successful.
* @throws SecurityException when delete access was denied
@ -141,15 +137,6 @@ class UncivFiles(
return file.delete()
}
interface ChooseLocationResult {
val location: String?
val exception: Exception?
fun isCanceled(): Boolean = location == null && exception == null
fun isError(): Boolean = exception != null
fun isSuccessful(): Boolean = location != null
}
//endregion
//region Saving
@ -165,7 +152,8 @@ class UncivFiles(
fun saveGame(game: GameInfo, file: FileHandle, saveCompletionCallback: (Exception?) -> Unit = { if (it != null) throw it }) {
try {
debug("Saving GameInfo %s to %s", game.gameId, file.path())
file.writeString(gameInfoToString(game), false)
val string = gameInfoToString(game)
file.writeString(string, false)
saveCompletionCallback(null)
} catch (ex: Exception) {
saveCompletionCallback(ex)
@ -194,31 +182,35 @@ class UncivFiles(
}
}
class CustomSaveResult(
override val location: String? = null,
override val exception: Exception? = null
) : ChooseLocationResult
/**
* [gameName] is a suggested name for the file. If the file has already been saved to or loaded from a custom location,
* this previous custom location will be used.
*
* Calls the [saveCompleteCallback] on the main thread with the save location on success, an [Exception] on error, or both null on cancel.
* Calls the [onSaved] on the main thread on success.
* Calls the [onError] on the main thread with an [Exception] on error.
*/
fun saveGameToCustomLocation(game: GameInfo, gameName: String, saveCompletionCallback: (CustomSaveResult) -> Unit) {
fun saveGameToCustomLocation(
game: GameInfo,
gameName: String,
onSaved: () -> Unit,
onError: (Exception) -> Unit) {
val saveLocation = game.customSaveLocation ?: Gdx.files.local(gameName).path()
val gameData = try {
gameInfoToString(game)
try {
val data = gameInfoToString(game)
debug("Saving GameInfo %s to custom location %s", game.gameId, saveLocation)
saverLoader.saveGame(data, saveLocation,
{ location ->
game.customSaveLocation = location
Concurrency.runOnGLThread { onSaved() }
},
{
Concurrency.runOnGLThread { onError(it) }
}
)
} catch (ex: Exception) {
Concurrency.runOnGLThread { saveCompletionCallback(CustomSaveResult(exception = ex)) }
return
}
debug("Saving GameInfo %s to custom location %s", game.gameId, saveLocation)
customFileLocationHelper!!.saveGame(gameData, saveLocation) {
if (it.isSuccessful()) {
game.customSaveLocation = it.location
}
saveCompletionCallback(it)
Concurrency.runOnGLThread { onError(ex) }
}
}
@ -240,11 +232,7 @@ class UncivFiles(
loadGamePreviewFromFile(getMultiplayerSave(gameName))
fun loadGamePreviewFromFile(gameFile: FileHandle): GameInfoPreview {
val preview = json().fromJson(GameInfoPreview::class.java, gameFile)
if (preview == null) {
throw emptyFile(gameFile)
}
return preview
return json().fromJson(GameInfoPreview::class.java, gameFile) ?: throw emptyFile(gameFile)
}
/**
@ -256,36 +244,29 @@ class UncivFiles(
return SerializationException("The file for the game ${gameFile.name()} is empty")
}
class CustomLoadResult<T>(
private val locationAndGameData: Pair<String, T>? = null,
override val exception: Exception? = null
) : ChooseLocationResult {
override val location: String? get() = locationAndGameData?.first
val gameData: T? get() = locationAndGameData?.second
}
/**
* Calls the [loadCompleteCallback] on the main thread with the [GameInfo] on success or the [Exception] on error or null in both on cancel.
*
* The exception may be [IncompatibleGameInfoVersionException] if the [gameData] was created by a version of this game that is incompatible with the current one.
* Calls the [onLoaded] on the main thread with the [GameInfo] on success.
* Calls the [onError] on the main thread with the [Exception] on error
* The exception may be [IncompatibleGameInfoVersionException] if the [GameInfo] was created by a version of this game that is incompatible with the current one.
*/
fun loadGameFromCustomLocation(loadCompletionCallback: (CustomLoadResult<GameInfo>) -> Unit) {
customFileLocationHelper!!.loadGame { result ->
val location = result.location
val gameData = result.gameData
if (location == null || gameData == null) {
loadCompletionCallback(CustomLoadResult(exception = result.exception))
return@loadGame
fun loadGameFromCustomLocation(
onLoaded: (GameInfo) -> Unit,
onError: (Exception) -> Unit
) {
saverLoader.loadGame(
{ data, location ->
try {
val game = gameInfoFromString(data)
game.customSaveLocation = location
Concurrency.runOnGLThread { onLoaded(game) }
} catch (ex: Exception) {
Concurrency.runOnGLThread { onError(ex) }
}
},
{
Concurrency.runOnGLThread { onError(it) }
}
try {
val gameInfo = gameInfoFromString(gameData)
gameInfo.customSaveLocation = location
loadCompletionCallback(CustomLoadResult(location to gameInfo))
} catch (ex: Exception) {
loadCompletionCallback(CustomLoadResult(exception = ex))
}
}
)
}
@ -293,7 +274,7 @@ class UncivFiles(
//region Settings
private fun getGeneralSettingsFile(): FileHandle {
return if (UncivGame.Current.consoleMode) FileHandle(SETTINGS_FILE_NAME)
return if (UncivGame.Current.isConsoleMode) FileHandle(SETTINGS_FILE_NAME)
else files.local(SETTINGS_FILE_NAME)
}
@ -325,6 +306,17 @@ class UncivFiles(
var saveZipped = false
/**
* If the GDX [com.badlogic.gdx.Files.getExternalStoragePath] should be preferred for this platform,
* otherwise uses [com.badlogic.gdx.Files.getLocalStoragePath]
*/
var preferExternalStorage = false
/**
* Platform dependent saver-loader to custom system locations
*/
lateinit var saverLoader: PlatformSaverLoader
/** Specialized function to access settings before Gdx is initialized.
*
* @param base Path to the directory where the file should be - if not set, the OS current directory is used (which is "/" on Android)
@ -333,11 +325,7 @@ class UncivFiles(
// FileHandle is Gdx, but the class and JsonParser are not dependent on app initialization
// In fact, at this point Gdx.app or Gdx.files are null but this still works.
val file = FileHandle(base + File.separator + SETTINGS_FILE_NAME)
return if (file.exists())
json().fromJsonFile(
GameSettings::class.java,
file
)
return if (file.exists()) json().fromJsonFile(GameSettings::class.java, file)
else GameSettings().apply { isFreshlyCreated = true }
}

View File

@ -3,7 +3,6 @@
import com.badlogic.gdx.math.Vector2
import com.unciv.Constants
import com.unciv.GUI
import com.unciv.UncivGame
import com.unciv.logic.IsPartOfGameInfoSerialization
import com.unciv.logic.city.City
import com.unciv.logic.civilization.Civilization
@ -22,6 +21,7 @@ import com.unciv.models.ruleset.unique.StateForConditionals
import com.unciv.models.ruleset.unique.Unique
import com.unciv.models.ruleset.unique.UniqueMap
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.utils.DebugUtils
import kotlin.math.abs
import kotlin.math.min
import kotlin.random.Random
@ -235,13 +235,13 @@ open class Tile : IsPartOfGameInfoSerialization {
else ruleset.terrains[naturalWonder!!]!!
fun isVisible(player: Civilization): Boolean {
if (UncivGame.Current.viewEntireMapForDebug)
if (DebugUtils.VISIBLE_MAP)
return true
return player.viewableTiles.contains(this)
}
fun isExplored(player: Civilization): Boolean {
if (UncivGame.Current.viewEntireMapForDebug || player.isSpectator())
if (DebugUtils.VISIBLE_MAP || player.isSpectator())
return true
return exploredBy.contains(player.civName)
}

View File

@ -1,19 +1,19 @@
package com.unciv.logic.map.tile
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.civilization.Civilization
import com.unciv.models.ruleset.tile.ResourceType
import com.unciv.models.translations.tr
import com.unciv.ui.screens.civilopediascreen.FormattedLine
import com.unciv.ui.components.Fonts
import com.unciv.utils.DebugUtils
object TileDescription {
/** Get info on a selected tile, used on WorldScreen (right side above minimap), CityScreen or MapEditorViewTab. */
fun toMarkup(tile: Tile, viewingCiv: Civilization?): ArrayList<FormattedLine> {
val lineList = ArrayList<FormattedLine>()
val isViewableToPlayer = viewingCiv == null || UncivGame.Current.viewEntireMapForDebug
val isViewableToPlayer = viewingCiv == null || DebugUtils.VISIBLE_MAP
|| viewingCiv.viewableTiles.contains(tile)
if (tile.isCityCenter()) {
@ -21,7 +21,7 @@ object TileDescription {
var cityString = city.name.tr()
if (isViewableToPlayer) cityString += " (${city.health})"
lineList += FormattedLine(cityString)
if (UncivGame.Current.viewEntireMapForDebug || city.civ == viewingCiv)
if (DebugUtils.VISIBLE_MAP || city.civ == viewingCiv)
lineList += city.cityConstructions.getProductionMarkup(tile.ruleset)
}

View File

@ -177,6 +177,7 @@ object Fonts {
const val ORIGINAL_FONT_SIZE = 50f
const val DEFAULT_FONT_FAMILY = ""
lateinit var fontImplementation: FontImplementation
lateinit var font: BitmapFont
/** This resets all cached font data in object Fonts.
@ -184,15 +185,12 @@ object Fonts {
*/
fun resetFont() {
val settings = GUI.getSettings()
val fontImpl = GUI.getFontImpl()
fontImpl.setFontFamily(settings.fontFamilyData, settings.getFontSize())
font = fontImpl.getBitmapFont()
fontImplementation.setFontFamily(settings.fontFamilyData, settings.getFontSize())
font = fontImplementation.getBitmapFont()
}
/** Reduce the font list returned by platform-specific code to font families (plain variant if possible) */
fun getSystemFonts(): Sequence<FontFamilyData> {
val fontImplementation = UncivGame.Current.fontImplementation
?: return emptySequence()
return fontImplementation.getSystemFonts()
.sortedWith(compareBy(UncivGame.Current.settings.getCollatorFromLocale()) { it.localName })
}

View File

@ -1,42 +0,0 @@
package com.unciv.ui.components
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.unciv.models.metadata.GameSettings
/** Interface to support various platform-specific tools */
interface GeneralPlatformSpecificHelpers {
/** Pass a Boolean setting as used in [allowAndroidPortrait][GameSettings.allowAndroidPortrait] to the OS.
*
* You can turn a mobile device on its side or upside down, and a mobile OS may or may not allow the
* position changes to automatically result in App orientation changes. This is about limiting that feature.
* @param allow `true`: allow all orientations (follows sensor as limited by OS settings)
* `false`: allow only landscape orientations (both if supported, otherwise default landscape only)
*/
fun allowPortrait(allow: Boolean) {}
fun hasDisplayCutout(): Boolean { return false }
fun toggleDisplayCutout(androidCutout: Boolean) {}
/**
* Notifies the user that it's their turn while the game is running
*/
fun notifyTurnStarted() {}
/**
* If the GDX [com.badlogic.gdx.Files.getExternalStoragePath] should be preferred for this platform,
* otherwise uses [com.badlogic.gdx.Files.getLocalStoragePath]
*/
fun shouldPreferExternalStorage(): Boolean
/**
* Handle an uncaught throwable.
* @return true if the throwable was handled.
*/
fun handleUncaughtThrowable(ex: Throwable): Boolean = false
/**
* Adds platform-specific improvements to the given text field, making it nicer to interact with on this platform.
*/
fun addImprovements(textField: TextField): TextField = textField
}

View File

@ -1,11 +1,16 @@
package com.unciv.ui.components
import com.badlogic.gdx.Application
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.InputEvent
import com.badlogic.gdx.scenes.scene2d.InputListener
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
import com.badlogic.gdx.scenes.scene2d.ui.TextField
import com.badlogic.gdx.scenes.scene2d.utils.FocusListener
import com.unciv.UncivGame
import com.unciv.logic.event.EventBus
import com.unciv.models.translations.tr
import com.unciv.ui.screens.basescreen.UncivStage
import com.unciv.ui.components.extensions.getAscendant
@ -13,7 +18,11 @@ import com.unciv.ui.components.extensions.getOverlap
import com.unciv.ui.components.extensions.right
import com.unciv.ui.components.extensions.stageBoundingBox
import com.unciv.ui.components.extensions.top
import com.unciv.ui.popups.Popup
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.utils.concurrency.Concurrency
import com.unciv.utils.concurrency.withGLContext
import kotlinx.coroutines.delay
object UncivTextField {
/**
@ -33,7 +42,9 @@ object UncivTextField {
}
}
})
UncivGame.Current.platformSpecificHelper?.addImprovements(textField)
if (Gdx.app.type == Application.ApplicationType.Android)
TextfieldImprovements.add(textField)
return textField
}
}
@ -96,3 +107,97 @@ fun TextField.scrollAscendantToTextField(): Boolean {
return true
}
object TextfieldImprovements {
private val hideKeyboard = { Gdx.input.setOnscreenKeyboardVisible(false) }
fun add(textField: TextField): TextField {
textField.addListener(object : InputListener() {
private val events = EventBus.EventReceiver()
init {
events.receive(UncivStage.VisibleAreaChanged::class) {
if (textField.stage == null || !textField.hasKeyboardFocus()) return@receive
Concurrency.run {
// If anything resizes, it also does so with this event. So we need to wait for that to finish to update the scroll position.
delay(100)
withGLContext {
if (textField.stage == null) return@withGLContext
if (textField.scrollAscendantToTextField()) {
val scrollPane = textField.getAscendant { it is ScrollPane } as ScrollPane?
// when screen dimensions change, we don't want an animation for scrolling, just show, just show the textfield immediately
scrollPane?.updateVisualScroll()
} else {
// We can't scroll the text field into view, so we need to show a popup
TextfieldPopup(textField).open()
}
}
}
}
}
override fun touchDown(event: InputEvent, x: Float, y: Float, pointer: Int, button: Int): Boolean {
addPopupCloseListener(textField)
return false
}
})
textField.addListener(object : FocusListener() {
override fun keyboardFocusChanged(event: FocusEvent?, actor: Actor?, focused: Boolean) {
if (focused) {
addPopupCloseListener(textField)
Gdx.input.setOnscreenKeyboardVisible(true)
}
}
})
return textField
}
private fun addPopupCloseListener(textField: TextField) {
val popup = textField.getAscendant { it is Popup } as Popup?
if (popup != null && !popup.closeListeners.contains(hideKeyboard)) {
popup.closeListeners.add(hideKeyboard)
}
}
}
class TextfieldPopup(
textField: TextField
) : Popup(textField.stage) {
val popupTextfield = clone(textField)
init {
addGoodSizedLabel(popupTextfield.messageText)
.colspan(2)
.row()
add(popupTextfield)
.width(stageToShowOn.width / 2)
.colspan(2)
.row()
addCloseButton("Cancel")
.left()
addOKButton { textField.text = popupTextfield.text }
.right()
.row()
showListeners.add {
stageToShowOn.keyboardFocus = popupTextfield
}
closeListeners.add {
stageToShowOn.keyboardFocus = null
Gdx.input.setOnscreenKeyboardVisible(false)
}
}
private fun clone(textField: TextField): TextField {
@Suppress("UNCIV_RAW_TEXTFIELD") // we are copying the existing text field
val copy = TextField(textField.text, textField.style)
copy.textFieldFilter = textField.textFieldFilter
copy.messageText = textField.messageText
copy.setSelection(textField.selectionStart, textField.selection.length)
copy.cursorPosition = textField.cursorPosition
copy.alignment = textField.alignment
copy.isPasswordMode = textField.isPasswordMode
copy.onscreenKeyboard = textField.onscreenKeyboard
return copy
}
}

View File

@ -33,6 +33,7 @@ import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.cityscreen.CityReligionInfoTable
import com.unciv.ui.screens.cityscreen.CityScreen
import com.unciv.ui.screens.diplomacyscreen.DiplomacyScreen
import com.unciv.utils.DebugUtils
import kotlin.math.max
import kotlin.math.min
@ -228,7 +229,7 @@ private class CityTable(city: City, forPopup: Boolean = false) : BorderedTable(
pad(0f)
defaults().pad(0f)
val isShowDetailedInfo = UncivGame.Current.viewEntireMapForDebug
val isShowDetailedInfo = DebugUtils.VISIBLE_MAP
|| city.civ == viewingCiv
|| viewingCiv.isSpectator()
@ -532,7 +533,7 @@ class CityButton(val city: City, private val tileGroup: TileGroup): Table(BaseSc
if (isButtonMoved) {
// second tap on the button will go to the city screen
// if this city belongs to you and you are not iterating though the air units
if (GUI.isDebugMapVisible() || viewingPlayer.isSpectator()
if (DebugUtils.VISIBLE_MAP || viewingPlayer.isSpectator()
|| (belongsToViewingCiv() && !tileGroup.tile.airUnits.contains(unitTable.selectedUnit))) {
GUI.pushScreen(CityScreen(city))
} else if (viewingPlayer.knows(city.civ)) {

View File

@ -2,7 +2,6 @@ package com.unciv.ui.components.tilegroups
import com.badlogic.gdx.graphics.g2d.Batch
import com.badlogic.gdx.scenes.scene2d.Group
import com.unciv.UncivGame
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.map.tile.Tile
import com.unciv.ui.components.tilegroups.layers.TileLayerBorders
@ -13,6 +12,7 @@ import com.unciv.ui.components.tilegroups.layers.TileLayerOverlay
import com.unciv.ui.components.tilegroups.layers.TileLayerTerrain
import com.unciv.ui.components.tilegroups.layers.TileLayerUnitArt
import com.unciv.ui.components.tilegroups.layers.TileLayerUnitFlag
import com.unciv.utils.DebugUtils
import kotlin.math.pow
import kotlin.math.sqrt
@ -40,7 +40,7 @@ open class TileGroup(
val hexagonImageOrigin = Pair(hexagonImageWidth / 2f, sqrt((hexagonImageWidth / 2f).pow(2) - (hexagonImageWidth / 4f).pow(2)))
val hexagonImagePosition = Pair(-hexagonImageOrigin.first / 3f, -hexagonImageOrigin.second / 4f)
var isForceVisible = UncivGame.Current.viewEntireMapForDebug
var isForceVisible = DebugUtils.VISIBLE_MAP
var isForMapEditorIcon = false
@Suppress("LeakingThis") val layerTerrain = TileLayerTerrain(this, groupSize)

View File

@ -4,11 +4,11 @@ import com.badlogic.gdx.graphics.g2d.Batch
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame
import com.unciv.logic.civilization.Civilization
import com.unciv.ui.components.tilegroups.CityButton
import com.unciv.ui.components.tilegroups.TileGroup
import com.unciv.ui.components.tilegroups.WorldTileGroup
import com.unciv.utils.DebugUtils
class TileLayerCityButton(tileGroup: TileGroup, size: Float) : TileLayer(tileGroup, size) {
@ -60,7 +60,7 @@ class TileLayerCityButton(tileGroup: TileGroup, size: Float) : TileLayer(tileGro
return
val tileIsViewable = isViewable(viewingCiv)
val shouldShow = UncivGame.Current.viewEntireMapForDebug
val shouldShow = DebugUtils.VISIBLE_MAP
// Create (if not yet) and update city button
if (city != null && tileGroup.tile.isCityCenter()) {

View File

@ -1,5 +0,0 @@
package com.unciv.ui.crashhandling
interface CrashReportSysInfo {
fun getInfo(): String
}

View File

@ -19,6 +19,7 @@ import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.images.IconTextButton
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popups.ToastPopup
import com.unciv.utils.Log
import java.io.PrintWriter
import java.io.StringWriter
@ -88,7 +89,7 @@ class CrashScreen(val exception: Throwable): BaseScreen() {
--------------------------------
${UncivGame.Current.crashReportSysInfo?.getInfo().toString().prependIndentToOnlyNewLines(baseIndent)}
${Log.getSystemInfo().prependIndentToOnlyNewLines(baseIndent)}
--------------------------------

View File

@ -54,17 +54,18 @@ fun advancedTab(
addAutosaveTurnsSelectBox(this, settings)
if (UncivGame.Current.platformSpecificHelper?.hasDisplayCutout() == true)
optionsPopup.addCheckbox(this, "Enable display cutout (requires restart)", settings.androidCutout, false) { settings.androidCutout = it }
if (UncivGame.Current.hasDisplayCutout())
optionsPopup.addCheckbox(this, "Enable display cutout (requires restart)", settings.androidCutout) {
settings.androidCutout = it
}
addMaxZoomSlider(this, settings)
val helper = UncivGame.Current.platformSpecificHelper
if (helper != null && Gdx.app.type == Application.ApplicationType.Android) {
if (Gdx.app.type == Application.ApplicationType.Android) {
optionsPopup.addCheckbox(this, "Enable portrait orientation", settings.allowAndroidPortrait) {
settings.allowAndroidPortrait = it
// Note the following might close the options screen indirectly and delayed
helper.allowPortrait(it)
UncivGame.Current.allowPortrait(it)
}
}
@ -237,7 +238,7 @@ private fun addTranslationGeneration(table: Table, optionsPopup: OptionsPopup) {
Concurrency.run("GenerateScreenshot") {
val extraImagesLocation = "../../extraImages"
// I'm not sure why we need to advance the y by 2 for every screenshot... but that's the only way it remains centered
generateScreenshots(arrayListOf(
generateScreenshots(optionsPopup.settings, arrayListOf(
ScreenshotConfig(630, 500, ScreenSize.Medium, "$extraImagesLocation/itch.io image.png", Vector2(-2f, 2f),false),
ScreenshotConfig(1280, 640, ScreenSize.Medium, "$extraImagesLocation/GithubPreviewImage.png", Vector2(-2f, 4f)),
ScreenshotConfig(1024, 500, ScreenSize.Medium, "$extraImagesLocation/Feature graphic - Google Play.png",Vector2(-2f, 6f)),
@ -251,12 +252,12 @@ private fun addTranslationGeneration(table: Table, optionsPopup: OptionsPopup) {
data class ScreenshotConfig(val width: Int, val height: Int, val screenSize: ScreenSize, var fileLocation:String, var centerTile:Vector2, var attackCity:Boolean=true)
private fun CoroutineScope.generateScreenshots(configs:ArrayList<ScreenshotConfig>) {
private fun CoroutineScope.generateScreenshots(settings: GameSettings, configs:ArrayList<ScreenshotConfig>) {
val currentConfig = configs.first()
launchOnGLThread {
val screenshotGame =
UncivGame.Current.files.loadGameByName("ScreenshotGenerationGame")
UncivGame.Current.settings.screenSize = currentConfig.screenSize
settings.screenSize = currentConfig.screenSize
val newScreen = UncivGame.Current.loadGame(screenshotGame)
newScreen.stage.viewport.update(currentConfig.width, currentConfig.height, true)
@ -290,7 +291,7 @@ private fun CoroutineScope.generateScreenshots(configs:ArrayList<ScreenshotConfi
)
pixmap.dispose()
val newConfigs = configs.withoutItem(currentConfig)
if (newConfigs.isNotEmpty()) generateScreenshots(newConfigs)
if (newConfigs.isNotEmpty()) generateScreenshots(settings, newConfigs)
}
}
}

View File

@ -14,6 +14,7 @@ import com.unciv.ui.components.extensions.onClick
import com.unciv.ui.components.extensions.toCheckBox
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.utils.DebugUtils
fun debugTab() = Table(BaseScreen.skin).apply {
pad(10f)
@ -22,7 +23,7 @@ fun debugTab() = Table(BaseScreen.skin).apply {
if (GUI.isWorldLoaded()) {
val simulateButton = "Simulate until turn:".toTextButton()
val simulateTextField = UncivTextField.create("Turn", game.simulateUntilTurnForDebug.toString())
val simulateTextField = UncivTextField.create("Turn", DebugUtils.SIMULATE_UNTIL_TURN.toString())
val invalidInputLabel = "This is not a valid integer!".toLabel().also { it.isVisible = false }
simulateButton.onClick {
val simulateUntilTurns = simulateTextField.text.toIntOrNull()
@ -30,7 +31,7 @@ fun debugTab() = Table(BaseScreen.skin).apply {
invalidInputLabel.isVisible = true
return@onClick
}
game.simulateUntilTurnForDebug = simulateUntilTurns
DebugUtils.SIMULATE_UNTIL_TURN = simulateUntilTurns
invalidInputLabel.isVisible = false
GUI.getWorldScreen().nextTurn()
}
@ -39,11 +40,11 @@ fun debugTab() = Table(BaseScreen.skin).apply {
add(invalidInputLabel).colspan(2).row()
}
add("Supercharged".toCheckBox(game.superchargedForDebug) {
game.superchargedForDebug = it
add("Supercharged".toCheckBox(DebugUtils.SUPERCHARGED) {
DebugUtils.SUPERCHARGED = it
}).colspan(2).row()
add("View entire map".toCheckBox(game.viewEntireMapForDebug) {
game.viewEntireMapForDebug = it
add("View entire map".toCheckBox(DebugUtils.VISIBLE_MAP) {
DebugUtils.VISIBLE_MAP = it
}).colspan(2).row()
val curGameInfo = game.gameInfo
if (curGameInfo != null) {

View File

@ -44,6 +44,8 @@ class OptionsPopup(
private val selectPage: Int = defaultPage,
private val onClose: () -> Unit = {}
) : Popup(screen.stage, /** [TabbedPager] handles scrolling */ scrollable = false ) {
val game = screen.game
val settings = screen.game.settings
val tabs: TabbedPager
val selectBoxMinWidth: Float
@ -120,7 +122,7 @@ class OptionsPopup(
addCloseButton {
screen.game.musicController.onChange(null)
screen.game.platformSpecificHelper?.allowPortrait(settings.allowAndroidPortrait)
screen.game.allowPortrait(settings.allowAndroidPortrait)
onClose()
}.padBottom(10f)
@ -205,7 +207,7 @@ open class SettingsSelect<T : Any>(
) {
private val settingsProperty: KMutableProperty0<T> = setting.getProperty(settings)
private val label = createLabel(labelText)
protected val refreshSelectBox = createSelectBox(items.toGdxArray(), settings)
private val refreshSelectBox = createSelectBox(items.toGdxArray(), settings)
val items by refreshSelectBox::items
private fun createLabel(labelText: String): Label {

View File

@ -17,6 +17,7 @@ import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.civilopediascreen.CivilopediaCategories
import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen
import com.unciv.utils.DebugUtils
class WonderOverviewTab(
viewingPlayer: Civilization,
@ -115,7 +116,7 @@ class WonderInfo {
val city: City?,
val location: Tile?
) {
val viewEntireMapForDebug = UncivGame.Current.viewEntireMapForDebug
val viewEntireMapForDebug = DebugUtils.VISIBLE_MAP
fun getImage() = if (status == WonderStatus.Unknown && !viewEntireMapForDebug) null
else category.getImage?.invoke(name, if (category == CivilopediaCategories.Terrain) 50f else 45f)

View File

@ -159,22 +159,22 @@ class LoadGameScreen : LoadOrSaveScreen() {
}
private fun Table.addLoadFromCustomLocationButton() {
if (!game.files.canLoadFromCustomSaveLocation()) return
val loadFromCustomLocation = loadFromCustomLocation.toTextButton()
loadFromCustomLocation.onClick {
errorLabel.isVisible = false
loadFromCustomLocation.setText(Constants.loading.tr())
loadFromCustomLocation.disable()
Concurrency.run(Companion.loadFromCustomLocation) {
game.files.loadGameFromCustomLocation { result ->
if (result.isError()) {
handleLoadGameException(result.exception!!, "Could not load game from custom location!")
} else if (result.isSuccessful()) {
Concurrency.run {
game.loadGame(result.gameData!!, true)
}
game.files.loadGameFromCustomLocation(
{
Concurrency.run { game.loadGame(it, true) }
loadFromCustomLocation.enable()
},
{
handleLoadGameException(it, "Could not load game from custom location!")
loadFromCustomLocation.enable()
}
}
)
}
}
add(loadFromCustomLocation).row()

View File

@ -82,7 +82,6 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
}
private fun Table.addSaveToCustomLocation() {
if (!game.files.canLoadFromCustomSaveLocation()) return
val saveToCustomLocation = "Save to custom location".toTextButton()
val errorLabel = "".toLabel(Color.RED)
saveToCustomLocation.onClick {
@ -90,15 +89,18 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves")
saveToCustomLocation.setText("Saving...".tr())
saveToCustomLocation.disable()
Concurrency.runOnNonDaemonThreadPool("Save to custom location") {
game.files.saveGameToCustomLocation(gameInfo, gameNameTextField.text) { result ->
if (result.isError()) {
errorLabel.setText("Could not save game to custom location!".tr())
result.exception?.printStackTrace()
} else if (result.isSuccessful()) {
game.files.saveGameToCustomLocation(gameInfo, gameNameTextField.text,
{
game.popScreen()
saveToCustomLocation.enable()
},
{
errorLabel.setText("Could not save game to custom location!".tr())
it.printStackTrace()
saveToCustomLocation.enable()
}
saveToCustomLocation.enable()
}
)
}
}
add(saveToCustomLocation).row()

View File

@ -350,7 +350,7 @@ class WorldScreen(
debug("loadLatestMultiplayerState downloaded game: gameId: %s, turn: %s, curCiv: %s",
latestGame.gameId, latestGame.turns, latestGame.currentPlayer)
if (viewingCiv.civName == latestGame.currentPlayer || viewingCiv.civName == Constants.spectator) {
game.platformSpecificHelper?.notifyTurnStarted()
game.notifyTurnStarted()
}
launchOnGLThread {
loadingGamePopup.close()

View File

@ -31,6 +31,7 @@ import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.worldscreen.WorldScreen
import com.unciv.ui.screens.worldscreen.bottombar.BattleTableHelpers.flashWoundedCombatants
import com.unciv.ui.screens.worldscreen.bottombar.BattleTableHelpers.getHealthBar
import com.unciv.utils.DebugUtils
import kotlin.math.max
import kotlin.math.roundToInt
@ -102,15 +103,12 @@ class BattleTable(val worldScreen: WorldScreen): Table() {
if (defender == null || (!includeFriendly && defender.getCivInfo() == attackerCiv))
return null // no enemy combatant in tile
val canSeeDefender =
if (UncivGame.Current.viewEntireMapForDebug) true
else {
when {
defender.isInvisible(attackerCiv) -> attackerCiv.viewableInvisibleUnitsTiles.contains(selectedTile)
defender.isCity() -> attackerCiv.hasExplored(selectedTile)
else -> attackerCiv.viewableTiles.contains(selectedTile)
}
}
val canSeeDefender = when {
DebugUtils.VISIBLE_MAP -> true
defender.isInvisible(attackerCiv) -> attackerCiv.viewableInvisibleUnitsTiles.contains(selectedTile)
defender.isCity() -> attackerCiv.hasExplored(selectedTile)
else -> attackerCiv.viewableTiles.contains(selectedTile)
}
if (!canSeeDefender) return null

View File

@ -15,6 +15,7 @@ import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.components.extensions.addBorderAllowOpacity
import com.unciv.ui.components.extensions.darken
import com.unciv.ui.components.extensions.toLabel
import com.unciv.utils.DebugUtils
class TileInfoTable(private val viewingCiv :Civilization) : Table(BaseScreen.skin) {
init {
@ -27,12 +28,12 @@ class TileInfoTable(private val viewingCiv :Civilization) : Table(BaseScreen.ski
internal fun updateTileTable(tile: Tile?) {
clearChildren()
if (tile != null && (UncivGame.Current.viewEntireMapForDebug || viewingCiv.hasExplored(tile)) ) {
if (tile != null && (DebugUtils.VISIBLE_MAP || viewingCiv.hasExplored(tile)) ) {
add(getStatsTable(tile))
add(MarkupRenderer.render(TileDescription.toMarkup(tile, viewingCiv), padding = 0f, iconDisplay = IconDisplay.None) {
UncivGame.Current.pushScreen(CivilopediaScreen(viewingCiv.gameInfo.ruleset, link = it))
} ).pad(5f).row()
if (UncivGame.Current.viewEntireMapForDebug)
if (DebugUtils.VISIBLE_MAP)
add(tile.position.run { "(${x.toInt()},${y.toInt()})" }.toLabel()).colspan(2).pad(5f)
}

View File

@ -5,7 +5,6 @@ import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.Group
import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame
import com.unciv.logic.map.HexMath
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.map.tile.Tile
@ -13,6 +12,7 @@ import com.unciv.ui.images.IconCircleGroup
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.components.extensions.onClick
import com.unciv.ui.components.extensions.surroundWithCircle
import com.unciv.utils.DebugUtils
import kotlin.math.PI
import kotlin.math.atan
@ -36,7 +36,7 @@ internal class MinimapTile(val tile: Tile, tileSize: Float, val onClick: () -> U
}
fun updateColor(isTileUnrevealed: Boolean) {
image.isVisible = UncivGame.Current.viewEntireMapForDebug || !isTileUnrevealed
image.isVisible = DebugUtils.VISIBLE_MAP || !isTileUnrevealed
if (!image.isVisible) return
image.color = when {
tile.isCityCenter() && !tile.isWater -> tile.getOwner()!!.nation.getInnerColor()

View File

@ -0,0 +1,20 @@
package com.unciv.utils
object DebugUtils {
/**
* This exists so that when debugging we can see the entire map.
* Remember to turn this to false before commit and upload!
*/
var VISIBLE_MAP: Boolean = false
/** For when you need to test something in an advanced game and don't have time to faff around */
var SUPERCHARGED: Boolean = false
/** Simulate until this turn on the first "Next turn" button press.
* Does not update World View changes until finished.
* Set to 0 to disable.
*/
var SIMULATE_UNTIL_TURN: Int = 0
}

View File

@ -143,6 +143,13 @@ object Log {
fun error(tag: Tag, msg: String, throwable: Throwable) {
doLog(backend::error, tag, buildThrowableMessage(msg, throwable))
}
/**
* Get string information about operation system
*/
fun getSystemInfo(): String {
return backend.getSystemInfo()
}
}
class Tag(val name: String)
@ -153,6 +160,9 @@ interface LogBackend {
/** Do not log on release builds for performance reasons. */
fun isRelease(): Boolean
/** Get string information about operation system */
fun getSystemInfo(): String
}
/** Only for tests, or temporary main() functions */
@ -168,6 +178,10 @@ open class DefaultLogBackend : LogBackend {
override fun isRelease(): Boolean {
return false
}
override fun getSystemInfo(): String {
return ""
}
}
/** Shortcut for [Log.debug] */

View File

@ -0,0 +1,17 @@
package com.unciv.utils
interface PlatformSpecific {
/** Notifies player that his multiplayer turn started */
fun notifyTurnStarted() {}
/** Install system audio hooks */
fun installAudioHooks() {}
/** (Android) allow screen orientation switch */
fun allowPortrait(allow: Boolean) {}
/** (Android) returns whether display has cutout */
fun hasDisplayCutout(): Boolean { return false }
}

View File

@ -2,7 +2,6 @@ package com.unciv.app.desktop
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.UncivGameParameters
import com.unciv.utils.Log
import com.unciv.logic.GameStarter
import com.unciv.logic.civilization.PlayerType
@ -26,8 +25,7 @@ internal object ConsoleLauncher {
fun main(arg: Array<String>) {
Log.backend = DesktopLogBackend()
val consoleParameters = UncivGameParameters(consoleMode = true)
val game = UncivGame(consoleParameters)
val game = UncivGame(true)
UncivGame.Current = game
UncivGame.Current.settings = GameSettings().apply {

View File

@ -1,100 +0,0 @@
package com.unciv.app.desktop
import com.badlogic.gdx.files.FileHandle
import com.unciv.ui.crashhandling.CrashReportSysInfo
import java.nio.charset.Charset
class CrashReportSysInfoDesktop : CrashReportSysInfo {
override fun getInfo(): String {
val builder = StringBuilder()
// Operating system
val osName = System.getProperty("os.name") ?: "Unknown"
val isWindows = osName.startsWith("Windows", ignoreCase = true)
builder.append("OS: $osName")
if (!isWindows) {
val osInfo = listOfNotNull(System.getProperty("os.arch"), System.getProperty("os.version")).joinToString()
if (osInfo.isNotEmpty()) builder.append(" ($osInfo)")
}
builder.appendLine()
// Specific release info
val osRelease = if (isWindows) getWinVer() else getLinuxDistro()
if (osRelease.isNotEmpty())
builder.appendLine("\t$osRelease")
// Java runtime version
val javaVendor: String? = System.getProperty("java.vendor")
if (javaVendor != null) {
val javaVersion: String = System.getProperty("java.vendor.version") ?: System.getProperty("java.vm.version") ?: ""
builder.appendLine("Java: $javaVendor $javaVersion")
}
// Java VM memory limit as set by -Xmx
val maxMemory = try {
Runtime.getRuntime().maxMemory() / 1024 / 1024
} catch (ex: Throwable) { -1L }
if (maxMemory > 0) {
builder.append('\t')
builder.appendLine("Max Memory: $maxMemory MB")
}
return builder.toString()
}
companion object {
@Suppress("SpellCheckingInspection")
private val winVerCommand = """
cmd /c
reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v ProductName &&
reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v ReleaseId &&
reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v CurrentBuild &&
reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v DisplayVersion
""".trimIndent().replace('\n', ' ')
/** Kludge to get the important Windows version info (no easier way than the registry AFAIK)
* using a subprocess running reg query. Other methods would involve nasty reflection
* to break java.util.prefs.Preferences out of its Sandbox, or JNA requiring new bindings.
*/
fun getWinVer(): String {
val entries: Map<String,String> = try {
val process = Runtime.getRuntime().exec(winVerCommand)
process.waitFor()
val output = process.inputStream.readAllBytes().toString(Charset.defaultCharset())
val goodLines = output.split('\n').mapNotNull {
it.removeSuffix("\r").run {
if (startsWith(" ") || startsWith("\t")) trim() else null
}
}
goodLines.map { it.split("REG_SZ") }
.filter { it.size == 2 }
.associate { it[0].trim() to it[1].trim() }
} catch (ex: Throwable) { mapOf() }
if ("ProductName" !in entries) return ""
return entries["ProductName"]!! +
((entries["DisplayVersion"] ?: entries["ReleaseId"])?.run { " Version $this" } ?: "") +
(entries["CurrentBuild"]?.run { " (Build $this)" } ?: "")
}
/** Get linux Distribution out of the /etc/os-release file (ini-style)
* Should be safely silent on systems not supporting that file.
*/
fun getLinuxDistro(): String {
val osRelease: Map<String,String> = try {
FileHandle("/etc/os-release")
.readString()
.split('\n')
.map { it.split('=') }
.filter { it.size == 2 }
.associate { it[0] to it[1].removeSuffix("\"").removePrefix("\"") }
} catch (ex: Throwable) { mapOf() }
if ("NAME" !in osRelease) return ""
return osRelease["PRETTY_NAME"] ?: "${osRelease["NAME"]} ${osRelease["VERSION"]}"
}
}
}

View File

@ -10,7 +10,7 @@ import java.awt.image.BufferedImage
import java.util.*
class FontDesktop : FontImplementation {
class DesktopFont : FontImplementation {
private lateinit var font: Font
private lateinit var metric: FontMetrics

View File

@ -0,0 +1,52 @@
package com.unciv.app.desktop
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration
import com.unciv.UncivGame
class DesktopGame(config: Lwjgl3ApplicationConfiguration) : UncivGame() {
private val audio = HardenGdxAudio()
private var discordUpdater = DiscordUpdater()
private val turnNotifier = MultiplayerTurnNotifierDesktop()
init {
config.setWindowListener(turnNotifier)
discordUpdater.setOnUpdate {
if (!isInitialized)
return@setOnUpdate null
val info = DiscordGameInfo()
val game = gameInfo
if (game != null) {
info.gameTurn = game.turns
info.gameLeader = game.getCurrentPlayerCivilization().nation.leaderName
info.gameNation = game.getCurrentPlayerCivilization().nation.name
}
return@setOnUpdate info
}
discordUpdater.startUpdates()
}
override fun installAudioHooks() {
audio.installHooks(
musicController.getAudioLoopCallback(),
musicController.getAudioExceptionHandler()
)
}
override fun notifyTurnStarted() {
turnNotifier.turnStarted()
}
override fun dispose() {
discordUpdater.stopUpdates()
super.dispose()
}
}

View File

@ -1,33 +1,33 @@
package com.unciv.app.desktop
import club.minnced.discord.rpc.DiscordEventHandlers
import club.minnced.discord.rpc.DiscordRPC
import club.minnced.discord.rpc.DiscordRichPresence
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.glutils.HdpiMode
import com.sun.jna.Native
import com.unciv.UncivGame
import com.unciv.UncivGameParameters
import com.unciv.json.json
import com.unciv.logic.files.SETTINGS_FILE_NAME
import com.unciv.logic.files.UncivFiles
import com.unciv.models.metadata.ScreenSize
import com.unciv.models.metadata.WindowState
import com.unciv.ui.components.Fonts
import com.unciv.utils.Log
import com.unciv.utils.debug
import java.awt.GraphicsEnvironment
import java.util.*
import kotlin.concurrent.timer
internal object DesktopLauncher {
private var discordTimer: Timer? = null
@JvmStatic
fun main(arg: Array<String>) {
// Setup Desktop logging
Log.backend = DesktopLogBackend()
// Setup Desktop font
Fonts.fontImplementation = DesktopFont()
// Setup Desktop saver-loader
UncivFiles.saverLoader = DesktopSaverLoader()
UncivFiles.preferExternalStorage = false
// Solves a rendering problem in specific GPUs and drivers.
// For more info see https://github.com/yairm210/Unciv/pull/3202 and https://github.com/LWJGL/lwjgl/issues/119
System.setProperty("org.lwjgl.opengl.Display.allowSoftwareOpenGL", "true")
@ -69,60 +69,7 @@ internal object DesktopLauncher {
UiElementDocsWriter().write()
}
val platformSpecificHelper = PlatformSpecificHelpersDesktop(config)
val desktopParameters = UncivGameParameters(
cancelDiscordEvent = { discordTimer?.cancel() },
fontImplementation = FontDesktop(),
customFileLocationHelper = CustomFileLocationHelperDesktop(),
crashReportSysInfo = CrashReportSysInfoDesktop(),
platformSpecificHelper = platformSpecificHelper,
audioExceptionHelper = HardenGdxAudio()
)
val game = UncivGame(desktopParameters)
tryActivateDiscord(game)
val game = DesktopGame(config)
Lwjgl3Application(game, config)
}
private fun tryActivateDiscord(game: UncivGame) {
try {
/*
We try to load the Discord library manually before the instance initializes.
This is because if there's a crash when the instance initializes on a similar line,
it's not within the bounds of the try/catch and thus the app will crash.
*/
Native.load("discord-rpc", DiscordRPC::class.java)
val handlers = DiscordEventHandlers()
DiscordRPC.INSTANCE.Discord_Initialize("647066573147996161", handlers, true, null)
Runtime.getRuntime().addShutdownHook(Thread { DiscordRPC.INSTANCE.Discord_Shutdown() })
discordTimer = timer(name = "Discord", daemon = true, period = 1000) {
try {
updateRpc(game)
} catch (ex: Exception) {
debug("Exception while updating Discord Rich Presence", ex)
}
}
} catch (ex: Throwable) {
// This needs to be a Throwable because if we can't find the discord_rpc library, we'll get a UnsatisfiedLinkError, which is NOT an exception.
debug("Could not initialize Discord")
}
}
private fun updateRpc(game: UncivGame) {
if (!game.isInitialized) return
val presence = DiscordRichPresence()
presence.largeImageKey = "logo" // The actual image is uploaded to the discord app / applications webpage
val gameInfo = game.gameInfo
if (gameInfo != null) {
val currentPlayerCiv = gameInfo.getCurrentPlayerCivilization()
presence.details = "${currentPlayerCiv.nation.leaderName} of ${currentPlayerCiv.nation.name}"
presence.largeImageText = "Turn" + " " + currentPlayerCiv.gameInfo.turns
}
DiscordRPC.INSTANCE.Discord_UpdatePresence(presence)
}
}

View File

@ -7,10 +7,14 @@ class DesktopLogBackend : DefaultLogBackend() {
// -ea (enable assertions) or kotlin debugging property as marker for a debug run.
// Can easily be added to IntelliJ/Android Studio launch configuration template for all launches.
private val release = !ManagementFactory.getRuntimeMXBean().getInputArguments().contains("-ea")
private val release = !ManagementFactory.getRuntimeMXBean().inputArguments.contains("-ea")
&& System.getProperty("kotlinx.coroutines.debug") == null
override fun isRelease(): Boolean {
return release
}
override fun getSystemInfo(): String {
return SystemUtils.getSystemInfo()
}
}

View File

@ -1,7 +1,8 @@
package com.unciv.app.desktop
import com.badlogic.gdx.Gdx
import com.unciv.logic.files.CustomFileLocationHelper
import com.unciv.logic.files.PlatformSaverLoader
import com.unciv.utils.Log
import java.awt.Component
import java.awt.EventQueue
import java.awt.event.WindowEvent
@ -11,17 +12,45 @@ import java.io.OutputStream
import javax.swing.JFileChooser
import javax.swing.JFrame
class CustomFileLocationHelperDesktop : CustomFileLocationHelper() {
class DesktopSaverLoader : PlatformSaverLoader {
override fun saveGame(
data: String,
suggestedLocation: String,
onSaved: (location: String) -> Unit,
onError: (ex: Exception) -> Unit
) {
val onFileChosen = { stream: OutputStream, location: String ->
try {
stream.writer().use { it.write(data) }
onSaved(location)
} catch (ex: Exception) {
onError(ex)
}
}
pickFile(onFileChosen, onError, JFileChooser::showSaveDialog, File::outputStream, suggestedLocation)
override fun createOutputStream(suggestedLocation: String, callback: (String?, OutputStream?, Exception?) -> Unit) {
pickFile(callback, JFileChooser::showSaveDialog, File::outputStream, suggestedLocation)
}
override fun createInputStream(callback: (String?, InputStream?, Exception?) -> Unit) {
pickFile(callback, JFileChooser::showOpenDialog, File::inputStream)
override fun loadGame(
onLoaded: (data: String, location: String) -> Unit,
onError: (ex: Exception) -> Unit
) {
val onFileChosen = { stream: InputStream, location: String ->
try {
val data = stream.reader().use { it.readText() }
onLoaded(data, location)
} catch (ex: Exception) {
onError(ex)
}
}
pickFile(onFileChosen, onError, JFileChooser::showOpenDialog, File::inputStream)
}
private fun <T> pickFile(callback: (String?, T?, Exception?) -> Unit,
private fun <T> pickFile(onSuccess: (T, String) -> Unit,
onError: (Exception) -> Unit,
chooseAction: (JFileChooser, Component) -> Int,
createValue: (File) -> T,
suggestedLocation: String? = null) {
@ -47,13 +76,13 @@ class CustomFileLocationHelperDesktop : CustomFileLocationHelper() {
frame.dispose()
if (result == JFileChooser.CANCEL_OPTION) {
callback(null, null, null)
return@invokeLater
} else {
val value = createValue(fileChooser.selectedFile)
callback(fileChooser.selectedFile.absolutePath, value, null)
onSuccess(value, fileChooser.selectedFile.absolutePath)
}
} catch (ex: Exception) {
callback(null, null, ex)
onError(ex)
}
}
}

View File

@ -0,0 +1,76 @@
package com.unciv.app.desktop
import club.minnced.discord.rpc.DiscordEventHandlers
import club.minnced.discord.rpc.DiscordRPC
import club.minnced.discord.rpc.DiscordRichPresence
import com.sun.jna.Native
import com.unciv.utils.debug
import java.util.*
import kotlin.concurrent.timer
class DiscordGameInfo(
var gameNation: String = "",
var gameLeader: String = "",
var gameTurn: Int = 0
)
class DiscordUpdater {
private var onUpdate: (() -> DiscordGameInfo?)? = null
private var updateTimer: Timer? = null
fun setOnUpdate(callback: () -> DiscordGameInfo?) {
onUpdate = callback
}
fun startUpdates() {
try {
/*
We try to load the Discord library manually before the instance initializes.
This is because if there's a crash when the instance initializes on a similar line,
it's not within the bounds of the try/catch and thus the app will crash.
*/
Native.load("discord-rpc", DiscordRPC::class.java)
val handlers = DiscordEventHandlers()
DiscordRPC.INSTANCE.Discord_Initialize("647066573147996161", handlers, true, null)
Runtime.getRuntime().addShutdownHook(Thread { DiscordRPC.INSTANCE.Discord_Shutdown() })
updateTimer = timer(name = "Discord", daemon = true, period = 1000) {
try {
updateRpc()
} catch (ex: Exception) {
debug("Exception while updating Discord Rich Presence", ex)
}
}
} catch (ex: Throwable) {
// This needs to be a Throwable because if we can't find the discord_rpc library, we'll get a UnsatisfiedLinkError, which is NOT an exception.
debug("Could not initialize Discord")
}
}
fun stopUpdates() {
updateTimer?.cancel()
}
private fun updateRpc() {
if (onUpdate == null)
return
val info = onUpdate!!.invoke() ?: return
val presence = DiscordRichPresence()
presence.largeImageKey = "logo" // The actual image is uploaded to the discord app / applications webpage
if (info.gameLeader.isNotEmpty() && info.gameNation.isNotEmpty()) {
presence.details = "${info.gameLeader} of ${info.gameNation}"
presence.details = "Turn ${info.gameTurn}"
}
DiscordRPC.INSTANCE.Discord_UpdatePresence(presence)
}
}

View File

@ -1,18 +0,0 @@
package com.unciv.app.desktop
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration
import com.unciv.ui.components.GeneralPlatformSpecificHelpers
class PlatformSpecificHelpersDesktop(config: Lwjgl3ApplicationConfiguration) : GeneralPlatformSpecificHelpers {
val turnNotifier = MultiplayerTurnNotifierDesktop()
init {
config.setWindowListener(turnNotifier)
}
override fun notifyTurnStarted() {
turnNotifier.turnStarted()
}
/** On desktop, external is likely some document folder, while local is the game directory. We'd like to keep everything in the game directory */
override fun shouldPreferExternalStorage(): Boolean = false
}

View File

@ -0,0 +1,97 @@
package com.unciv.app.desktop
import com.badlogic.gdx.files.FileHandle
import java.nio.charset.Charset
object SystemUtils {
fun getSystemInfo(): String {
val builder = StringBuilder()
// Operating system
val osName = System.getProperty("os.name") ?: "Unknown"
val isWindows = osName.startsWith("Windows", ignoreCase = true)
builder.append("OS: $osName")
if (!isWindows) {
val osInfo = listOfNotNull(System.getProperty("os.arch"), System.getProperty("os.version")).joinToString()
if (osInfo.isNotEmpty()) builder.append(" ($osInfo)")
}
builder.appendLine()
// Specific release info
val osRelease = if (isWindows) getWinVer() else getLinuxDistro()
if (osRelease.isNotEmpty())
builder.appendLine("\t$osRelease")
// Java runtime version
val javaVendor: String? = System.getProperty("java.vendor")
if (javaVendor != null) {
val javaVersion: String = System.getProperty("java.vendor.version") ?: System.getProperty("java.vm.version") ?: ""
builder.appendLine("Java: $javaVendor $javaVersion")
}
// Java VM memory limit as set by -Xmx
val maxMemory = try {
Runtime.getRuntime().maxMemory() / 1024 / 1024
} catch (ex: Throwable) { -1L }
if (maxMemory > 0) {
builder.append('\t')
builder.appendLine("Max Memory: $maxMemory MB")
}
return builder.toString()
}
/** Kludge to get the important Windows version info (no easier way than the registry AFAIK)
* using a subprocess running reg query. Other methods would involve nasty reflection
* to break java.util.prefs.Preferences out of its Sandbox, or JNA requiring new bindings.
*/
private fun getWinVer(): String {
val winVerCommand = """
cmd /c
reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v ProductName &&
reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v ReleaseId &&
reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v CurrentBuild &&
reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /v DisplayVersion
""".trimIndent().replace('\n', ' ')
val entries: Map<String,String> = try {
val process = Runtime.getRuntime().exec(winVerCommand)
process.waitFor()
val output = process.inputStream.readAllBytes().toString(Charset.defaultCharset())
val goodLines = output.split('\n').mapNotNull {
it.removeSuffix("\r").run {
if (startsWith(" ") || startsWith("\t")) trim() else null
}
}
goodLines.map { it.split("REG_SZ") }
.filter { it.size == 2 }
.associate { it[0].trim() to it[1].trim() }
} catch (ex: Throwable) { mapOf() }
if ("ProductName" !in entries) return ""
return entries["ProductName"]!! +
((entries["DisplayVersion"] ?: entries["ReleaseId"])?.run { " Version $this" } ?: "") +
(entries["CurrentBuild"]?.run { " (Build $this)" } ?: "")
}
/** Get linux Distribution out of the /etc/os-release file (ini-style)
* Should be safely silent on systems not supporting that file.
*/
private fun getLinuxDistro(): String {
val osRelease: Map<String,String> = try {
FileHandle("/etc/os-release")
.readString()
.split('\n')
.map { it.split('=') }
.filter { it.size == 2 }
.associate { it[0] to it[1].removeSuffix("\"").removePrefix("\"") }
} catch (ex: Throwable) { mapOf() }
if ("NAME" !in osRelease) return ""
return osRelease["PRETTY_NAME"] ?: "${osRelease["NAME"]} ${osRelease["VERSION"]}"
}
}

View File

@ -11,7 +11,6 @@ import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.InputEvent
import com.badlogic.gdx.scenes.scene2d.InputListener
import com.unciv.UncivGame
import com.unciv.UncivGameParameters
import com.unciv.logic.files.UncivFiles
import com.unciv.logic.multiplayer.throttle
import com.unciv.ui.images.ImageGetter
@ -61,10 +60,11 @@ object FasterUIDevelopment {
}
class UIDevGame : Game() {
val game = UncivGame(UncivGameParameters(
fontImplementation = FontDesktop()
))
private val game = UncivGame()
override fun create() {
Fonts.fontImplementation = FontDesktop()
UncivGame.Current = game
UncivGame.Current.files = UncivFiles(Gdx.files)
game.settings = UncivGame.Current.files.getGeneralSettings()

View File

@ -17,6 +17,7 @@ import com.unciv.models.stats.Stat
import com.unciv.models.stats.Stats
import com.unciv.models.translations.getPlaceholderParameters
import com.unciv.models.translations.getPlaceholderText
import com.unciv.utils.DebugUtils
import com.unciv.utils.Log
import com.unciv.utils.debug
import org.junit.Assert
@ -53,10 +54,10 @@ class BasicTests {
fun gameIsNotRunWithDebugModes() {
val game = UncivGame()
Assert.assertTrue("This test will only pass if the game is not run with debug modes",
!game.superchargedForDebug
&& !game.viewEntireMapForDebug
&& game.simulateUntilTurnForDebug <= 0
&& !game.consoleMode
!DebugUtils.SUPERCHARGED
&& !DebugUtils.VISIBLE_MAP
&& DebugUtils.SIMULATE_UNTIL_TURN <= 0
&& !game.isConsoleMode
)
}