Prettier Events - that now respect 'hidden from users' (#11474)

* Expand Events to allow civilopediaText, fixing hidden uniques as side effect

* Fix ConditionalChance save scumming

* Fix ExtraImages not working within mods

* Missing documentation of the changes to Events

* Kdoc to clarify why helper returns nullable

* Lint getJavaClassByName: fix compiler warning and re-sort when

* FormattedLine now throws on problems with extraImage loading

* Fix Tutorial extraImage by removing auto and making it explicit

* Revert "FormattedLine now throws on problems with extraImage loading"

This reverts commit b5ab4084ee96133ea4c0c0d3d43ef4264c352057.

* Partially revert the revert, so bad extraImage fields are console-logged
This commit is contained in:
SomeTroglodyte 2024-04-21 14:30:41 +02:00 committed by GitHub
parent 371690a678
commit c75861af25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 167 additions and 66 deletions

View File

@ -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.",

View File

@ -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<FormattedLine>()
override fun makeLink() = "Event/$name"
// todo: add unrepeatable events
var choices = ArrayList<EventChoice>()
/** @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<FormattedLine>()
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<String>()
val triggeredUniqueObjects by lazy { triggeredUniques.map { Unique(it) } }
var conditions = ArrayList<String>()
val conditionObjects by lazy { conditions.map { Unique(it) } }
fun matchesConditions(stateForConditionals: StateForConditionals) =
conditionObjects.all { Conditionals.conditionalApplies(null, it, stateForConditionals) }

View File

@ -30,9 +30,6 @@ class Tutorial : RulesetObject() {
override fun getUniqueTarget() = UniqueTarget.Tutorial
override fun makeLink() = "Tutorial/$name"
override fun getCivilopediaTextLines(ruleset: Ruleset): List<FormattedLine> {
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()
}

View File

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

View File

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

View File

@ -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<Any> {
private fun getJavaClassByName(name: String): Class<Any>? {
return when (name) {
"Beliefs" -> emptyArray<Belief>().javaClass
"Buildings" -> emptyArray<Building>().javaClass
"CityStateTypes" -> emptyArray<CityStateType>().javaClass
"Difficulties" -> emptyArray<Difficulty>().javaClass
"Eras" -> emptyArray<Era>().javaClass
"Speeds" -> emptyArray<Speed>().javaClass
"Events" -> emptyArray<Event>().javaClass
"GlobalUniques" -> GlobalUniques().javaClass
"Nations" -> emptyArray<Nation>().javaClass
"Policies" -> emptyArray<PolicyBranch>().javaClass
@ -511,6 +511,7 @@ object TranslationFileWriter {
"Religions" -> emptyArray<String>().javaClass
"Ruins" -> emptyArray<RuinReward>().javaClass
"Specialists" -> emptyArray<Specialist>().javaClass
"Speeds" -> emptyArray<Speed>().javaClass
"Techs" -> emptyArray<TechColumn>().javaClass
"Terrains" -> emptyArray<Terrain>().javaClass
"TileImprovements" -> emptyArray<TileImprovement>().javaClass
@ -520,9 +521,7 @@ object TranslationFileWriter {
"Units" -> emptyArray<BaseUnit>().javaClass
"UnitTypes" -> emptyArray<UnitType>().javaClass
"VictoryTypes" -> emptyArray<Victory>().javaClass
"CityStateTypes" -> emptyArray<CityStateType>().javaClass
"Events" -> emptyArray<Event>().javaClass
else -> String.javaClass // dummy value
else -> null // dummy value
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 `<mod>/jsons/` normally even if the original is found one level above the vanilla jsons.
Also, place it under `<mod>/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).