mirror of
https://github.com/yairm210/Unciv.git
synced 2025-07-14 17:59:11 +07:00
Key bindings Step 3 - Better UI (#8891)
* Configurable key bindings - better Widget step 2 * Configurable key bindings - step 2 updated help
This commit is contained in:
@ -401,16 +401,15 @@
|
|||||||
{"text":"Currently, there are no checks to prevent conflicting assignments.","starred":true},
|
{"text":"Currently, there are no checks to prevent conflicting assignments.","starred":true},
|
||||||
{},
|
{},
|
||||||
{"text":"Using the Keys page","header":3},
|
{"text":"Using the Keys page","header":3},
|
||||||
{"text":"Each binding has a label, a text field, and a key button looking like this:"},
|
{"text":"Each binding has a label, a dropdown select, and a key button looking like this:"},
|
||||||
{"extraImage":"OtherIcons/Keyboard","imageSize":36},
|
{"extraImage":"OtherIcons/Keyboard","imageSize":36},
|
||||||
{"text":"While hovering the mouse over the key button, you can press a desired key directly to assign it."},
|
{"text":"While hovering the mouse over the key button, you can press a desired key directly to assign it."},
|
||||||
{"text":"Alternatively, you can enter the key's name in the text field."},
|
{"text":"Alternatively, you can select the key in the dropdown."},
|
||||||
{"text":"To reset a binding to its default, simply clear the text field."},
|
|
||||||
{},
|
{},
|
||||||
{"text":"Bindings mapped to their default keys are displayed in gray, those reassigned by you in white."},
|
{"text":"Bindings mapped to their default keys are displayed in gray, those reassigned by you in white."},
|
||||||
{},
|
{},
|
||||||
{"text":"For discussion about missing entries, see the linked github issue.","link":"https://github.com/yairm210/Unciv/issues/8862"}
|
{"text":"For discussion about missing entries, see the linked github issue.","link":"https://github.com/yairm210/Unciv/issues/8862"}
|
||||||
],
|
]
|
||||||
// "uniques": ["Will not be displayed in Civilopedia"] // would prevent use for help link
|
// "uniques": ["Will not be displayed in Civilopedia"] // would prevent use for help link
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
69
core/src/com/unciv/ui/components/KeyCapturingButton.kt
Normal file
69
core/src/com/unciv/ui/components/KeyCapturingButton.kt
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package com.unciv.ui.components
|
||||||
|
|
||||||
|
import com.badlogic.gdx.Input
|
||||||
|
import com.badlogic.gdx.graphics.Color
|
||||||
|
import com.badlogic.gdx.scenes.scene2d.Actor
|
||||||
|
import com.badlogic.gdx.scenes.scene2d.InputEvent
|
||||||
|
import com.badlogic.gdx.scenes.scene2d.ui.ImageButton
|
||||||
|
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener
|
||||||
|
import com.badlogic.gdx.utils.Align
|
||||||
|
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
|
||||||
|
import com.unciv.ui.images.ImageGetter
|
||||||
|
|
||||||
|
/** A button that captures keyboard keys and reports them through [onKeyHit] */
|
||||||
|
class KeyCapturingButton(
|
||||||
|
private val onKeyHit: (keyCode: Int, control: Boolean) -> Unit
|
||||||
|
) : ImageButton(getStyle()) {
|
||||||
|
companion object {
|
||||||
|
private const val buttonSize = 36f
|
||||||
|
private const val buttonImage = "OtherIcons/Keyboard"
|
||||||
|
private val controlKeys = setOf(Input.Keys.CONTROL_LEFT, Input.Keys.CONTROL_RIGHT)
|
||||||
|
|
||||||
|
private fun getStyle() = ImageButtonStyle().apply {
|
||||||
|
val image = ImageGetter.getDrawable(buttonImage)
|
||||||
|
imageUp = image
|
||||||
|
imageOver = image.tint(Color.LIME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var savedFocus: Actor? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
setSize(buttonSize, buttonSize)
|
||||||
|
addTooltip("Hit the desired key now", 18f, targetAlign = Align.bottomRight)
|
||||||
|
addListener(ButtonListener(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ButtonListener(private val myButton: KeyCapturingButton) : ClickListener() {
|
||||||
|
private var controlDown = false
|
||||||
|
|
||||||
|
override fun enter(event: InputEvent?, x: Float, y: Float, pointer: Int, fromActor: Actor?) {
|
||||||
|
if (myButton.stage == null) return
|
||||||
|
myButton.savedFocus = myButton.stage.keyboardFocus
|
||||||
|
myButton.stage.keyboardFocus = myButton
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exit(event: InputEvent?, x: Float, y: Float, pointer: Int, toActor: Actor?) {
|
||||||
|
if (myButton.stage == null) return
|
||||||
|
myButton.stage.keyboardFocus = myButton.savedFocus
|
||||||
|
myButton.savedFocus = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun keyDown(event: InputEvent?, keycode: Int): Boolean {
|
||||||
|
if (keycode == Input.Keys.ESCAPE) return false
|
||||||
|
if (keycode in controlKeys) {
|
||||||
|
controlDown = true
|
||||||
|
} else {
|
||||||
|
myButton.onKeyHit(keycode, controlDown)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun keyUp(event: InputEvent?, keycode: Int): Boolean {
|
||||||
|
if (keycode == Input.Keys.ESCAPE) return false
|
||||||
|
if (keycode in controlKeys)
|
||||||
|
controlDown = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
140
core/src/com/unciv/ui/components/KeysSelectBox.kt
Normal file
140
core/src/com/unciv/ui/components/KeysSelectBox.kt
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
package com.unciv.ui.components
|
||||||
|
|
||||||
|
import com.badlogic.gdx.Input
|
||||||
|
import com.badlogic.gdx.graphics.Color
|
||||||
|
import com.badlogic.gdx.scenes.scene2d.Actor
|
||||||
|
import com.badlogic.gdx.scenes.scene2d.ui.SelectBox
|
||||||
|
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener
|
||||||
|
import com.unciv.models.translations.tr
|
||||||
|
import com.unciv.ui.screens.basescreen.BaseScreen
|
||||||
|
import com.badlogic.gdx.utils.Array as GdxArray
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Widget to allow selecting keys from a [SelectBox].
|
||||||
|
*
|
||||||
|
* Added value:
|
||||||
|
* * Easier change callback as direct [changeCallback] parameter
|
||||||
|
* * Pulls existing keys from Gdx.[Input.Keys] with an exclusion parameter
|
||||||
|
* * Key names made translatable as e.g. `Key-"A"` or `Key-Space`
|
||||||
|
* * Supports TranslationFileWriter by providing those translation keys
|
||||||
|
*/
|
||||||
|
// Note this has similarities with TranslatedSelectBox, but having entries in the translation
|
||||||
|
// files that consist only of a punctuation character wouldn't be pretty
|
||||||
|
class KeysSelectBox(
|
||||||
|
private val default: String = "",
|
||||||
|
excludeKeys: Set<Int> = defaultExclusions,
|
||||||
|
private val changeCallback: (() -> Unit)? = null
|
||||||
|
) : SelectBox<KeysSelectBox.KeysSelectBoxEntry>(BaseScreen.skin) {
|
||||||
|
|
||||||
|
class KeysSelectBoxEntry(val name: String) {
|
||||||
|
val translation = run {
|
||||||
|
val translationKey = getTranslationKey(name)
|
||||||
|
val translation = translationKey.tr()
|
||||||
|
if (translation == translationKey) name else translation
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = translation
|
||||||
|
override fun equals(other: Any?) = other is KeysSelectBoxEntry && other.name == this.name
|
||||||
|
override fun hashCode() = name.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Gdx.Input.Keys has a keyNames map internally that would serve,
|
||||||
|
// but it's private and only used for valueOf() - So we do the same here...
|
||||||
|
private val keyCodeMap = LinkedHashMap<String, Int>(200).apply {
|
||||||
|
for (code in 0..Input.Keys.MAX_KEYCODE) {
|
||||||
|
val name = Input.Keys.toString(code) ?: continue
|
||||||
|
put(name, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val defaultExclusions = setOf(
|
||||||
|
Input.Keys.UNKNOWN, // Any key GLFW or Gdx fail to translate fire this - and we assign our own meaning
|
||||||
|
Input.Keys.ESCAPE, // Married to Android Back, we want to keep this closing the Options
|
||||||
|
Input.Keys.CONTROL_LEFT, // Captured to support control-letter combos
|
||||||
|
Input.Keys.CONTROL_RIGHT,
|
||||||
|
Input.Keys.PICTSYMBOLS, // Ugly toString and unknown who could have such a key
|
||||||
|
Input.Keys.SWITCH_CHARSET,
|
||||||
|
Input.Keys.SYM,
|
||||||
|
Input.Keys.PRINT_SCREEN, // Not captured by Gdx by default, these are handled by the OS
|
||||||
|
Input.Keys.VOLUME_UP,
|
||||||
|
Input.Keys.VOLUME_DOWN,
|
||||||
|
Input.Keys.MUTE,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getKeyNames(excludeKeys: Set<Int>): Sequence<String> {
|
||||||
|
val keyCodeNames = keyCodeMap.asSequence()
|
||||||
|
.filterNot { it.value in excludeKeys }
|
||||||
|
.map { it.key }
|
||||||
|
val controlNames = ('A'..'Z').asSequence()
|
||||||
|
.map { "Ctrl-$it" }
|
||||||
|
return (keyCodeNames + controlNames)
|
||||||
|
.sortedWith(
|
||||||
|
compareBy<String> {
|
||||||
|
when {
|
||||||
|
it.length == 1 -> 0 // Characters first
|
||||||
|
it.length in 2..3 && it[0] == 'F' && it[1].isDigit() ->
|
||||||
|
it.drop(1).toInt() // then F-Keys numerically
|
||||||
|
it.startsWith("Ctrl-") -> 100 // Then Ctrl-*
|
||||||
|
else -> 999 // Rest last
|
||||||
|
}
|
||||||
|
}.thenBy { it } // should be by translated, but - later
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTranslationKey(keyName: String): String {
|
||||||
|
val noSpaces = keyName.replace(' ','-')
|
||||||
|
if (noSpaces.last().isLetterOrDigit()) return "Key-$noSpaces"
|
||||||
|
return "Key-${noSpaces.dropLast(1)}\"${noSpaces.last()}\""
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused") // TranslationFileWriter integration will be done later
|
||||||
|
fun getAllTranslationKeys() = getKeyNames(defaultExclusions).map { getTranslationKey(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val normalStyle: SelectBoxStyle
|
||||||
|
private val defaultStyle: SelectBoxStyle
|
||||||
|
|
||||||
|
init {
|
||||||
|
//TODO:
|
||||||
|
// * Dropdown doesn't listen to mouse wheel until after some focus setting click
|
||||||
|
// (like any other SelectBox, but here it's more noticeable)
|
||||||
|
|
||||||
|
items = GdxArray<KeysSelectBoxEntry>(200).apply {
|
||||||
|
for (name in getKeyNames(excludeKeys))
|
||||||
|
add(KeysSelectBoxEntry(name))
|
||||||
|
}
|
||||||
|
setSelected(default)
|
||||||
|
|
||||||
|
maxListCount = 12 // or else the dropdown will fill as much vertical space as it can, including upwards
|
||||||
|
|
||||||
|
addListener(object : ChangeListener() {
|
||||||
|
override fun changed(event: ChangeEvent?, actor: Actor?) {
|
||||||
|
setColorForDefault()
|
||||||
|
changeCallback?.invoke()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// clone style to prevent bleeding our changes to other Widgets
|
||||||
|
normalStyle = SelectBoxStyle(style)
|
||||||
|
// Another instance of SelectBoxStyle for displaying whether the selection is the default one
|
||||||
|
defaultStyle = SelectBoxStyle(normalStyle)
|
||||||
|
defaultStyle.fontColor = Color.GRAY.cpy()
|
||||||
|
|
||||||
|
setColorForDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSelected(name: String) {
|
||||||
|
val newSelection = items.firstOrNull { it.name == name }
|
||||||
|
?: return
|
||||||
|
selected = newSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update fontColor to show whether the selection is the default one (as grayed) */
|
||||||
|
private fun setColorForDefault() {
|
||||||
|
// See Javadoc of underlying style - setStyle is needed to update visually
|
||||||
|
@Suppress("UsePropertyAccessSyntax")
|
||||||
|
setStyle(if (selected.name == default) defaultStyle else normalStyle)
|
||||||
|
}
|
||||||
|
}
|
@ -1,29 +1,21 @@
|
|||||||
package com.unciv.ui.popups.options
|
package com.unciv.ui.popups.options
|
||||||
|
|
||||||
import com.badlogic.gdx.Input
|
|
||||||
import com.badlogic.gdx.graphics.Color
|
|
||||||
import com.badlogic.gdx.scenes.scene2d.Actor
|
|
||||||
import com.badlogic.gdx.scenes.scene2d.InputEvent
|
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.ImageButton
|
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.TextField
|
|
||||||
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener
|
|
||||||
import com.badlogic.gdx.utils.Align
|
|
||||||
import com.unciv.GUI
|
import com.unciv.GUI
|
||||||
import com.unciv.models.ruleset.RulesetCache
|
import com.unciv.models.ruleset.RulesetCache
|
||||||
|
import com.unciv.ui.components.KeyCapturingButton
|
||||||
import com.unciv.ui.components.KeyCharAndCode
|
import com.unciv.ui.components.KeyCharAndCode
|
||||||
import com.unciv.ui.components.KeyboardBinding
|
import com.unciv.ui.components.KeyboardBinding
|
||||||
import com.unciv.ui.components.KeyboardBindings
|
import com.unciv.ui.components.KeyboardBindings
|
||||||
|
import com.unciv.ui.components.KeysSelectBox
|
||||||
import com.unciv.ui.components.TabbedPager
|
import com.unciv.ui.components.TabbedPager
|
||||||
import com.unciv.ui.components.UncivTextField
|
|
||||||
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
|
|
||||||
import com.unciv.ui.components.extensions.toLabel
|
import com.unciv.ui.components.extensions.toLabel
|
||||||
import com.unciv.ui.images.ImageGetter
|
|
||||||
import com.unciv.ui.screens.basescreen.BaseScreen
|
import com.unciv.ui.screens.basescreen.BaseScreen
|
||||||
import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen
|
import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen
|
||||||
import com.unciv.ui.screens.civilopediascreen.FormattedLine
|
import com.unciv.ui.screens.civilopediascreen.FormattedLine
|
||||||
import com.unciv.ui.screens.civilopediascreen.MarkupRenderer
|
import com.unciv.ui.screens.civilopediascreen.MarkupRenderer
|
||||||
|
|
||||||
|
|
||||||
class KeyBindingsTab(
|
class KeyBindingsTab(
|
||||||
optionsPopup: OptionsPopup,
|
optionsPopup: OptionsPopup,
|
||||||
labelWidth: Float
|
labelWidth: Float
|
||||||
@ -75,114 +67,56 @@ class KeyBindingsTab(
|
|||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A button that captures keyboard keys and reports them through [onKeyHit] */
|
|
||||||
class KeyCapturingButton(
|
|
||||||
private val onKeyHit: (keyCode: Int, control: Boolean) -> Unit
|
|
||||||
) : ImageButton(getStyle()) {
|
|
||||||
companion object {
|
|
||||||
private const val buttonSize = 36f
|
|
||||||
private const val buttonImage = "OtherIcons/Keyboard"
|
|
||||||
private val controlKeys = setOf(Input.Keys.CONTROL_LEFT, Input.Keys.CONTROL_RIGHT)
|
|
||||||
|
|
||||||
private fun getStyle() = ImageButtonStyle().apply {
|
|
||||||
val image = ImageGetter.getDrawable(buttonImage)
|
|
||||||
imageUp = image
|
|
||||||
imageOver = image.tint(Color.LIME)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var savedFocus: Actor? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
setSize(buttonSize, buttonSize)
|
|
||||||
addTooltip("Hit the desired key now", 18f, targetAlign = Align.bottomRight)
|
|
||||||
addListener(ButtonListener(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
class ButtonListener(private val myButton: KeyCapturingButton) : ClickListener() {
|
|
||||||
private var controlDown = false
|
|
||||||
|
|
||||||
override fun enter(event: InputEvent?, x: Float, y: Float, pointer: Int, fromActor: Actor?) {
|
|
||||||
if (myButton.stage == null) return
|
|
||||||
myButton.savedFocus = myButton.stage.keyboardFocus
|
|
||||||
myButton.stage.keyboardFocus = myButton
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun exit(event: InputEvent?, x: Float, y: Float, pointer: Int, toActor: Actor?) {
|
|
||||||
if (myButton.stage == null) return
|
|
||||||
myButton.stage.keyboardFocus = myButton.savedFocus
|
|
||||||
myButton.savedFocus = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun keyDown(event: InputEvent?, keycode: Int): Boolean {
|
|
||||||
if (keycode == Input.Keys.ESCAPE) return false
|
|
||||||
if (keycode in controlKeys) {
|
|
||||||
controlDown = true
|
|
||||||
} else {
|
|
||||||
myButton.onKeyHit(keycode, controlDown)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun keyUp(event: InputEvent?, keycode: Int): Boolean {
|
|
||||||
if (keycode == Input.Keys.ESCAPE) return false
|
|
||||||
if (keycode in controlKeys)
|
|
||||||
controlDown = false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class KeyboardBindingWidget(
|
class KeyboardBindingWidget(
|
||||||
/** The specific binding to edit */
|
/** The specific binding to edit */
|
||||||
private val binding: KeyboardBinding
|
private val binding: KeyboardBinding
|
||||||
) : Table(BaseScreen.skin) {
|
) : Table(BaseScreen.skin) {
|
||||||
private val textField: TextField =
|
private val selectBox = KeysSelectBox(binding.defaultKey.toString()) {
|
||||||
UncivTextField.create(binding.defaultKey.toString()) { focused ->
|
validateSelection()
|
||||||
if (!focused) validateText()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val button = KeyCapturingButton { code, control ->
|
private val button = KeyCapturingButton { code, control ->
|
||||||
|
selectBox.hideScrollPane()
|
||||||
boundKey = if (control)
|
boundKey = if (control)
|
||||||
KeyCharAndCode.ctrlFromCode(code)
|
KeyCharAndCode.ctrlFromCode(code)
|
||||||
else KeyCharAndCode(code)
|
else KeyCharAndCode(code)
|
||||||
resetText()
|
resetSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var boundKey: KeyCharAndCode? = null
|
private var boundKey: KeyCharAndCode = binding.defaultKey
|
||||||
|
|
||||||
init {
|
init {
|
||||||
pad(0f)
|
pad(0f)
|
||||||
defaults().pad(0f)
|
defaults().pad(0f)
|
||||||
textField.setScale(0.1f)
|
add(selectBox)
|
||||||
add(textField)
|
add(button).size(36f).padLeft(2f)
|
||||||
addActor(button)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the (untranslated) key name selected by the Widget */
|
||||||
|
// we let the KeysSelectBox handle undesired mappings
|
||||||
val text: String
|
val text: String
|
||||||
get() = textField.text
|
get() = selectBox.selected.name
|
||||||
|
|
||||||
|
/** Update control to show current binding */
|
||||||
fun update(keyBindings: KeyboardBindings) {
|
fun update(keyBindings: KeyboardBindings) {
|
||||||
boundKey = keyBindings[binding]
|
boundKey = keyBindings[binding]
|
||||||
resetText()
|
resetSelection()
|
||||||
|
|
||||||
// Since the TextField itself is temporary, this is only quick & dirty
|
|
||||||
button.setPosition(textField.width - (textField.height - button.height) / 2, textField.height / 2, Align.right)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateText() {
|
/** Set boundKey from selectBox */
|
||||||
|
private fun validateSelection() {
|
||||||
val value = text
|
val value = text
|
||||||
val parsedKey = KeyCharAndCode.parse(value)
|
val parsedKey = KeyCharAndCode.parse(value)
|
||||||
if (parsedKey == KeyCharAndCode.UNKNOWN) {
|
if (parsedKey == KeyCharAndCode.UNKNOWN) {
|
||||||
resetText()
|
resetSelection()
|
||||||
} else {
|
} else {
|
||||||
boundKey = parsedKey
|
boundKey = parsedKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resetText() {
|
/** Set selectBox from boundKey */
|
||||||
if (boundKey == binding.defaultKey) boundKey = null
|
private fun resetSelection() {
|
||||||
textField.text = boundKey?.toString() ?: ""
|
selectBox.setSelected(boundKey.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,6 @@ import com.unciv.ui.popups.Popup
|
|||||||
import com.unciv.ui.screens.basescreen.BaseScreen
|
import com.unciv.ui.screens.basescreen.BaseScreen
|
||||||
import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen
|
import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen
|
||||||
import com.unciv.ui.screens.worldscreen.WorldScreen
|
import com.unciv.ui.screens.worldscreen.WorldScreen
|
||||||
import com.unciv.utils.DebugUtils
|
|
||||||
import com.unciv.utils.concurrency.Concurrency
|
import com.unciv.utils.concurrency.Concurrency
|
||||||
import com.unciv.utils.concurrency.withGLContext
|
import com.unciv.utils.concurrency.withGLContext
|
||||||
import kotlin.reflect.KMutableProperty0
|
import kotlin.reflect.KMutableProperty0
|
||||||
|
Reference in New Issue
Block a user