Moddable UI skins (#7804)

* 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
This commit is contained in:
Leonard Günther 2022-09-19 15:13:09 +02:00 committed by GitHub
parent a75a157dff
commit d05b3d376b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 210 additions and 5 deletions

View File

@ -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))

View File

@ -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<String, SkinConfig>() {
private data class SkinAndMod(val skin: String, val mod: String)
private val allConfigs = HashMap<SkinAndMod, SkinConfig>()
/** 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<String>) {
// 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<FileHandle> =
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
}
}

View File

@ -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<String, SkinElement> = 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()
}
}
}

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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()