Spruced up Civilopedia - phase 4 - Visual candy, Units (#4350)

* Spruced up Civilopedia - phase 4 - Visual candy, Units

* Unified separators, CheckBox helper - patch2

* Unified separators, CheckBox helper - atlas merge

* Spruced up Civilopedia - phase 4 - rebuild atlas

* Spruced up Civilopedia - phase 4 - rebuild atlas

* Spruced up Civilopedia - phase 4 - do separator to-do
This commit is contained in:
SomeTroglodyte
2021-07-05 15:35:41 +02:00
committed by GitHub
parent c2a43ffee0
commit c42561c545
11 changed files with 943 additions and 706 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -37,7 +37,10 @@
"cost": 40,
"obsoleteTech": "Metal Casting",
"upgradesTo": "Swordsman",
"attackSound": "nonmetalhit"
"attackSound": "nonmetalhit",
"civilopediaText": [
{"text": "This is your basic, club-swinging fighter."}
]
},
{
"name": "Maori Warrior",

View File

@ -174,7 +174,7 @@ class Nation : INamed {
textList += unit.name.tr() + " - " + "Replaces [${unit.replaces}], which is not found in the ruleset!".tr()
} else {
textList += unit.name.tr()
textList += " " + unit.getDescription(true).split("\n").joinToString("\n ")
textList += " " + unit.getDescription().split("\n").joinToString("\n ")
}
textList += ""

View File

@ -7,8 +7,10 @@ import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.MapUnit
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.Unique
import com.unciv.models.stats.INamed
import com.unciv.models.translations.tr
import com.unciv.models.stats.INamed
import com.unciv.ui.civilopedia.CivilopediaText
import com.unciv.ui.civilopedia.FormattedLine
import com.unciv.ui.utils.Fonts
import kotlin.math.pow
@ -16,7 +18,7 @@ import kotlin.math.pow
/** This is the basic info of the units, as specified in Units.json,
in contrast to MapUnit, which is a specific unit of a certain type that appears on the map */
class BaseUnit : INamed, IConstruction {
class BaseUnit : INamed, IConstruction, CivilopediaText() {
override lateinit var name: String
var cost: Int = 0
@ -52,36 +54,115 @@ class BaseUnit : INamed, IConstruction {
return infoList.joinToString()
}
fun getDescription(forPickerScreen: Boolean): String {
val sb = StringBuilder()
/** Generate description as multi-line string for Nation description addUniqueUnitsText and CityScreen addSelectedConstructionTable */
fun getDescription(): String {
val lines = mutableListOf<String>()
for ((resource, amount) in getResourceRequirements()) {
if (amount == 1) sb.appendLine("Consumes 1 [$resource]".tr())
else sb.appendLine("Consumes [$amount]] [$resource]".tr())
}
if (!forPickerScreen) {
if (uniqueTo != null) sb.appendLine("Unique to [$uniqueTo], replaces [$replaces]".tr())
else sb.appendLine("{Cost}: $cost".tr())
if (requiredTech != null) sb.appendLine("Required tech: [$requiredTech]".tr())
if (upgradesTo != null) sb.appendLine("Upgrades to [$upgradesTo]".tr())
if (obsoleteTech != null) sb.appendLine("Obsolete with [$obsoleteTech]".tr())
lines += if (amount == 1) "Consumes 1 [$resource]".tr()
else "Consumes [$amount] [$resource]".tr()
}
var strengthLine = ""
if (strength != 0) {
sb.append("$strength${Fonts.strength}, ")
if (rangedStrength != 0) sb.append("$rangedStrength${Fonts.rangedStrength}, ")
if (rangedStrength != 0) sb.append("$range${Fonts.range}, ")
strengthLine += "$strength${Fonts.strength}, "
if (rangedStrength != 0)
strengthLine += "$rangedStrength${Fonts.rangedStrength}, $range${Fonts.range}, "
}
sb.appendLine("$movement${Fonts.movement}")
lines += "$strengthLine$movement${Fonts.movement}"
if (replacementTextForUniques != "") sb.appendLine(replacementTextForUniques)
if (replacementTextForUniques != "") lines += replacementTextForUniques
else for (unique in uniques)
sb.appendLine(unique.tr())
lines += unique.tr()
if (promotions.isNotEmpty()) {
sb.append((if (promotions.size == 1) "Free promotion:" else "Free promotions:").tr())
sb.appendLine(promotions.joinToString(", ", " ") { it.tr() })
val prefix = "Free promotion${if (promotions.size == 1) "" else "s"}:".tr() + " "
lines += promotions.joinToString(", ", prefix) { it.tr() }
}
return sb.toString().trim()
return lines.joinToString("\n")
}
override fun getCivilopediaTextHeader() = FormattedLine(name, icon="Unit/$name", header=2)
override fun replacesCivilopediaDescription() = true
override fun hasCivilopediaTextLines() = true
override fun getCivilopediaTextLines(ruleset: Ruleset): List<FormattedLine> {
val textList = ArrayList<FormattedLine>()
val stats = ArrayList<String>()
if (strength != 0) stats += "$strength${Fonts.strength}"
if (rangedStrength != 0) {
stats += "$rangedStrength${Fonts.rangedStrength}"
stats += "$range${Fonts.range}"
}
if (movement != 0) stats += "$movement${Fonts.movement}"
if (cost != 0) stats += "{Cost}: $cost"
if (stats.isNotEmpty())
textList += FormattedLine(stats.joinToString(", "))
if (replacementTextForUniques != "") {
textList += FormattedLine()
textList += FormattedLine(replacementTextForUniques)
} else if (uniques.isNotEmpty()) {
textList += FormattedLine()
for (uniqueObject in uniqueObjects.sortedBy { it.text }) {
if (uniqueObject.placeholderText == "Can construct []") {
val improvement = uniqueObject.params[0]
textList += FormattedLine(uniqueObject.text, link="Improvement/$improvement")
} else {
textList += FormattedLine(uniqueObject.text)
}
}
}
val resourceRequirements = getResourceRequirements()
if (resourceRequirements.isNotEmpty()) {
textList += FormattedLine()
for ((resource, amount) in resourceRequirements) {
textList += FormattedLine(
if (amount == 1) "Consumes 1 [$resource]" else "Consumes [$amount] [$resource]",
link="Resource/$resource", color="#F42")
}
}
if (uniqueTo != null) {
textList += FormattedLine()
textList += FormattedLine("Unique to [$uniqueTo],", link="Nation/$uniqueTo")
if (replaces != null)
textList += FormattedLine("replaces [$replaces]", link="Unit/$replaces", indent=1)
}
if (requiredTech != null || upgradesTo != null || obsoleteTech != null) textList += FormattedLine()
if (requiredTech != null) textList += FormattedLine("Required tech: [$requiredTech]", link="Technology/$requiredTech")
if (upgradesTo != null) textList += FormattedLine("Upgrades to [$upgradesTo]", link="Unit/$upgradesTo")
if (obsoleteTech != null) textList += FormattedLine("Obsolete with [$obsoleteTech]", link="Technology/$obsoleteTech")
if (promotions.isNotEmpty()) {
textList += FormattedLine()
promotions.withIndex().forEach {
textList += FormattedLine(
when {
promotions.size == 1 -> "{Free promotion:} "
it.index == 0 -> "{Free promotions:} "
else -> ""
} + "{${it.value}}" +
(if (promotions.size == 1 || it.index == promotions.size - 1) "" else ","),
link="Promotions/${it.value}",
indent=if(it.index==0) 0 else 1)
}
}
val seeAlso = ArrayList<FormattedLine>()
for ((other, unit) in ruleset.units) {
if (unit.replaces == name || uniques.contains("[$name]") ) {
seeAlso += FormattedLine(other, link="Unit/$other", indent=1)
}
}
if (seeAlso.isNotEmpty()) {
textList += FormattedLine()
textList += FormattedLine("{See also}:")
textList += seeAlso
}
return textList
}
fun getMapUnit(ruleset: Ruleset): MapUnit {

View File

@ -34,7 +34,7 @@ class CityScreenTileTable(private val cityScreen: CityScreen): Table() {
val stats = selectedTile.getTileStats(city, city.civInfo)
innerTable.pad(5f)
innerTable.add( MarkupRenderer.render(selectedTile.toMarkup(city.civInfo)) {
innerTable.add( MarkupRenderer.render(selectedTile.toMarkup(city.civInfo), noLinkImages = true) {
// Sorry, this will leave the city screen
UncivGame.Current.setScreen(CivilopediaScreen(city.civInfo.gameInfo.ruleSet, link = it))
} )

View File

@ -58,7 +58,7 @@ class ConstructionInfoTable(val city: CityInfo): Table() {
val description: String = when (construction) {
is BaseUnit -> construction.getDescription(true)
is BaseUnit -> construction.getDescription()
is Building -> construction.getDescription(true, city, city.civInfo.gameInfo.ruleSet)
is PerpetualConstruction -> construction.description.replace("[rate]", "[${construction.getConversionRate(city)}]").tr()
else -> "" // Should never happen

View File

@ -93,7 +93,7 @@ class CivilopediaScreen(
if (category !in categoryToButtons) return // defense against being passed a bad selector
categoryToButtons[category]!!.color = Color.BLUE
if (category !in categoryToEntries) return // defense, allowing buggy panes to remain emtpy while others work
if (category !in categoryToEntries) return // defense, allowing buggy panes to remain empty while others work
var entries = categoryToEntries[category]!!
if (category != CivilopediaCategories.Difficulty) // this is the only case where we need them in order
entries = entries.sortedBy { it.name.tr() } // Alphabetical order of localized names
@ -215,7 +215,7 @@ class CivilopediaScreen(
.map {
CivilopediaEntry(
it.name,
it.getDescription(false),
"",
CivilopediaCategories.Unit.getImage?.invoke(it.name, imageSize),
(it as? ICivilopediaText).takeUnless { ct -> ct==null || ct.isCivilopediaTextEmpty() }
)
@ -253,7 +253,7 @@ class CivilopediaScreen(
.map {
CivilopediaEntry(
it.key.replace("_", " "),
it.value.joinToString("\n\n") { line -> line.tr() },
"",
// CivilopediaCategories.Tutorial.getImage?.invoke(it.name, imageSize)
flavour = SimpleCivilopediaText(
sequenceOf(FormattedLine(extraImage = it.key)),
@ -317,6 +317,7 @@ class CivilopediaScreen(
entrySplitPane.splitAmount = 0.3f
entryTable.addActor(entrySplitPane)
entrySplitPane.setFillParent(true)
entrySplitPane.pack() // ensure selectEntry has correct entrySelectScroll.height and maxY
if (link.isEmpty() || '/' !in link)
selectCategory(category)

View File

@ -8,28 +8,59 @@ import com.badlogic.gdx.utils.Align
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.stats.INamed
import com.unciv.ui.utils.*
import kotlin.math.max
/* Ideas:
* - Now we're using a Table container and inside one Table per line. Rendering order, in view of
* texture swaps, is per Group, as this goes by ZIndex and that is implemented as actual index
* into the parent's children array. So, we're SOL to get the number of texture switches down
* with this structure, as many lines will require at least 2 texture switches.
* We *could* instead try go for one big table with 4 columns (3 images, plus rest)
* and use colspan - then group all images separate from labels via ZIndex. To-Do later.
* - Do bold using Distance field fonts wrapped in something like [maltaisn/msdf-gdx](https://github.com/maltaisn/msdf-gdx)
* - Do strikethrough by stacking a line on top (as rectangle with background like the separator but thinner)
*/
/** Represents a text line with optional linking capability.
// Kdoc not using the @property syntax because Android Studio 4.2.2 renders those _twice_
/** Represents a decorated text line with optional linking capability.
* A line can have [text] with optional [size], [color], [indent] or as [header];
* and up to three icons: [link], [object][icon], [star][starred] in that order.
* Special cases:
* - Standalone [image][extraImage] from atlas or from ExtraImages
* - A separator line ([separator])
* - Automatic external links (no [text] but [link] begins with a URL protocol)
*
* @param text Text to display.
* @param link Create link: Line gets a 'Link' icon and is linked to either
* an Unciv object (format `category/entryname`) or an external URL.
* @param extraImage Display an Image instead of text. Can be a path as understood by
* [ImageGetter.getImage] or the name of a png or jpg in ExtraImages.
* @param imageSize Width of the [extraImage], height is calculated preserving aspect ratio. Defaults to available width.
* @param header Header level. 1 means double text size and decreases from there.
* @param separator Renders a separator line instead of text.
*/
class FormattedLine (
/** Text to display. */
val text: String = "",
/** Create link: Line gets a 'Link' icon and is linked to either
* an Unciv object (format `category/entryname`) or an external URL. */
val link: String = "",
/** Display an Unciv object's icon inline but do not link (format `category/entryname`). */
val icon: String = "",
/** Display an Image instead of text, [sized][imageSize]. Can be a path as understood by
* [ImageGetter.getImage] or the name of a png or jpg in ExtraImages. */
val extraImage: String = "",
/** Width of the [extraImage], height is calculated preserving aspect ratio. Defaults to available width. */
val imageSize: Float = Float.NaN,
/** Text size, defaults to 18. Use [size] or [header] but not both. */
val size: Int = Int.MIN_VALUE,
/** Header level. 1 means double text size and decreases from there. */
val header: Int = 0,
/** Indentation: 0 = text will follow icons with a little padding,
* 1 = aligned to a little more than 3 icons, each step above that adds 30f. */
val indent: Int = 0,
/** Defines vertical padding between rows, defaults to 5f. */
val padding: Float = Float.NaN,
/** Sets text color, accepts Java names or 6/3-digit web colors (e.g. #FFA040). */
val color: String = "",
/** Renders a separator line instead of text. Can be combined only with [color] and [size] (line width, default 2) */
val separator: Boolean = false,
/** Decorates text with a star icon - if set, it receives the [color] instead of the text. */
val starred: Boolean = false,
/** Centers the line (and turns off wrap) */
val centered: Boolean = false
) {
// Note: This gets directly deserialized by Json - please keep all attributes meant to be read
// from json in the primary constructor parameters above. Everything else should be a fun(),
@ -53,31 +84,89 @@ class FormattedLine (
}
}
/** Translates [centered] into [libGdx][Gdx] [Align] value */
val align: Int by lazy {if (centered) Align.center else Align.left}
private val iconToDisplay: String by lazy {
if (icon.isNotEmpty()) icon else if (linkType == LinkType.Internal) link else ""
}
private val textToDisplay: String by lazy {
if (text.isEmpty() && linkType == LinkType.External) link else text
}
/** Returns true if this formatted line will not display anything */
fun isEmpty(): Boolean = text.isEmpty() && extraImage.isEmpty() && link.isEmpty() && !separator
/** Constants used by [FormattedLine]
* @property defaultSize Mirrors default text size as defined elsewhere
* @property headerSizes Array of text sizes to translate the [header] attribute
/** Retrieves the parsed [Color] corresponding to the [color] property (String)*/
val displayColor: Color by lazy { parseColor() }
/** Returns true if this formatted line will not display anything */
fun isEmpty(): Boolean = text.isEmpty() && extraImage.isEmpty() &&
!starred && icon.isEmpty() && link.isEmpty()
/** Self-check to potentially support the mod checker
* @return `null` if no problems found, or multiline String naming problems.
*/
@Suppress("unused")
fun unsupportedReason(): String? {
val reasons = sequence {
if (text.isNotEmpty() && separator) yield("separator and text are incompatible")
if (extraImage.isNotEmpty() && link.isNotEmpty()) yield("extraImage and other options except imageSize are incompatible")
if (header != 0 && size != Int.MIN_VALUE) yield("use either size or header but not both")
// ...
}
return reasons.joinToString { "\n" }.takeIf { it.isNotEmpty() }
}
/** Constants used by [FormattedLine] */
companion object {
/** Mirrors default [text] size as used by [toLabel] */
const val defaultSize = 18
/** Array of text sizes to translate the [header] attribute */
val headerSizes = arrayOf(defaultSize,36,32,27,24,21,15,12,9) // pretty arbitrary, yes
/** Default color for [text] _and_ icons */
val defaultColor: Color = Color.WHITE
/** Internal path to the [Link][link] image */
const val linkImage = "OtherIcons/Link"
/** Internal path to the [Star][starred] image */
const val starImage = "OtherIcons/Star"
/** Default inline icon size */
const val minIconSize = 30f
/** Padding added to the right of each icon */
const val iconPad = 5f
/** Padding distance per [indent] level */
const val indentPad = 30f
}
/** Extension: determines if a [String] looks like a link understood by the OS */
private fun String.hasProtocol() = startsWith("http://") || startsWith("https://") || startsWith("mailto:")
/** Extension: determines if a section of a [String] is composed entirely of hex digits
* @param start starting index
* @param length length of section (if == 0 [isHex] returns `true`, if receiver too short [isHex] returns `false`)
*/
private fun String.isHex(start: Int, length: Int) =
when {
length == 0 -> false
start + length > this.length -> false
substring(start, start + length).all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' } -> true
else -> false
}
/** Parse a json-supplied color string to [Color], defaults to [defaultColor]. */
private fun parseColor(): Color {
if (color.isEmpty()) return defaultColor
if (color[0] == '#' && color.isHex(1,3)) {
if (color.isHex(1,6)) return Color.valueOf(color)
val hex6 = String(charArrayOf(color[1], color[1], color[2], color[2], color[3], color[3]))
return Color.valueOf(hex6)
}
return defaultColor
}
/**
* Renders the formatted line as a scene2d [Actor] (currently always a [Table])
* @param labelWidth Total width to render into, needed to support wrap on Labels.
* @param noLinkImages Omit visual indicator that a line is linked.
*/
fun render(labelWidth: Float): Actor {
fun render(labelWidth: Float, noLinkImages: Boolean = false): Actor {
if (extraImage.isNotEmpty()) {
val table = Table(CameraStageBaseScreen.skin)
try {
@ -101,21 +190,70 @@ class FormattedLine (
val fontSize = when {
header in headerSizes.indices -> headerSizes[header]
else -> defaultSize
size == Int.MIN_VALUE -> defaultSize
else -> size
}
val labelColor = if(starred) defaultColor else displayColor
val table = Table(CameraStageBaseScreen.skin)
var iconCount = 0
val iconSize = max(minIconSize, fontSize * 1.5f)
if (linkType != LinkType.None && !noLinkImages) {
table.add( ImageGetter.getImage(linkImage) ).size(iconSize).padRight(iconPad)
iconCount++
}
if (!noLinkImages)
iconCount += renderIcon(table, iconToDisplay, iconSize)
if (starred) {
val image = ImageGetter.getImage(starImage)
image.color = displayColor
table.add(image).size(iconSize).padRight(iconPad)
iconCount++
}
if (textToDisplay.isNotEmpty()) {
val label = if (fontSize == defaultSize) textToDisplay.toLabel()
else textToDisplay.toLabel(defaultColor,fontSize)
label.wrap = labelWidth > 0f
val usedWidth = iconCount * (iconSize + iconPad)
val padIndent = when {
centered -> -usedWidth
indent == 0 && iconCount == 0 -> 0f
indent == 0 -> iconPad
else -> (indent-1) * indentPad + 3 * minIconSize + 4 * iconPad - usedWidth
}
val label = if (fontSize == defaultSize && labelColor == defaultColor) textToDisplay.toLabel()
else textToDisplay.toLabel(labelColor,fontSize)
label.wrap = !centered && labelWidth > 0f
label.setAlignment(align)
if (labelWidth == 0f)
table.add(label)
.padLeft(padIndent).align(align)
else
table.add(label).width(labelWidth)
table.add(label).width(labelWidth - usedWidth - padIndent)
.padLeft(padIndent).align(align)
}
return table
}
/** Place a RuleSet object icon.
* @return 1 if successful for easy counting
*/
private fun renderIcon(table: Table, iconToDisplay: String, iconSize: Float): Int {
// prerequisites: iconToDisplay has form "category/name", category can be mapped to
// a `CivilopediaCategories`, and that knows how to get an Image.
if (iconToDisplay.isEmpty()) return 0
val parts = iconToDisplay.split('/', limit = 2)
if (parts.size != 2) return 0
val category = CivilopediaCategories.fromLink(parts[0]) ?: return 0
if (category.getImage == null) return 0
// That Enum custom property is a nullable reference to a lambda which
// in turn is allowed to return null. Sorry, but without `!!` the code
// won't compile and with we would get the incorrect warning.
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
val image = category.getImage!!(parts[1], iconSize) ?: return 0
table.add(image).size(iconSize).padRight(iconPad)
return 1
}
// Debug visualization only
override fun toString(): String {
return when {
@ -131,22 +269,28 @@ class FormattedLine (
/** Makes [renderer][render] available outside [ICivilopediaText] */
object MarkupRenderer {
/** Height of empty line (`FormattedLine()`) - about half a normal text line, independent of font size */
private const val emptyLineHeight = 10f
/** Default cell padding of non-empty lines */
private const val defaultPadding = 2.5f
/** Padding above a [separator][FormattedLine.separator] line */
private const val separatorTopPadding = 5f
/** Padding below a [separator][FormattedLine.separator] line */
private const val separatorBottomPadding = 15f
/**
* Build a Gdx [Table] showing [formatted][FormattedLine] [content][lines].
*
* @param lines The formatted content to render.
* @param labelWidth Available width needed for wrapping labels and [centered][FormattedLine.centered] attribute.
* @param padding Default cell padding (default 2.5f) to control line spacing
* @param noLinkImages Flag to omit link images (but not linking itself)
* @param linkAction Delegate to call for internal links. Leave null to suppress linking.
*/
fun render(
lines: Collection<FormattedLine>,
labelWidth: Float = 0f,
padding: Float = defaultPadding,
noLinkImages: Boolean = false,
linkAction: ((id: String) -> Unit)? = null
): Table {
val skin = CameraStageBaseScreen.skin
@ -157,10 +301,11 @@ object MarkupRenderer {
continue
}
if (line.separator) {
table.addSeparator().pad(separatorTopPadding, 0f, separatorBottomPadding, 0f)
table.addSeparator(line.displayColor, 1, if (line.size == Int.MIN_VALUE) 2f else line.size.toFloat())
.pad(separatorTopPadding, 0f, separatorBottomPadding, 0f)
continue
}
val actor = line.render(labelWidth)
val actor = line.render(labelWidth, noLinkImages)
if (line.linkType == FormattedLine.LinkType.Internal && linkAction != null)
actor.onClick {
linkAction(line.link)
@ -170,9 +315,9 @@ object MarkupRenderer {
Gdx.net.openURI(line.link)
}
if (labelWidth == 0f)
table.add(actor).row()
table.add(actor).align(line.align).row()
else
table.add(actor).width(labelWidth).row()
table.add(actor).width(labelWidth).align(line.align).row()
}
return table.apply { pack() }
}

View File

@ -22,7 +22,7 @@ class TileInfoTable(private val viewingCiv :CivilizationInfo) : Table(CameraStag
if (tile != null && (UncivGame.Current.viewEntireMapForDebug || viewingCiv.exploredTiles.contains(tile.position)) ) {
add(getStatsTable(tile))
add( MarkupRenderer.render(tile.toMarkup(viewingCiv), padding = 0f) {
add( MarkupRenderer.render(tile.toMarkup(viewingCiv), padding = 0f, noLinkImages = true) {
UncivGame.Current.setScreen(CivilopediaScreen(viewingCiv.gameInfo.ruleSet, link = it))
} ).pad(5f)
// For debug only!