Pedia Search (#9997)

* Minor Civilopedia linting

* Civilopedia Search Popup

* Add missing "entire current complex ruleset" scope

* Address comments

* Wording change

* Remove comment
This commit is contained in:
SomeTroglodyte 2023-09-03 08:32:28 +02:00 committed by GitHub
parent 23c8ba05de
commit bb3335aaa8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 271 additions and 65 deletions

View File

@ -1604,6 +1604,13 @@ Toggle UI (World Screen only) =
Overrides yields from underlying terrain =
No yields =
Mod: [modname] =
Search text: =
Invalid regular expression =
Mod filter: =
-Combined- =
Search! =
Results =
Nothing found! =
# Policies

View File

@ -7,7 +7,6 @@ import com.unciv.models.ruleset.unique.UniqueTarget
import com.unciv.models.translations.tr
import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen.Companion.showReligionInCivilopedia
import com.unciv.ui.screens.civilopediascreen.FormattedLine
import kotlin.collections.ArrayList
class Belief() : RulesetObject() {
var type: BeliefType = BeliefType.None
@ -23,7 +22,6 @@ class Belief() : RulesetObject() {
override fun makeLink() = "Belief/$name"
override fun getCivilopediaTextHeader() = FormattedLine(name, icon = makeLink(), header = 2, color = if (type == BeliefType.None) "#e34a2b" else "")
override fun getSortGroup(ruleset: Ruleset) = type.ordinal
override fun getIconName() = if (type == BeliefType.None) "Religion" else type.name
override fun getCivilopediaTextLines(ruleset: Ruleset): List<FormattedLine> {
return getCivilopediaTextLines(false)

View File

@ -56,7 +56,7 @@ class Speed : RulesetObject(), IsPartOfGameInfoSerialization {
override fun getUniqueTarget(): UniqueTarget = UniqueTarget.Speed
override fun makeLink(): String = "GameSpeed/$name"
override fun makeLink(): String = "Speed/$name"
override fun getCivilopediaTextHeader() = FormattedLine(name, header = 2)
override fun getCivilopediaTextLines(ruleset: Ruleset) = sequence {
yield(FormattedLine("General speed modifier: [${modifier * 100}]%${Fonts.turn}"))

View File

@ -92,7 +92,9 @@ class TutorialController(screen: BaseScreen) {
) : INamed, SimpleCivilopediaText(
sequenceOf(FormattedLine(extraImage = name.replace(' ', '_'))) + tutorial.civilopediaText.asSequence(),
tutorial.steps?.asSequence() ?: emptySequence()
)
) {
override fun makeLink() = "Tutorial/$name"
}
/** Get all Tutorials intended to be displayed in the Civilopedia
* as a List of wrappers supporting INamed and ICivilopediaText

View File

@ -10,13 +10,16 @@ import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.tile.Terrain
import com.unciv.models.ruleset.tile.TerrainType
import com.unciv.models.ruleset.unit.UnitMovementType
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.components.tilegroups.TileGroup
import com.unciv.ui.components.tilegroups.TileSetStrings
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.extensions.setSize
import com.unciv.ui.components.extensions.surroundWithCircle
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.tilegroups.TileGroup
import com.unciv.ui.components.tilegroups.TileSetStrings
import com.unciv.ui.images.IconCircleGroup
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.TutorialController
import com.unciv.models.ruleset.Belief as BaseBelief
import com.unciv.models.ruleset.unit.UnitType as BaseUnitType
/** Encapsulates the knowledge on how to get an icon for each of the Civilopedia categories */
@ -115,87 +118,105 @@ enum class CivilopediaCategories (
val hide: Boolean, // Omitted on CivilopediaScreen
val getImage: ((name: String, size: Float) -> Actor?)?,
val key: KeyCharAndCode = KeyCharAndCode.UNKNOWN,
val headerIcon: String
) {
val headerIcon: String,
val getCategoryIterator: (ruleset: Ruleset, tutorialController: TutorialController) -> Collection<ICivilopediaText>
) {
Building ("Buildings", false,
CivilopediaImageGetters.construction,
KeyCharAndCode('B'),
"OtherIcons/Cities"
"OtherIcons/Cities",
{ ruleset, _ -> ruleset.buildings.values.filter { !it.isAnyWonder() } }
),
Wonder ("Wonders", false,
CivilopediaImageGetters.construction,
KeyCharAndCode('W'),
"OtherIcons/Wonders"
"OtherIcons/Wonders",
{ ruleset, _ -> ruleset.buildings.values.filter { it.isAnyWonder() } }
),
Resource ("Resources", false,
CivilopediaImageGetters.resource,
KeyCharAndCode('R'),
"OtherIcons/Resources"
"OtherIcons/Resources",
{ ruleset, _ -> ruleset.tileResources.values }
),
Terrain ("Terrains", false,
CivilopediaImageGetters.terrain,
KeyCharAndCode('T'),
"OtherIcons/Terrains"
"OtherIcons/Terrains",
{ ruleset, _ -> ruleset.terrains.values }
),
Improvement ("Tile Improvements", false,
CivilopediaImageGetters.improvement,
KeyCharAndCode('T'),
"OtherIcons/Improvements"
"OtherIcons/Improvements",
{ ruleset, _ -> ruleset.tileImprovements.values }
),
Unit ("Units", false,
CivilopediaImageGetters.construction,
KeyCharAndCode('U'),
"OtherIcons/Shield"
"OtherIcons/Shield",
{ ruleset, _ -> ruleset.units.values }
),
UnitType ("Unit types", false,
CivilopediaImageGetters.unitType,
KeyCharAndCode('U'),
"UnitTypeIcons/UnitTypes"
"UnitTypeIcons/UnitTypes",
{ ruleset, _ -> BaseUnitType.getCivilopediaIterator(ruleset) }
),
Nation ("Nations", false,
CivilopediaImageGetters.nation,
KeyCharAndCode('N'),
"OtherIcons/Nations"
"OtherIcons/Nations",
{ ruleset, _ -> ruleset.nations.values.filter { !it.isSpectator } }
),
Technology ("Technologies", false,
CivilopediaImageGetters.technology,
KeyCharAndCode('T'),
"TechIcons/Philosophy"
"TechIcons/Philosophy",
{ ruleset, _ -> ruleset.technologies.values }
),
Promotion ("Promotions", false,
CivilopediaImageGetters.promotion,
KeyCharAndCode('P'),
"UnitPromotionIcons/Mobility"
"UnitPromotionIcons/Mobility",
{ ruleset, _ -> ruleset.unitPromotions.values }
),
Policy ("Policies", false,
CivilopediaImageGetters.policy,
KeyCharAndCode('P'),
"PolicyIcons/Constitution"
"PolicyIcons/Constitution",
{ ruleset, _ -> ruleset.policies.values }
),
Belief("Religions and Beliefs", false,
CivilopediaImageGetters.belief,
KeyCharAndCode('R'),
"ReligionIcons/Religion"
"ReligionIcons/Religion",
{ ruleset, _ -> (ruleset.beliefs.values.asSequence() +
BaseBelief.getCivilopediaReligionEntry(ruleset)).toList() }
),
Tutorial ("Tutorials", false,
getImage = null,
KeyCharAndCode(Input.Keys.F1),
"OtherIcons/ExclamationMark"
"OtherIcons/ExclamationMark",
{ _, tutorialController -> tutorialController.getCivilopediaTutorials() }
),
Difficulty ("Difficulty levels", false,
getImage = null,
KeyCharAndCode('D'),
"OtherIcons/Quickstart"
"OtherIcons/Quickstart",
{ ruleset, _ -> ruleset.difficulties.values }
),
Era ("Eras", false,
getImage = null,
KeyCharAndCode('D'),
"OtherIcons/Tyrannosaurus"
"OtherIcons/Tyrannosaurus",
{ ruleset, _ -> ruleset.eras.values }
),
Speed ("Speeds", false,
getImage = null,
KeyCharAndCode('S'),
"OtherIcons/Timer"
"OtherIcons/Timer",
{ ruleset, _ -> ruleset.speeds.values }
);
private fun getByOffset(offset: Int) = values()[(ordinal + count + offset) % count]

View File

@ -7,23 +7,23 @@ import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.ui.Button
import com.badlogic.gdx.scenes.scene2d.ui.SplitPane
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.models.ruleset.Belief
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.ruleset.unique.IHasUniques
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.ruleset.unit.UnitType
import com.unciv.models.stats.INamed
import com.unciv.models.translations.tr
import com.unciv.ui.components.Fonts
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.colorFromRGB
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.extensions.toImageButton
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.components.input.onClick
import com.unciv.ui.images.IconTextButton
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
@ -75,6 +75,11 @@ class CivilopediaScreen(
private var currentEntry: String = ""
private val currentEntryPerCategory = HashMap<CivilopediaCategories, String>()
private val searchPopup by lazy { CivilopediaSearchPopup(this, tutorialController) {
selectLink(it)
} }
/** Jump to a "link" selecting both category and entry
*
* Calls [selectCategory] with the substring before the first '/',
@ -160,7 +165,7 @@ class CivilopediaScreen(
* @param name Entry (Ruleset object) name
* @param noScrollAnimation Disable scroll animation
*/
fun selectEntry(name: String, noScrollAnimation: Boolean = false) {
private fun selectEntry(name: String, noScrollAnimation: Boolean = false) {
val entry = entryIndex[name] ?: return
// fails: entrySelectScroll.scrollTo(0f, entry.y, 0f, entry.h, false, true)
entrySelectScroll.scrollY = (entry.y + (entry.height - entrySelectScroll.height) / 2)
@ -195,7 +200,6 @@ class CivilopediaScreen(
init {
val imageSize = 50f
globalShortcuts.add(KeyCharAndCode.BACK) { game.popScreen() }
val religionEnabled = showReligionInCivilopedia(ruleset)
val victoryTypes = game.gameInfo?.gameParameters?.victoryTypes ?: ruleset.victories.keys
@ -209,32 +213,11 @@ class CivilopediaScreen(
}
}
fun getCategoryIterator(category: CivilopediaCategories): Collection<ICivilopediaText> =
when (category) {
CivilopediaCategories.Building -> ruleset.buildings.values.filter { !it.isAnyWonder() }
CivilopediaCategories.Wonder -> ruleset.buildings.values.filter { it.isAnyWonder() }
CivilopediaCategories.Resource -> ruleset.tileResources.values
CivilopediaCategories.Terrain -> ruleset.terrains.values
CivilopediaCategories.Improvement -> ruleset.tileImprovements.values
CivilopediaCategories.Unit -> ruleset.units.values
CivilopediaCategories.UnitType -> UnitType.getCivilopediaIterator(ruleset)
CivilopediaCategories.Nation -> ruleset.nations.values.filter { !it.isSpectator }
CivilopediaCategories.Technology -> ruleset.technologies.values
CivilopediaCategories.Promotion -> ruleset.unitPromotions.values
CivilopediaCategories.Policy -> ruleset.policies.values
CivilopediaCategories.Tutorial -> tutorialController.getCivilopediaTutorials()
CivilopediaCategories.Difficulty -> ruleset.difficulties.values
CivilopediaCategories.Belief -> (ruleset.beliefs.values.asSequence() +
Belief.getCivilopediaReligionEntry(ruleset)).toList()
CivilopediaCategories.Era -> ruleset.eras.values
CivilopediaCategories.Speed -> ruleset.speeds.values
}
for (loopCategory in CivilopediaCategories.values()) {
if (loopCategory.hide) continue
if (!religionEnabled && loopCategory == CivilopediaCategories.Belief) continue
categoryToEntries[loopCategory] =
getCategoryIterator(loopCategory)
loopCategory.getCategoryIterator(ruleset, tutorialController)
.filter { (it as? IHasUniques)?.let { obj -> shouldBeDisplayed(obj) } ?: true }
.map { CivilopediaEntry(
(it as INamed).name,
@ -254,7 +237,6 @@ class CivilopediaScreen(
val icon = if (categoryKey.headerIcon.isNotEmpty()) ImageGetter.getImage(categoryKey.headerIcon) else null
val button = IconTextButton(categoryKey.label, icon)
button.addTooltip(categoryKey.key)
// button.style = ImageButton.ImageButtonStyle(button.style)
button.onClick { selectCategory(categoryKey) }
val cell = buttonTable.add(button)
categoryToButtons[categoryKey] = CategoryButtonInfo(button, currentX, cell.prefWidth)
@ -265,14 +247,18 @@ class CivilopediaScreen(
buttonTableScroll = ScrollPane(buttonTable)
buttonTableScroll.setScrollingDisabled(false, true)
val goToGameButton = Constants.close.toTextButton()
goToGameButton.onClick {
game.popScreen()
}
val searchButton = "OtherIcons/Search".toImageButton(imageSize - 16f, imageSize, skinStrings.skinConfig.baseColor, Color.GOLD)
searchButton.onActivation { searchPopup.open(true) }
searchButton.keyShortcuts.add(KeyboardBinding.Civilopedia) // "hit twice to search"
val closeButton = "OtherIcons/Close".toImageButton(imageSize - 20f, imageSize, skinStrings.skinConfig.baseColor, Color.RED)
closeButton.onActivation { game.popScreen() }
closeButton.keyShortcuts.add(KeyCharAndCode.BACK)
val topTable = Table()
topTable.add(goToGameButton).pad(10f)
topTable.add(buttonTableScroll).growX()
topTable.add(searchButton).padLeft(10f)
topTable.add(closeButton).padLeft(10f).padRight(10f)
topTable.width = stage.width
topTable.layout()

View File

@ -0,0 +1,187 @@
package com.unciv.ui.screens.civilopediascreen
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.SelectBox
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.utils.Align
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.stats.INamed
import com.unciv.models.translations.tr
import com.unciv.ui.components.ExpanderTab
import com.unciv.ui.components.UncivTextField
import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.enable
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.onClick
import com.unciv.ui.popups.Popup
import com.unciv.ui.popups.ToastPopup
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.basescreen.TutorialController
import com.unciv.utils.Concurrency
import com.unciv.utils.launchOnGLThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import com.badlogic.gdx.utils.Array as GdxArray
class CivilopediaSearchPopup(
private val pediaScreen: CivilopediaScreen,
private val tutorialController: TutorialController,
private val linkAction: (String) -> Unit
) : Popup(pediaScreen) {
private var ruleset = pediaScreen.ruleset
private val searchText = UncivTextField.create("") // Always focused, "hint" never seen
private val modSelect = ModSelectBox()
private lateinit var resultExpander: ExpanderTab
private val resultCell: Cell<Actor?>
private val searchButton: TextButton
private var searchJob: Job? = null
private var checkLine: (String) -> Boolean = { _ -> false }
init {
searchText.maxLength = 100
add("Search text:".toLabel())
add(searchText).growX().row()
add("Mod filter:".toLabel())
add(modSelect).growX().row()
resultCell = add().colspan(2).growX()
row()
searchButton = addButton("Search!", KeyCharAndCode.RETURN) {
startSearch(searchText.text)
}.actor
addCloseButton()
showListeners.add {
keyboardFocus = searchText
searchText.selectAll()
}
closeListeners.add {
if (isSearchRunning()) searchJob!!.cancel()
}
}
private fun isSearchRunning() = searchJob?.isActive == true
private fun startSearch(text: String) {
searchButton.disable()
@Suppress("LiftReturnOrAssignment")
if (text.isEmpty()) {
checkLine = { true }
} else if (".*" in text || '\\' in text || '|' in text) {
try {
val regex = Regex(text, RegexOption.IGNORE_CASE)
checkLine = { regex.containsMatchIn(it) }
} catch (ex: Exception) {
ToastPopup("Invalid regular expression", pediaScreen, 4000).open(true)
searchButton.enable()
return
}
} else {
val words = text.split(' ').toSet()
checkLine = { line -> words.all { line.contains(it, ignoreCase = true) } }
}
ruleset = modSelect.selectedRuleset()
if (::resultExpander.isInitialized) {
resultExpander.innerTable.clear()
} else {
resultExpander = ExpanderTab("Results") {}
resultCell.setActor(resultExpander)
resultExpander.innerTable.defaults().growX().pad(2f)
}
searchJob = Concurrency.run("PediaSearch") {
searchLoop()
}
searchJob!!.invokeOnCompletion {
searchJob = null
Concurrency.runOnGLThread {
finishSearch()
}
}
}
private fun CoroutineScope.searchLoop() {
for (category in CivilopediaCategories.values()) {
if (!isActive) break
if (category.hide) continue
if (!ruleset.modOptions.isBaseRuleset && category == CivilopediaCategories.Tutorial)
continue // Search tutorials only when the mod filter is a base ruleset
for (entry in category.getCategoryIterator(ruleset, tutorialController)) {
if (!isActive) break
if (entry !is INamed) continue
if (!ruleset.modOptions.isBaseRuleset) {
val sort = entry.getSortGroup(ruleset)
if (category == CivilopediaCategories.UnitType && sort < 2)
continue // Search "Domain:" entries only when the mod filter is a base ruleset
if (category == CivilopediaCategories.Belief && sort == 0)
continue // Search "Religions" from `getCivilopediaReligionEntry` only when the mod filter is a base ruleset
}
searchEntry(entry)
}
}
}
private fun CoroutineScope.searchEntry(entry: ICivilopediaText) {
val scope = sequence {
entry.getCivilopediaTextHeader()?.let { yield(it) }
yieldAll(entry.civilopediaText)
yieldAll(entry.getCivilopediaTextLines(ruleset))
}
for (line in scope) {
if (!isActive) break
val lineText = line.text.tr(hideIcons = true)
if (!checkLine(lineText)) continue
addResult(entry)
break
}
}
private fun CoroutineScope.addResult(entry: ICivilopediaText) {
launchOnGLThread {
val actor = entry.getIconName().toLabel(alignment = Align.left)
val link = entry.makeLink()
resultExpander.innerTable.add(actor).row()
actor.onClick {
linkAction(link)
close()
}
}
}
private fun finishSearch() {
searchButton.enable()
if (!resultExpander.innerTable.cells.isEmpty) return
val nothingFound = FormattedLine("Nothing found!", color = "#f53", header = 3, centered = true)
.render(0f)
resultExpander.innerTable.add(nothingFound)
}
class ModSelectEntry(val key: String, val translate: Boolean = false) {
override fun toString() = if (translate) key.tr() else key
}
private inner class ModSelectBox : SelectBox<ModSelectEntry>(BaseScreen.skin) {
init {
val mods = pediaScreen.ruleset.mods
val entries = GdxArray<ModSelectEntry>(mods.size + 1)
entries.add(ModSelectEntry("-Combined-", true))
// This intersect is needed when pedia was called from the MainMenuScreen with an easter egg ruleset active -
// they are not in the cache and have their elements not marked with originRuleset anyway.
for (mod in mods.intersect(RulesetCache.keys)) entries.add(ModSelectEntry(mod))
items = entries
selectedIndex = 0
}
fun selectedRuleset(): Ruleset =
if (selectedIndex == 0) pediaScreen.ruleset
else RulesetCache[selected.key]!! // `!!` guarded by the intersect above
}
}

View File

@ -15,7 +15,6 @@ import com.unciv.ui.components.ColorMarkupLabel
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.components.extensions.toLabel
import com.unciv.utils.Log
import kotlin.math.max

View File

@ -4,9 +4,9 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.UncivGame
import com.unciv.models.ruleset.IRulesetObject
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetObject
import com.unciv.models.stats.INamed
/** Addon common to most ruleset game objects managing civilopedia display
*
* ### Usage:
@ -91,7 +91,13 @@ interface ICivilopediaText {
return SimpleCivilopediaText(newLines.toList())
}
/** Create the correct string for a Civilopedia link */
/** Create the correct string for a Civilopedia link.
*
* To actually make it work both as link and as icon identifier, return a string in the form
* category/entryname where `category` **must** correspond exactly to either name or label of
* the correct [CivilopediaCategories] member. `entryname` must equal the
* [ruleset object name][RulesetObject] as defined by the [INamed] interface.
*/
fun makeLink(): String
/** Overrides alphabetical sorting in Civilopedia