From d05b3d376b8684a6c835caae94bcf330fce63546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20G=C3=BCnther?= Date: Mon, 19 Sep 2022 15:13:09 +0200 Subject: [PATCH] Moddable UI skins (#7804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added SkinStrings and SkinConfig TODO SkinCache * Deprecated all old ui background getters * Added SkinCache for SkinConfigs to take effect * Modable or moddable? idk ¯\_(ツ)_/¯ * Added separate alpha to config --- core/src/com/unciv/UncivGame.kt | 6 +- core/src/com/unciv/models/skins/SkinCache.kt | 90 +++++++++++++++++++ core/src/com/unciv/models/skins/SkinConfig.kt | 36 ++++++++ .../src/com/unciv/models/skins/SkinStrings.kt | 47 ++++++++++ core/src/com/unciv/ui/images/ImageGetter.kt | 23 ++++- core/src/com/unciv/ui/options/DisplayTab.kt | 2 + core/src/com/unciv/ui/options/OptionsPopup.kt | 6 +- core/src/com/unciv/ui/utils/BaseScreen.kt | 3 + .../com/unciv/app/desktop/ConsoleLauncher.kt | 2 + 9 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 core/src/com/unciv/models/skins/SkinCache.kt create mode 100644 core/src/com/unciv/models/skins/SkinConfig.kt create mode 100644 core/src/com/unciv/models/skins/SkinStrings.kt diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 394b302bb8..cac1151958 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -16,6 +16,7 @@ import com.unciv.logic.civilization.PlayerType import com.unciv.logic.multiplayer.OnlineMultiplayer import com.unciv.models.metadata.GameSettings import com.unciv.models.ruleset.RulesetCache +import com.unciv.models.skins.SkinCache import com.unciv.models.tilesets.TileSetCache import com.unciv.models.translations.Translations import com.unciv.ui.LanguagePickerScreen @@ -131,8 +132,6 @@ class UncivGame(parameters: UncivGameParameters) : Game() { settings.tileSet = Constants.defaultTileset } - BaseScreen.setSkin() // needs to come AFTER the Texture reset, since the buttons depend on it - Gdx.graphics.isContinuousRendering = settings.continuousRendering Concurrency.run("LoadJSON") { @@ -140,6 +139,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { translations.tryReadTranslationForCurrentLanguage() translations.loadPercentageCompleteOfLanguages() TileSetCache.loadTileSetConfigs() + SkinCache.loadSkinConfigs() if (settings.multiplayer.userId.isEmpty()) { // assign permanent user id settings.multiplayer.userId = UUID.randomUUID().toString() @@ -152,6 +152,8 @@ class UncivGame(parameters: UncivGameParameters) : Game() { // This stuff needs to run on the main thread because it needs the GL context launchOnGLThread { + BaseScreen.setSkin() // needs to come AFTER the Texture reset, since the buttons depend on it and after loadSkinConfigs to be able to use the SkinConfig + musicController.chooseTrack(suffixes = listOf(MusicMood.Menu, MusicMood.Ambient), flags = EnumSet.of(MusicTrackChooserFlags.SuffixMustMatch)) diff --git a/core/src/com/unciv/models/skins/SkinCache.kt b/core/src/com/unciv/models/skins/SkinCache.kt new file mode 100644 index 0000000000..d5ed2e32a1 --- /dev/null +++ b/core/src/com/unciv/models/skins/SkinCache.kt @@ -0,0 +1,90 @@ +package com.unciv.models.skins + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.files.FileHandle +import com.unciv.UncivGame +import com.unciv.json.fromJsonFile +import com.unciv.json.json +import com.unciv.models.ruleset.RulesetCache +import com.unciv.ui.images.ImageGetter +import com.unciv.utils.debug + +object SkinCache : HashMap() { + private data class SkinAndMod(val skin: String, val mod: String) + private val allConfigs = HashMap() + + /** Combine [SkinConfig]s for chosen mods. + * Vanilla always active, even with a base ruleset mod active. + * Permanent visual mods always included as long as UncivGame.Current is initialized. + * Other active mods can be passed in parameter [ruleSetMods], if that is `null` and a game is in + * progress, that game's mods are used instead. + */ + fun assembleSkinConfigs(ruleSetMods: Set) { + // Needs to be a list and not a set, so subsequent mods override the previous ones + // Otherwise you rely on hash randomness to determine override order... not good + val mods = mutableListOf("") + if (UncivGame.isCurrentInitialized()) { + mods.addAll(UncivGame.Current.settings.visualMods) + } + mods.addAll(ruleSetMods) + clear() + for (mod in mods.distinct()) { + for (entry in allConfigs.entries.filter { it.key.mod == mod } ) { // Built-in skins all have empty strings as their `.mod`, so loop through all of them. + val skin = entry.key.skin + if (skin in this) this[skin]!!.updateConfig(entry.value) + else this[skin] = entry.value.clone() + } + } + } + + fun loadSkinConfigs(consoleMode: Boolean = false){ + allConfigs.clear() + var skinName = "" + + //load internal Skins + val fileHandles: Sequence = + if (consoleMode) FileHandle("jsons/Skins").list().asSequence() + else ImageGetter.getAvailableSkins().map { Gdx.files.internal("jsons/Skins/$it.json")}.filter { it.exists() } + + for (configFile in fileHandles){ + skinName = configFile.nameWithoutExtension().removeSuffix("Config") + try { + val key = SkinAndMod(skinName, "") + assert(key !in allConfigs) + allConfigs[key] = json().fromJsonFile(SkinConfig::class.java, configFile) + debug("SkinConfig loaded successfully: %s", configFile.name()) + } catch (ex: Exception){ + debug("Exception loading SkinConfig '%s':", configFile.path()) + debug(" %s", ex.localizedMessage) + debug(" (Source file %s line %s)", ex.stackTrace[0].fileName, ex.stackTrace[0].lineNumber) + } + } + + //load mod Skins + val modsHandles = + if (consoleMode) FileHandle("mods").list().toList() + else RulesetCache.values.mapNotNull { it.folderLocation } + + for (modFolder in modsHandles) { + val modName = modFolder.name() + if (modName.startsWith('.')) continue + if (!modFolder.isDirectory) continue + + try { + for (configFile in modFolder.child("jsons/Skins").list()){ + skinName = configFile.nameWithoutExtension().removeSuffix("Config") + val key = SkinAndMod(skinName, modName) + assert(key !in allConfigs) + allConfigs[key] = json().fromJsonFile(SkinConfig::class.java, configFile) + debug("Skin loaded successfully: %s", configFile.path()) + } + } catch (ex: Exception){ + debug("Exception loading Skin '%s/jsons/Skins/%s':", modFolder.name(), skinName) + debug(" %s", ex.localizedMessage) + debug(" (Source file %s line %s)", ex.stackTrace[0].fileName, ex.stackTrace[0].lineNumber) + } + } + + assembleSkinConfigs(hashSetOf()) // no game is loaded, this is just the initial game setup + } +} diff --git a/core/src/com/unciv/models/skins/SkinConfig.kt b/core/src/com/unciv/models/skins/SkinConfig.kt new file mode 100644 index 0000000000..0db75dcc4f --- /dev/null +++ b/core/src/com/unciv/models/skins/SkinConfig.kt @@ -0,0 +1,36 @@ +package com.unciv.models.skins + +import com.badlogic.gdx.graphics.Color + +class SkinElement { + var image: String? = null + var tint: Color? = null + var alpha: Float? = null + + fun clone(): SkinElement { + val toReturn = SkinElement() + toReturn.image = image + toReturn.tint = tint?.cpy() + toReturn.alpha = alpha + return toReturn + } +} + +class SkinConfig { + var baseColor: Color = Color(0x004085bf) + var skinVariants: HashMap = HashMap() + + fun clone(): SkinConfig { + val toReturn = SkinConfig() + toReturn.baseColor = baseColor.cpy() + toReturn.skinVariants.putAll(skinVariants.map { Pair(it.key, it.value.clone()) }) + return toReturn + } + + fun updateConfig(other: SkinConfig) { + baseColor = other.baseColor.cpy() + for ((variantName, element) in other.skinVariants){ + skinVariants[variantName] = element.clone() + } + } +} diff --git a/core/src/com/unciv/models/skins/SkinStrings.kt b/core/src/com/unciv/models/skins/SkinStrings.kt new file mode 100644 index 0000000000..530e95043b --- /dev/null +++ b/core/src/com/unciv/models/skins/SkinStrings.kt @@ -0,0 +1,47 @@ +package com.unciv.models.skins + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.utils.NinePatchDrawable +import com.unciv.UncivGame +import com.unciv.ui.images.ImageGetter + +class SkinStrings(skin: String = UncivGame.Current.settings.skin) { + private val skinLocation = "Skins/$skin/" + val skinConfig = SkinCache[skin] ?: SkinConfig() + + val roundedEdgeRectangle = skinLocation + "roundedEdgeRectangle" + val rectangleWithOutline = skinLocation + "rectangleWithOutline" + val selectBox = skinLocation + "select-box" + val selectBoxPressed = skinLocation + "select-box-pressed" + val checkbox = skinLocation + "checkbox" + val checkboxPressed = skinLocation + "checkbox-pressed" + + /** + * Gets either a drawable which was defined inside skinConfig for the given path or the drawable + * found at path itself or the default drawable to be applied as the background for an UI element. + * + * @param path The path of the UI background in UpperCamelCase. Should be the location of the + * UI element inside the UI tree e.g. WorldScreen/TopBar/StatsTable. + * + * If the UI element is used in multiple Screens start the path with General + * e.g. General/Tooltip + * + * + * @param default The path to the background which should be used if path is not available. + * Should be one of the predefined ones inside SkinStrings or null to get a + * solid background. + */ + fun getUiBackground(path: String, default: String? = null, tintColor: Color? = null): NinePatchDrawable { + val locationByName = skinLocation + path + val locationByConfigVariant = skinLocation + skinConfig.skinVariants[path]?.image + val tint = (skinConfig.skinVariants[path]?.tint ?: tintColor)?.apply { + a = skinConfig.skinVariants[path]?.alpha ?: a + } + + return when { + ImageGetter.ninePatchImageExists(locationByConfigVariant) -> ImageGetter.getNinePatch(locationByConfigVariant, tint) + ImageGetter.ninePatchImageExists(locationByName) -> ImageGetter.getNinePatch(locationByName, tint) + else -> ImageGetter.getNinePatch(default, tint) + } + } +} diff --git a/core/src/com/unciv/ui/images/ImageGetter.kt b/core/src/com/unciv/ui/images/ImageGetter.kt index 7796f387d1..283bab7861 100644 --- a/core/src/com/unciv/ui/images/ImageGetter.kt +++ b/core/src/com/unciv/ui/images/ImageGetter.kt @@ -22,6 +22,7 @@ import com.unciv.json.json import com.unciv.models.ruleset.Nation import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.tile.ResourceType +import com.unciv.models.skins.SkinCache import com.unciv.models.stats.Stats import com.unciv.models.tilesets.TileSetCache import com.unciv.ui.utils.* @@ -76,6 +77,7 @@ object ImageGetter { } TileSetCache.assembleTileSetConfigs(ruleset.mods) + SkinCache.assembleSkinConfigs(ruleset.mods) } /** Loads all atlas/texture files from a folder, as controlled by an Atlases.json */ @@ -181,10 +183,19 @@ object ImageGetter { return textureRegionDrawables[fileName] ?: textureRegionDrawables[whiteDotLocation]!! } - fun getNinePatch(fileName: String?): NinePatchDrawable { - return ninePatchDrawables[fileName] ?: NinePatchDrawable(NinePatch(textureRegionDrawables[whiteDotLocation]!!.region)) + fun getNinePatch(fileName: String?, tintColor: Color? = null): NinePatchDrawable { + val drawable = ninePatchDrawables[fileName] ?: NinePatchDrawable(NinePatch(textureRegionDrawables[whiteDotLocation]!!.region)) + + if (fileName == null || ninePatchDrawables[fileName] == null) { + drawable.minHeight = 0f + drawable.minWidth = 0f + } + if (tintColor == null) + return drawable + return drawable.tint(tintColor) } + @Deprecated("Use SkinStrings.getUiBackground instead to make UI element moddable", ReplaceWith("BaseScreen.skinStrings.getUiBackground(path, BaseScreen.skinStrings.roundedEdgeRectangle, tintColor)", "com.unciv.ui.utils.BaseScreen")) fun getRoundedEdgeRectangle(tintColor: Color? = null): NinePatchDrawable { val drawable = getNinePatch("Skins/${UncivGame.Current.settings.skin}/roundedEdgeRectangle") @@ -192,22 +203,27 @@ object ImageGetter { return drawable.tint(tintColor) } + @Deprecated("Use SkinStrings.getUiBackground instead to make UI element moddable", ReplaceWith("BaseScreen.skinStrings.getUiBackground(path, BaseScreen.skinStrings.rectangleWithOutline)", "com.unciv.ui.utils.BaseScreen")) fun getRectangleWithOutline(): NinePatchDrawable { return getNinePatch("Skins/${UncivGame.Current.settings.skin}/rectangleWithOutline") } + @Deprecated("Use SkinStrings.getUiBackground instead to make UI element moddable", ReplaceWith("BaseScreen.skinStrings.getUiBackground(path, BaseScreen.skinStrings.selectBox)", "com.unciv.ui.utils.BaseScreen")) fun getSelectBox(): NinePatchDrawable { return getNinePatch("Skins/${UncivGame.Current.settings.skin}/select-box") } + @Deprecated("Use SkinStrings.getUiBackground instead to make UI element moddable", ReplaceWith("BaseScreen.skinStrings.getUiBackground(path, BaseScreen.skinStrings.selectBoxPressed)", "com.unciv.ui.utils.BaseScreen")) fun getSelectBoxPressed(): NinePatchDrawable { return getNinePatch("Skins/${UncivGame.Current.settings.skin}/select-box-pressed") } + @Deprecated("Use SkinStrings.getUiBackground instead to make UI element moddable", ReplaceWith("BaseScreen.skinStrings.getUiBackground(path, BaseScreen.skinStrings.checkbox)", "com.unciv.ui.utils.BaseScreen")) fun getCheckBox(): NinePatchDrawable { return getNinePatch("Skins/${UncivGame.Current.settings.skin}/checkbox") } + @Deprecated("Use SkinStrings.getUiBackground instead to make UI element moddable", ReplaceWith("BaseScreen.skinStrings.getUiBackground(path, BaseScreen.skinStrings.checkboxPressed)", "com.unciv.ui.utils.BaseScreen")) fun getCheckBoxPressed(): NinePatchDrawable { return getNinePatch("Skins/${UncivGame.Current.settings.skin}/checkbox-pressed") } @@ -215,6 +231,7 @@ object ImageGetter { fun imageExists(fileName: String) = textureRegionDrawables.containsKey(fileName) fun techIconExists(techName: String) = imageExists("TechIcons/$techName") fun unitIconExists(unitName: String) = imageExists("UnitIcons/$unitName") + fun ninePatchImageExists(fileName: String) = ninePatchDrawables.containsKey(fileName) fun getStatIcon(statName: String): Image { return getImage("StatIcons/$statName") @@ -323,11 +340,13 @@ object ImageGetter { return getReligionImage(iconName).surroundWithCircle(size, color = Color.BLACK ) } + @Deprecated("Use skin defined base color instead", ReplaceWith("BaseScreen.skinStrings.skinConfig.baseColor", "com.unciv.ui.utils.BaseScreen")) fun getBlue() = Color(0x004085bf) fun getCircle() = getImage("OtherIcons/Circle") fun getTriangle() = getImage("OtherIcons/Triangle") + @Deprecated("Use SkinStrings.getUiBackground instead to make UI element moddable", ReplaceWith("BaseScreen.skinStrings.getUiBackground(path, tintColor=color)", "com.unciv.ui.utils.BaseScreen")) fun getBackground(color: Color): Drawable { val drawable = getDrawable("") drawable.minHeight = 0f diff --git a/core/src/com/unciv/ui/options/DisplayTab.kt b/core/src/com/unciv/ui/options/DisplayTab.kt index 649c5fe1d4..4bdc9d1f97 100644 --- a/core/src/com/unciv/ui/options/DisplayTab.kt +++ b/core/src/com/unciv/ui/options/DisplayTab.kt @@ -7,6 +7,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.utils.Array import com.unciv.UncivGame import com.unciv.models.metadata.GameSettings +import com.unciv.models.skins.SkinCache import com.unciv.models.tilesets.TileSetCache import com.unciv.models.translations.tr import com.unciv.ui.images.ImageGetter @@ -162,6 +163,7 @@ private fun addSkinSelectBox(table: Table, settings: GameSettings, selectBoxMinW skinSelectBox.onChange { settings.skin = skinSelectBox.selected // ImageGetter ruleset should be correct no matter what screen we're on + SkinCache.assembleSkinConfigs(ImageGetter.ruleset.mods) onSkinChange() } } diff --git a/core/src/com/unciv/ui/options/OptionsPopup.kt b/core/src/com/unciv/ui/options/OptionsPopup.kt index 6ba7c01fe1..26b3b915e0 100644 --- a/core/src/com/unciv/ui/options/OptionsPopup.kt +++ b/core/src/com/unciv/ui/options/OptionsPopup.kt @@ -138,6 +138,11 @@ class OptionsPopup( private fun reloadWorldAndOptions() { Concurrency.run("Reload from options") { settings.save() + withGLContext { + // We have to run setSkin before the screen is rebuild else changing skins + // would only load the new SkinConfig after the next rebuild + BaseScreen.setSkin() + } val screen = UncivGame.Current.screen if (screen is WorldScreen) { UncivGame.Current.reloadWorldscreen() @@ -147,7 +152,6 @@ class OptionsPopup( } } withGLContext { - BaseScreen.setSkin() UncivGame.Current.screen?.openOptionsPopup(tabs.activePage) } } diff --git a/core/src/com/unciv/ui/utils/BaseScreen.kt b/core/src/com/unciv/ui/utils/BaseScreen.kt index 7c4794afb1..51aff77f29 100644 --- a/core/src/com/unciv/ui/utils/BaseScreen.kt +++ b/core/src/com/unciv/ui/utils/BaseScreen.kt @@ -17,6 +17,7 @@ import com.badlogic.gdx.scenes.scene2d.utils.Drawable import com.badlogic.gdx.utils.viewport.ExtendViewport import com.unciv.UncivGame import com.unciv.models.TutorialTrigger +import com.unciv.models.skins.SkinStrings import com.unciv.ui.UncivStage import com.unciv.ui.images.ImageGetter import com.unciv.ui.popup.activePopup @@ -110,8 +111,10 @@ abstract class BaseScreen : Screen { var enableSceneDebug = false lateinit var skin: Skin + lateinit var skinStrings: SkinStrings fun setSkin() { Fonts.resetFont() + skinStrings = SkinStrings() skin = Skin().apply { add("Nativefont", Fonts.font, BitmapFont::class.java) add("RoundedEdgeRectangle", ImageGetter.getRoundedEdgeRectangle(), Drawable::class.java) diff --git a/desktop/src/com/unciv/app/desktop/ConsoleLauncher.kt b/desktop/src/com/unciv/app/desktop/ConsoleLauncher.kt index 8677f6741c..5db13c818b 100644 --- a/desktop/src/com/unciv/app/desktop/ConsoleLauncher.kt +++ b/desktop/src/com/unciv/app/desktop/ConsoleLauncher.kt @@ -17,6 +17,7 @@ import com.unciv.models.simulation.Simulation import com.unciv.models.tilesets.TileSetCache import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.ruleset.Speed +import com.unciv.models.skins.SkinCache import kotlin.time.ExperimentalTime internal object ConsoleLauncher { @@ -36,6 +37,7 @@ internal object ConsoleLauncher { RulesetCache.loadRulesets(true) TileSetCache.loadTileSetConfigs(true) + SkinCache.loadSkinConfigs(true) val gameParameters = getGameParameters("China", "Greece") val mapParameters = getMapParameters()