diff --git a/android/assets/jsons/Tutorials.json b/android/assets/jsons/Tutorials.json index 103ab40ce1..918db91647 100644 --- a/android/assets/jsons/Tutorials.json +++ b/android/assets/jsons/Tutorials.json @@ -246,6 +246,7 @@ }, { "name": "World Screen", + "civilopediaText": [{"extraImage":"World_Screen"}], "steps": [ "", "This is where you spend most of your time playing Unciv. See the world, control your units, access other screens from here.", diff --git a/core/src/com/unciv/models/ruleset/Event.kt b/core/src/com/unciv/models/ruleset/Event.kt index ca1ebbb6de..c99cf82bdd 100644 --- a/core/src/com/unciv/models/ruleset/Event.kt +++ b/core/src/com/unciv/models/ruleset/Event.kt @@ -6,25 +6,41 @@ import com.unciv.models.ruleset.unique.StateForConditionals import com.unciv.models.ruleset.unique.Unique import com.unciv.models.ruleset.unique.UniqueTriggerActivation import com.unciv.models.stats.INamed +import com.unciv.ui.components.input.KeyCharAndCode +import com.unciv.ui.screens.civilopediascreen.FormattedLine +import com.unciv.ui.screens.civilopediascreen.ICivilopediaText - -class Event : INamed { +class Event : INamed, ICivilopediaText { override var name = "" var text = "" + override var civilopediaText = listOf() + override fun makeLink() = "Event/$name" + // todo: add unrepeatable events var choices = ArrayList() + + /** @return `null` when no choice passes the condition tests, so client code can easily bail using Elvis `?:`. */ + fun getMatchingChoices(stateForConditionals: StateForConditionals) = + choices.filter { it.matchesConditions(stateForConditionals) }.ifEmpty { null } } -class EventChoice { +class EventChoice : ICivilopediaText { var text = "" + override var civilopediaText = listOf() + override fun makeLink() = "" + + /** Keyboard support - not user-rebindable, mod control only. Will be [parsed][KeyCharAndCode.parse], so Gdx key names will work. */ + val keyShortcut = "" + var triggeredUniques = ArrayList() val triggeredUniqueObjects by lazy { triggeredUniques.map { Unique(it) } } var conditions = ArrayList() val conditionObjects by lazy { conditions.map { Unique(it) } } + fun matchesConditions(stateForConditionals: StateForConditionals) = conditionObjects.all { Conditionals.conditionalApplies(null, it, stateForConditionals) } diff --git a/core/src/com/unciv/models/ruleset/Tutorial.kt b/core/src/com/unciv/models/ruleset/Tutorial.kt index a51d91ede4..65df8971f3 100644 --- a/core/src/com/unciv/models/ruleset/Tutorial.kt +++ b/core/src/com/unciv/models/ruleset/Tutorial.kt @@ -30,9 +30,6 @@ class Tutorial : RulesetObject() { override fun getUniqueTarget() = UniqueTarget.Tutorial override fun makeLink() = "Tutorial/$name" - override fun getCivilopediaTextLines(ruleset: Ruleset): List { - val imageLine = FormattedLine(extraImage = name.replace(' ', '_')) - if (steps == null) return listOf(imageLine) - return (sequenceOf(imageLine) + steps.asSequence().map { FormattedLine(it) }).toList() - } + override fun getCivilopediaTextLines(ruleset: Ruleset) = + steps?.map { FormattedLine(it) }.orEmpty() } diff --git a/core/src/com/unciv/models/ruleset/unique/StateForConditionals.kt b/core/src/com/unciv/models/ruleset/unique/StateForConditionals.kt index 4213a108c0..91a11cd438 100644 --- a/core/src/com/unciv/models/ruleset/unique/StateForConditionals.kt +++ b/core/src/com/unciv/models/ruleset/unique/StateForConditionals.kt @@ -42,4 +42,27 @@ data class StateForConditionals( companion object { val IgnoreConditionals = StateForConditionals(ignoreConditionals = true) } + + /** Used ONLY for stateBasedRandom in [Conditionals.conditionalApplies] to prevent save scumming on [UniqueType.ConditionalChance] */ + override fun hashCode(): Int { + fun Civilization?.hash() = this?.civName?.hashCode() ?: 0 + fun City?.hash() = this?.id?.hashCode() ?: 0 + fun Tile?.hash() = this?.position?.hashCode() ?: 0 + fun MapUnit?.hash() = (this?.name?.hashCode() ?: 0) + 17 * this?.currentTile.hash() + fun ICombatant?.hash() = (this?.getName()?.hashCode() ?: 0) + 17 * this?.getTile().hash() + fun CombatAction?.hash() = this?.name?.hashCode() ?: 0 + fun Region?.hash() = this?.rect?.hashCode() ?: 0 + + var result = civInfo.hash() + result = 31 * result + city.hash() + result = 31 * result + unit.hash() + result = 31 * result + tile.hash() + result = 31 * result + ourCombatant.hash() + result = 31 * result + theirCombatant.hash() + result = 31 * result + attackedTile.hash() + result = 31 * result + combatAction.hash() + result = 31 * result + region.hash() + result = 31 * result + ignoreConditionals.hashCode() + return result + } } diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt b/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt index 3d0ccabc28..0b6ca018bd 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt @@ -105,8 +105,8 @@ object UniqueTriggerActivation { when (unique.type) { UniqueType.TriggerEvent -> { val event = ruleset.events[unique.params[0]] ?: return null - val choices = event.choices.filter { it.matchesConditions(stateForConditionals) } - if (choices.isEmpty()) return null + val choices = event.getMatchingChoices(stateForConditionals) + ?: return null return { if (civInfo.isAI()) choices.random().triggerChoice(civInfo) else civInfo.popupAlerts.add(PopupAlert(AlertType.Event, event.name)) @@ -301,7 +301,7 @@ object UniqueTriggerActivation { .mapNotNull { civInfo.gameInfo.ruleset.policies[it] } .filter { it.matchesFilter(policyFilter) } if (policiesToRemove.isEmpty()) return null - + return { for (policy in policiesToRemove){ civInfo.policies.removePolicy(policy) diff --git a/core/src/com/unciv/models/translations/TranslationFileWriter.kt b/core/src/com/unciv/models/translations/TranslationFileWriter.kt index 04406d41b6..a58d5fcdac 100644 --- a/core/src/com/unciv/models/translations/TranslationFileWriter.kt +++ b/core/src/com/unciv/models/translations/TranslationFileWriter.kt @@ -316,8 +316,7 @@ object TranslationFileWriter { val filename = jsonFile.nameWithoutExtension() val javaClass = getJavaClassByName(filename) - if (javaClass == String.javaClass) - continue // unknown JSON, let's skip it + ?: continue // unknown JSON, let's skip it val array = json().fromJsonFile(javaClass, jsonFile.path()) @@ -497,13 +496,14 @@ object TranslationFileWriter { (clazz.componentType?.simpleName ?: clazz.simpleName) + "." + field.name !in untranslatableFieldSet } - private fun getJavaClassByName(name: String): Class { + private fun getJavaClassByName(name: String): Class? { return when (name) { "Beliefs" -> emptyArray().javaClass "Buildings" -> emptyArray().javaClass + "CityStateTypes" -> emptyArray().javaClass "Difficulties" -> emptyArray().javaClass "Eras" -> emptyArray().javaClass - "Speeds" -> emptyArray().javaClass + "Events" -> emptyArray().javaClass "GlobalUniques" -> GlobalUniques().javaClass "Nations" -> emptyArray().javaClass "Policies" -> emptyArray().javaClass @@ -511,6 +511,7 @@ object TranslationFileWriter { "Religions" -> emptyArray().javaClass "Ruins" -> emptyArray().javaClass "Specialists" -> emptyArray().javaClass + "Speeds" -> emptyArray().javaClass "Techs" -> emptyArray().javaClass "Terrains" -> emptyArray().javaClass "TileImprovements" -> emptyArray().javaClass @@ -520,9 +521,7 @@ object TranslationFileWriter { "Units" -> emptyArray().javaClass "UnitTypes" -> emptyArray().javaClass "VictoryTypes" -> emptyArray().javaClass - "CityStateTypes" -> emptyArray().javaClass - "Events" -> emptyArray().javaClass - else -> String.javaClass // dummy value + else -> null // dummy value } } } diff --git a/core/src/com/unciv/ui/images/ImageGetter.kt b/core/src/com/unciv/ui/images/ImageGetter.kt index 3b96a50596..5f9b69058b 100644 --- a/core/src/com/unciv/ui/images/ImageGetter.kt +++ b/core/src/com/unciv/ui/images/ImageGetter.kt @@ -176,14 +176,33 @@ object ImageGetter { fun getWhiteDotDrawable() = textureRegionDrawables[whiteDotLocation]!! fun getDot(dotColor: Color) = getWhiteDot().apply { color = dotColor } - fun getExternalImage(fileName: String): Image { + /** Finds an image file under /ExtraImages/, including Mods (which can override builtin). + * Extension can be included or is guessed as png/jpg. + * @return `null` if no match found. + */ + fun findExternalImage(name: String): FileHandle? { + val folders = ruleset.mods.asSequence().map { Gdx.files.local("mods/$it/ExtraImages") } + + sequenceOf(Gdx.files.internal("ExtraImages")) + val extensions = sequenceOf("", ".png", ".jpg") + return folders.flatMap { folder -> + extensions.map { folder.child(name + it) } + }.firstOrNull { it.exists() } + } + + /** Loads an image on the fly - uncached Texture, not too fast. */ + fun getExternalImage(file: FileHandle): Image { // Since these are not packed in an atlas, they have no scaling filter metadata and // default to Nearest filter, anisotropic level 1. Use Linear instead, helps // loading screen and Tutorial.WorldScreen quite a bit. More anisotropy barely helps. - val texture = Texture("ExtraImages/$fileName") + val texture = Texture(file) texture.setFilter(TextureFilter.Linear, TextureFilter.Linear) return ImageWithCustomSize(TextureRegion(texture)) } + /** Loads an image from (assets)/ExtraImages, from the jar if Unciv runs packaged. + * Cannot load ExtraImages from a Mod - use [findExternalImage] and the [getExternalImage](FileHandle) overload instead. + */ + fun getExternalImage(fileName: String) = + getExternalImage(Gdx.files.internal("ExtraImages/$fileName")) fun getImage(fileName: String?): Image { return ImageWithCustomSize(getDrawable(fileName)) diff --git a/core/src/com/unciv/ui/screens/basescreen/TutorialController.kt b/core/src/com/unciv/ui/screens/basescreen/TutorialController.kt index 9fb529bcf1..c0de0b2052 100644 --- a/core/src/com/unciv/ui/screens/basescreen/TutorialController.kt +++ b/core/src/com/unciv/ui/screens/basescreen/TutorialController.kt @@ -105,8 +105,9 @@ class TutorialRender(private val screen: BaseScreen) { val tutorialPopup = Popup(screen) tutorialPopup.name = Constants.tutorialPopupNamePrefix + tutorialName - if (Gdx.files.internal("ExtraImages/$tutorialName").exists()) { - tutorialPopup.add(ImageGetter.getExternalImage(tutorialName)).row() + val externalImage = ImageGetter.findExternalImage(tutorialName) + if (externalImage != null) { + tutorialPopup.add(ImageGetter.getExternalImage(externalImage)).row() } tutorialPopup.addGoodSizedLabel(texts[0]).row() diff --git a/core/src/com/unciv/ui/screens/civilopediascreen/FormattedLine.kt b/core/src/com/unciv/ui/screens/civilopediascreen/FormattedLine.kt index 9eb6111058..586edff88e 100644 --- a/core/src/com/unciv/ui/screens/civilopediascreen/FormattedLine.kt +++ b/core/src/com/unciv/ui/screens/civilopediascreen/FormattedLine.kt @@ -12,6 +12,7 @@ import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable import com.badlogic.gdx.utils.Align import com.unciv.Constants import com.unciv.UncivGame +import com.unciv.logic.UncivShowableException import com.unciv.models.metadata.BaseRuleset import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache @@ -239,30 +240,7 @@ class FormattedLine ( * @param iconDisplay Flag to omit link or all images. */ fun render(labelWidth: Float, iconDisplay: IconDisplay = IconDisplay.All): Actor { - if (extraImage.isNotEmpty()) { - val table = Table(BaseScreen.skin) - try { - val image = when { - ImageGetter.imageExists(extraImage) -> - if (centered) ImageGetter.getDrawable(extraImage).cropToContent() - else ImageGetter.getImage(extraImage) - Gdx.files.internal("ExtraImages/$extraImage.png").exists() -> - ImageGetter.getExternalImage("$extraImage.png") - Gdx.files.internal("ExtraImages/$extraImage.jpg").exists() -> - ImageGetter.getExternalImage("$extraImage.jpg") - else -> return table - } - // limit larger cordinate to a given max size - val maxSize = if (imageSize.isNaN()) labelWidth else imageSize - val (width, height) = if (image.width > image.height) - maxSize to maxSize * image.height / image.width - else maxSize * image.width / image.height to maxSize - table.add(image).size(width, height) - } catch (exception: Exception) { - Log.error("Exception while rendering civilopedia text", exception) - } - return table - } + if (extraImage.isNotEmpty()) return renderExtraImage(labelWidth) val fontSize = when { header in 1 until headerSizes.size -> headerSizes[header] @@ -316,6 +294,30 @@ class FormattedLine ( return table } + private fun renderExtraImage(labelWidth: Float): Table { + val table = Table(BaseScreen.skin) + fun getExtraImage(): Image { + if (ImageGetter.imageExists(extraImage)) + return if (centered) ImageGetter.getDrawable(extraImage).cropToContent() + else ImageGetter.getImage(extraImage) + val externalImage = ImageGetter.findExternalImage(extraImage) + ?: throw UncivShowableException("Extra image '[$extraImage]' not found") // logged in catch below + return ImageGetter.getExternalImage(externalImage) + } + try { + val image = getExtraImage() + // limit larger cordinate to a given max size + val maxSize = if (imageSize.isNaN()) labelWidth else imageSize + val (width, height) = if (image.width > image.height) + maxSize to maxSize * image.height / image.width + else maxSize * image.width / image.height to maxSize + table.add(image).size(width, height) + } catch (exception: Exception) { + Log.error("Exception while rendering civilopedia text", exception) + } + return table + } + /** Place a RuleSet object icon. * @return 1 if successful for easy counting */ diff --git a/core/src/com/unciv/ui/screens/worldscreen/AlertPopup.kt b/core/src/com/unciv/ui/screens/worldscreen/AlertPopup.kt index 66bad710c9..33500bc5fd 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/AlertPopup.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/AlertPopup.kt @@ -18,21 +18,27 @@ import com.unciv.logic.civilization.PopupAlert import com.unciv.logic.civilization.diplomacy.DiplomacyFlags import com.unciv.logic.civilization.diplomacy.DiplomaticModifiers import com.unciv.logic.civilization.diplomacy.RelationshipLevel +import com.unciv.models.ruleset.EventChoice import com.unciv.models.ruleset.unique.StateForConditionals import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.translations.fillPlaceholders import com.unciv.models.translations.tr import com.unciv.ui.audio.MusicMood import com.unciv.ui.audio.MusicTrackChooserFlags +import com.unciv.ui.components.UncivTooltip.Companion.addTooltip import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.pad import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.components.input.KeyCharAndCode import com.unciv.ui.components.input.KeyboardBinding import com.unciv.ui.components.input.keyShortcuts import com.unciv.ui.components.input.onActivation import com.unciv.ui.images.ImageGetter import com.unciv.ui.popups.Popup +import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen +import com.unciv.ui.screens.civilopediascreen.FormattedLine +import com.unciv.ui.screens.civilopediascreen.MarkupRenderer import com.unciv.ui.screens.diplomacyscreen.LeaderIntroTable import com.unciv.ui.screens.victoryscreen.VictoryScreen import java.util.EnumSet @@ -503,17 +509,17 @@ class AlertPopup( private fun addEvent(): Boolean { val event = gameInfo.ruleset.events[popupAlert.value] ?: return false - val civ = gameInfo.currentPlayerCiv - val choices = event.choices.filter { it.matchesConditions(StateForConditionals(civ)) } - if (choices.isEmpty()) return false + val stateForConditionals = StateForConditionals(gameInfo.currentPlayerCiv) + val choices = event.getMatchingChoices(stateForConditionals) + ?: return false - addGoodSizedLabel(event.text) - for (choice in choices){ - addSeparator() - add(choice.text.toTextButton().onActivation { close(); choice.triggerChoice(civ) }).row() - for (triggeredUnique in choice.triggeredUniques) - addGoodSizedLabel(triggeredUnique).row() + if (event.text.isNotEmpty()) + addGoodSizedLabel(event.text) + if (event.civilopediaText.isNotEmpty()) { + add(event.renderCivilopediaText(stageWidth * 0.5f, ::openCivilopedia)).row() } + + for (choice in choices) addChoice(choice) return true } @@ -524,4 +530,32 @@ class AlertPopup( worldScreen.shouldUpdate = true super.close() } + + private fun addChoice(choice: EventChoice) { + addSeparator() + + val button = choice.text.toTextButton() + button.onActivation { + close() + choice.triggerChoice(gameInfo.currentPlayerCiv) + } + val key = KeyCharAndCode.parse(choice.keyShortcut) + if (key != KeyCharAndCode.UNKNOWN) { + button.keyShortcuts.add(key) + button.addTooltip(key) + } + add(button).row() + + val lines = ( + choice.civilopediaText.asSequence() + + choice.triggeredUniqueObjects.asSequence() + .filterNot { it.isHiddenToUsers() } + .map { FormattedLine(it) } + ).asIterable() + add(MarkupRenderer.render(lines, stageWidth * 0.5f, linkAction = ::openCivilopedia)).row() + } + + private fun openCivilopedia(link: String) { + worldScreen.game.pushScreen(CivilopediaScreen(gameInfo.ruleset, link = link)) + } } diff --git a/docs/Modders/Mod-file-structure/5-Miscellaneous-JSON-files.md b/docs/Modders/Mod-file-structure/5-Miscellaneous-JSON-files.md index 34cef3ca2b..869d1ef1b3 100644 --- a/docs/Modders/Mod-file-structure/5-Miscellaneous-JSON-files.md +++ b/docs/Modders/Mod-file-structure/5-Miscellaneous-JSON-files.md @@ -118,20 +118,26 @@ The code below is an example of a valid "turns" definition and it specifies that Events allow users to choose between options of triggers to activate. -| Attribute | Type | Default | Notes | -|-----------|----------------------|----------|-----------------------------------------------------------| -| name | String | Required | Used for triggering via "Triggers a [event] event" unique | -| text | String | None | Flavor text displayed to user | -| choices | List of EventChoices | | User can choose to trigger one of the viable choices | +| Attribute | Type | Default | Notes | +|-----------------|----------------------|----------|-------------------------------------------------------------------------------| +| name | String | Required | Used for triggering via "Triggers a [event] event" unique | +| text | String | None | Flavor text displayed to user | +| civilopediaText | List | Optional | See [civilopediaText chapter](5-Miscellaneous-JSON-files.md#civilopedia-text) | +| choices | List of EventChoices | | User can choose to trigger one of the viable choices | + +You can use text and/or civilopediaText, if both are present both are shown (but why would you?) Event choices are comprised of: -| Attribute | Type | Default | Notes | -|------------------|-----------------------------|------------|---------------------------------------------------------------| -| text | String | Required | Displayed to user. Should be an action name - "Do X" | -| triggeredUniques | List of trigger uniques | Required | The triggers that this choice activates upon being chosen | -| conditions | List of conditional uniques | Empty list | If any conditional is not met, this option becomes unpickable | +| Attribute | Type | Default | Notes | +|------------------|-----------------------------|------------|----------------------------------------------------------------------------------------------------------------------| +| text | String | Required | Displayed to user as button. Should be an action name - "Do X" | +| triggeredUniques | List of trigger uniques | Required | The triggers that this choice activates upon being chosen | +| conditions | List of conditional uniques | Empty list | If any conditional is not met, this option becomes unpickable (not shown) | +| keyShortcut | key to select (name) | none | Key names see [Gdx.Input.Keys](https://github.com/libgdx/libgdx/blob/master/gdx/src/com/badlogic/gdx/Input.java#L69) | +| civilopediaText | List | Optional | See [civilopediaText chapter](5-Miscellaneous-JSON-files.md#civilopedia-text) | +Here, civilopediaText is shown outside the active Button, before the triggeredUniques. ## ModOptions.json @@ -268,7 +274,6 @@ When extension rulesets define GlobalUniques, all uniques are merged. At the mom **Note a Base Ruleset mod can define a "welcome page" here by adding a "Tutorial" with a name equal to the name of the mod!** As an exception to the general rule, this file in a Base Ruleset mod will not _replace_ the default, but add to it like extension mods do. Also, place it under `/jsons/` normally even if the original is found one level above the vanilla jsons. -Also, place it under `/jsons/` normally even if the original is found one level above the vanilla jsons. Each tutorial has the following structure: @@ -279,6 +284,8 @@ Each tutorial has the following structure: | steps | List of Strings | Optional | Plain text | If an entry contains both `steps` and `civilopediaText` attributes, the `civilopediaText` is shown first. +Tutorials shown as Popup can show an show an external image (not part of the texture atlases) if there is an image unter ExtraImages (directly under assets or the Mod folder) having the same name. +This is searched for, meaning the mod defining the Tutorial is irrelevant, mods can override builtin ExtraImages, and case sensitivity depends on the OS. ## VictoryTypes.json @@ -362,6 +369,8 @@ The lines from json will 'surround' the automatically generated lines such that Note: `text` now also supports inline color markup. Insert `«color»` to start coloring text, `«»` to stop. `color` can be a name or 6/8-digit hex notation like `#ffa040` (different from the `color` attribute notation only by not allowing 3-digit codes, but allowing the alpha channel). Effectively, the `«»` markers are replaced with `[]` _after_ translation and then passed to [gdx markup language](https://libgdx.com/wiki/graphics/2d/fonts/color-markup-language). +Note: Using an ExtraImages folder in a mod was not working until version 4.11.5 + ## RGB colors list Certain objects can be specified to have its own unique color. The colors are defined by a list of 3× Integer in this order: red, green, blue. The range of color is from \[0, 0, 0\] (black) to \[255, 255, 255\] (white).