This commit is contained in:
Yair Morgenstern 2021-06-01 19:57:00 +03:00
commit 876bdf8f30
44 changed files with 1635 additions and 709 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -410,72 +410,79 @@ Maori Warrior
orig: 100, 100
offset: 0, 0
index: -1
Mechanized Infantry
Marine
rotate: false
xy: 1124, 206
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Minuteman
Mechanized Infantry
rotate: false
xy: 1226, 308
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Modern Armor
Minuteman
rotate: false
xy: 1328, 410
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Mohawk Warrior
Modern Armor
rotate: false
xy: 1124, 104
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Musketeer
Mohawk Warrior
rotate: false
xy: 1124, 2
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Musketeer
rotate: false
xy: 1226, 206
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Musketman
rotate: false
xy: 1226, 207
xy: 1328, 309
size: 100, 99
orig: 100, 99
offset: 0, 0
index: -1
Naresuan's Elephant
rotate: false
xy: 1328, 308
xy: 1430, 410
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Norwegian Ski Infantry
rotate: false
xy: 1430, 410
xy: 1226, 104
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Nuclear Missile
rotate: false
xy: 1226, 105
xy: 1226, 2
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Panzer
rotate: false
xy: 1328, 206
xy: 1328, 207
size: 100, 100
orig: 100, 100
offset: 0, 0
@ -496,126 +503,126 @@ Pikeman
index: -1
Rifleman
rotate: false
xy: 1226, 3
xy: 1328, 105
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Rocket Artillery
rotate: false
xy: 1328, 104
xy: 1430, 206
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Samurai
rotate: false
xy: 1328, 2
xy: 1532, 308
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Scout
rotate: false
xy: 1430, 206
xy: 1634, 410
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Settler
rotate: false
xy: 1532, 308
xy: 1328, 3
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Ship of the Line
rotate: false
xy: 1634, 410
xy: 1430, 104
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Sipahi
rotate: false
xy: 1430, 104
xy: 1430, 2
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Slinger
rotate: false
xy: 1430, 2
xy: 1532, 206
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Spearman
rotate: false
xy: 1532, 206
xy: 1634, 308
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Stealth Bomber
rotate: false
xy: 1634, 308
xy: 1736, 410
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Submarine
rotate: false
xy: 1736, 410
xy: 1532, 104
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Swordsman
rotate: false
xy: 1532, 104
xy: 1532, 2
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Tank
rotate: false
xy: 1532, 2
xy: 1634, 206
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Tercio
rotate: false
xy: 1634, 206
xy: 1736, 308
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Trebuchet
rotate: false
xy: 1736, 308
xy: 1838, 410
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Triplane
rotate: false
xy: 1838, 410
xy: 1634, 104
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Trireme
rotate: false
xy: 1634, 103
xy: 1736, 205
size: 100, 101
orig: 100, 101
offset: 0, 0
index: -1
Turtle Ship
rotate: false
xy: 1736, 206
xy: 1634, 2
size: 100, 100
orig: 100, 100
offset: 0, 0
@ -636,28 +643,28 @@ War Elephant
index: -1
Warrior
rotate: false
xy: 1736, 104
xy: 1736, 103
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Work Boats
rotate: false
xy: 1736, 2
xy: 1838, 206
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Worker
rotate: false
xy: 1838, 206
xy: 1940, 308
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
Zero
rotate: false
xy: 1940, 308
xy: 1838, 104
size: 100, 100
orig: 100, 100
offset: 0, 0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 279 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 964 KiB

After

Width:  |  Height:  |  Size: 956 KiB

View File

@ -527,7 +527,7 @@
},
{
"name": "Nuclear Fission",
"row": 3,
"row": 4,
"prerequisites": ["Atomic Theory","Radar"],
"quote": "'I am become Death, the destroyer of worlds.' - J. Robert Oppenheimer"
},

View File

@ -135,6 +135,12 @@
"effect": "Double movement rate through Forest and Jungle",
"unitTypes": ["Melee"]
},
{
"name": "Amphibious",
"prerequisites": ["Shock I", "Drill I"],
"uniques": ["Eliminates combat penalty for attacking over a river", "Eliminates combat penalty for attacking from the sea"],
"unitTypes": ["Melee"]
},
{
"name": "Medic",
"prerequisites": ["Shock I", "Drill I", "Scouting II"],
@ -469,4 +475,4 @@
"name": "Slinger Withdraw", // only for Slinger and subsequent upgrades
"effect": "May withdraw before melee ([80]%)"
}
]
]

View File

@ -525,7 +525,7 @@
"upgradesTo": "Musketman",
"obsoleteTech": "Metallurgy",
"requiredResource": "Iron",
"uniques": ["Amphibious"],
"promotions": ["Amphibious"],
"hurryCostModifier": 20,
"attackSound": "metalhit"
//Danish unique unit. Can attack from the sea without any penalty, and moves faster.
@ -1076,7 +1076,8 @@
"cost": 1000,
"requiredTech": "Rocketry",
"requiredResource": "Uranium",
"uniques": ["Self-destructs when attacking", "Nuclear weapon", "Requires [Manhattan Project]"]
"uniques": ["Self-destructs when attacking", "Nuclear weapon", "Requires [Manhattan Project]"],
"attackSound": "nuke"
},
{
"name": "Landship",
@ -1175,6 +1176,17 @@
"obsoleteTech": "Mobile Tactics",
"attackSound": "shot"
},
{
"name": "Marine",
"unitType": "Melee",
"movement": 2,
"strength": 65,
"cost": 400,
"requiredTech": "Pharmaceuticals",
"attackSound": "shot",
"promotions": ["Amphibious"],
"uniques": ["+1 sight when embarked", "Defense bonus when embarked"]
},
{
"name": "Machine Gun",
"unitType": "Ranged",

View File

@ -135,6 +135,10 @@ Provides 3 happiness at 30 Influence =
Provides land units every 20 turns at 30 Influence =
Gift [giftAmount] gold (+[influenceAmount] influence) =
Relationship changes in another [turnsToRelationshipChange] turns =
Protected by =
Revoke Protection =
Pledge to protect =
Declare Protection of [cityStateName]? =
Cultured =
Maritime =
@ -936,6 +940,7 @@ Invalid ID! =
Mods =
Download [modName] =
Update [modName] =
Could not download mod list =
Download mod from URL =
Download =
@ -952,6 +957,9 @@ Disable as permanent visual mod =
Installed =
Downloaded! =
Could not download mod =
Online query result is incomplete =
No description provided =
[stargazers]✯ =
# Uniques that are relevant to more than one type of game object

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -45,6 +45,7 @@ object NextTurnAutomation {
chooseTechToResearch(civInfo)
automateCityBombardment(civInfo)
useGold(civInfo)
protectCityStates(civInfo)
automateUnits(civInfo)
reassignWorkedTiles(civInfo)
trainSettler(civInfo)
@ -140,6 +141,20 @@ object NextTurnAutomation {
}
}
private fun protectCityStates(civInfo: CivilizationInfo) {
for (state in civInfo.getKnownCivs().filter{!it.isDefeated() && it.isCityState()}) {
val diplomacyManager = state.getDiplomacyManager(civInfo.civName)
if(diplomacyManager.relationshipLevel() >= RelationshipLevel.Friend
&& diplomacyManager.diplomaticStatus == DiplomaticStatus.Peace)
{
state.addProtectorCiv(civInfo)
} else if (diplomacyManager.relationshipLevel() < RelationshipLevel.Friend
&& diplomacyManager.diplomaticStatus == DiplomaticStatus.Protector) {
state.removeProtectorCiv(civInfo)
}
}
}
private fun getFreeTechForCityStates(civInfo: CivilizationInfo) {
// City-States automatically get all techs that at least half of the major civs know
val researchableTechs = civInfo.gameInfo.ruleSet.technologies.keys
@ -346,9 +361,14 @@ object NextTurnAutomation {
private fun motivationToAttack(civInfo: CivilizationInfo, otherCiv: CivilizationInfo): Int {
val ourCombatStrength = Automation.evaluteCombatStrength(civInfo).toFloat()
val theirCombatStrength = Automation.evaluteCombatStrength(otherCiv)
if (theirCombatStrength > ourCombatStrength) return 0
var theirCombatStrength = Automation.evaluteCombatStrength(otherCiv)
//for city-states, also consider there protectors
if(otherCiv.isCityState() and otherCiv.getProtectorCivs().isNotEmpty()) {
theirCombatStrength += otherCiv.getProtectorCivs().sumOf{Automation.evaluteCombatStrength(it)}
}
if (theirCombatStrength > ourCombatStrength) return 0
fun isTileCanMoveThrough(tileInfo: TileInfo): Boolean {
val owner = tileInfo.getOwner()
@ -410,7 +430,8 @@ object NextTurnAutomation {
if (theirCity.getTiles().none { it.neighbors.any { it.getOwner() == theirCity.civInfo && it.getCity() != theirCity } })
modifierMap["Isolated city"] = 15
if (otherCiv.isCityState()) modifierMap["City-state"] = -20
//Maybe not needed if city-state has potential protectors?
if (otherCiv.isCityState()) modifierMap["City-state"] = -10
return modifierMap.values.sum()
}

View File

@ -118,9 +118,10 @@ object BattleDamage {
modifiers.add("Attacker Bonus", unique.params[0].toInt())
}
if (attacker.unit.isEmbarked() && !attacker.unit.hasUnique("Amphibious"))
if (attacker.unit.isEmbarked() && !attacker.unit.hasUnique("Eliminates combat penalty for attacking from the sea"))
modifiers["Landing"] = -50
if (attacker.isMelee()) {
val numberOfAttackersSurroundingDefender = defender.getTile().neighbors.count {
it.militaryUnit != null
@ -130,7 +131,7 @@ object BattleDamage {
if (numberOfAttackersSurroundingDefender > 1)
modifiers["Flanking"] = 10 * (numberOfAttackersSurroundingDefender - 1) //https://www.carlsguides.com/strategy/civilization5/war/combatbonuses.php
if (attacker.getTile().aerialDistanceTo(defender.getTile()) == 1 && attacker.getTile().isConnectedByRiver(defender.getTile())
&& !attacker.unit.hasUnique("Amphibious")) {
&& !attacker.unit.hasUnique("Eliminates combat penalty for attacking over a river")) {
if (!attacker.getTile().hasConnection(attacker.getCivInfo()) // meaning, the tiles are not road-connected for this civ
|| !defender.getTile().hasConnection(attacker.getCivInfo())
|| !attacker.getCivInfo().tech.roadsConnectAcrossRivers) {

View File

@ -3,6 +3,7 @@ package com.unciv.logic.battle
import com.unciv.logic.city.CityInfo
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.TileInfo
import com.unciv.models.UncivSound
import com.unciv.models.ruleset.unit.UnitType
import kotlin.math.pow
import kotlin.math.roundToInt
@ -20,6 +21,7 @@ class CityCombatant(val city: CityInfo) : ICombatant {
override fun isInvisible(): Boolean = false
override fun canAttack(): Boolean = (!city.attackedThisTurn)
override fun matchesCategory(category: String) = category == "City"
override fun getAttackSound() = UncivSound.Bombard
override fun takeDamage(damage: Int) {
city.health -= damage

View File

@ -2,6 +2,7 @@ package com.unciv.logic.battle
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.TileInfo
import com.unciv.models.UncivSound
import com.unciv.models.ruleset.unit.UnitType
interface ICombatant{
@ -18,6 +19,7 @@ interface ICombatant{
fun isInvisible(): Boolean
fun canAttack(): Boolean
fun matchesCategory(category:String): Boolean
fun getAttackSound(): UncivSound
fun isMelee(): Boolean {
return getUnitType().isMelee()

View File

@ -3,6 +3,7 @@ package com.unciv.logic.battle
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.MapUnit
import com.unciv.logic.map.TileInfo
import com.unciv.models.UncivSound
import com.unciv.models.ruleset.unit.UnitType
class MapUnitCombatant(val unit: MapUnit) : ICombatant {
@ -15,6 +16,9 @@ class MapUnitCombatant(val unit: MapUnit) : ICombatant {
override fun isInvisible(): Boolean = unit.isInvisible()
override fun canAttack(): Boolean = unit.canAttack()
override fun matchesCategory(category:String) = unit.matchesFilter(category)
override fun getAttackSound() = unit.baseUnit.attackSound.let {
if (it==null) UncivSound.Click else UncivSound.custom(it)
}
override fun takeDamage(damage: Int) {
unit.health -= damage

View File

@ -5,10 +5,13 @@ import com.unciv.logic.civilization.AlertType
import com.unciv.logic.civilization.NotificationIcon
import com.unciv.logic.civilization.PopupAlert
import com.unciv.models.ruleset.Building
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.UniqueMap
import com.unciv.models.ruleset.unit.BaseUnit
import com.unciv.models.stats.Stats
import com.unciv.models.translations.tr
import com.unciv.ui.civilopedia.CivilopediaCategories
import com.unciv.ui.civilopedia.FormattedLine
import com.unciv.ui.utils.Fonts
import com.unciv.ui.utils.withItem
import com.unciv.ui.utils.withoutItem
@ -141,6 +144,28 @@ class CityConstructions {
return result
}
fun getProductionMarkup(ruleset: Ruleset): FormattedLine {
val currentConstructionSnapshot = currentConstructionFromQueue
if (currentConstructionSnapshot.isEmpty()) return FormattedLine()
val category = when {
ruleset.buildings[currentConstructionSnapshot]
?.let{ it.isWonder || it.isNationalWonder } == true ->
CivilopediaCategories.Wonder.name
currentConstructionSnapshot in ruleset.buildings ->
CivilopediaCategories.Building.name
currentConstructionSnapshot in ruleset.units ->
CivilopediaCategories.Unit.name
else -> ""
}
var label = currentConstructionSnapshot
if (!PerpetualConstruction.perpetualConstructionsMap.containsKey(currentConstructionSnapshot)) {
val turnsLeft = turnsToConstruction(currentConstructionSnapshot)
label += " - $turnsLeft${Fonts.turn}"
}
return if (category.isEmpty()) FormattedLine(label)
else FormattedLine(label, link="$category/$currentConstructionSnapshot")
}
fun getCurrentConstruction(): IConstruction = getConstruction(currentConstructionFromQueue)
fun isBuilt(buildingName: String): Boolean = builtBuildings.contains(buildingName)

View File

@ -653,6 +653,30 @@ class CivilizationInfo {
fun getAllyCiv() = allyCivName
fun getProtectorCivs() : List<CivilizationInfo> {
if(this.isMajorCiv()) return emptyList()
return diplomacy.values
.filter{!it.otherCiv().isDefeated() && it.diplomaticStatus == DiplomaticStatus.Protector}
.map{it->it.otherCiv()}
}
fun addProtectorCiv(otherCiv: CivilizationInfo) {
if(!this.isCityState() or !otherCiv.isMajorCiv() or otherCiv.isDefeated()) return
if(!knows(otherCiv) or isAtWarWith(otherCiv)) return //Exception
val diplomacy = getDiplomacyManager(otherCiv.civName)
diplomacy.diplomaticStatus = DiplomaticStatus.Protector
}
fun removeProtectorCiv(otherCiv: CivilizationInfo) {
if(!this.isCityState() or !otherCiv.isMajorCiv() or otherCiv.isDefeated()) return
if(!knows(otherCiv) or isAtWarWith(otherCiv)) return //Exception
val diplomacy = getDiplomacyManager(otherCiv.civName)
diplomacy.diplomaticStatus = DiplomaticStatus.Peace
diplomacy.influence -= 20
}
fun updateAllyCivForCityState() {
var newAllyName = ""
if (!isCityState()) return

View File

@ -182,6 +182,7 @@ class DiplomacyManager() {
var restingPoint = 0f
for (unique in otherCiv().getMatchingUniques("Resting point for Influence with City-States is increased by []"))
restingPoint += unique.params[0].toInt()
if(diplomaticStatus == DiplomaticStatus.Protector) restingPoint += 5
return restingPoint
}
@ -546,6 +547,16 @@ class DiplomacyManager() {
}
}
}
if (otherCiv.isCityState())
{
for (thirdCiv in otherCiv.getProtectorCivs()) {
if (thirdCiv.knows(civInfo)
&& thirdCiv.getDiplomacyManager(civInfo).canDeclareWar()) {
thirdCiv.getDiplomacyManager(civInfo).declareWar()
}
}
}
}
/** Should only be called from makePeace */

View File

@ -2,5 +2,6 @@ package com.unciv.logic.civilization.diplomacy
enum class DiplomaticStatus{
Peace,
Protector, //city state's diplomacy for major civ can be marked as Protector, not vice versa.
War
}

View File

@ -10,6 +10,7 @@ import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.tile.*
import com.unciv.models.stats.Stats
import com.unciv.models.translations.tr
import com.unciv.ui.civilopedia.FormattedLine
import com.unciv.ui.utils.Fonts
import kotlin.math.abs
import kotlin.math.min
@ -144,7 +145,7 @@ open class TileInfo {
else if (!ruleset.tileResources.containsKey(resource!!)) throw Exception("Resource $resource does not exist in this ruleset!")
else ruleset.tileResources[resource!!]!!
fun getNaturalWonder(): Terrain =
private fun getNaturalWonder(): Terrain =
if (naturalWonder == null) throw Exception("No natural wonder exists for this tile!")
else ruleset.terrains[naturalWonder!!]!!
@ -179,8 +180,7 @@ open class TileInfo {
fun getBaseTerrain(): Terrain = baseTerrainObject
fun getOwner(): CivilizationInfo? {
val containingCity = getCity()
if (containingCity == null) return null
val containingCity = getCity() ?: return null
return containingCity.civInfo
}
@ -204,8 +204,7 @@ open class TileInfo {
fun hasUnique(unique: String) = getAllTerrains().any { it.uniques.contains(unique) }
fun getWorkingCity(): CityInfo? {
val civInfo = getOwner()
if (civInfo == null) return null
val civInfo = getOwner() ?: return null
return civInfo.cities.firstOrNull { it.isWorked(this) }
}
@ -260,7 +259,7 @@ open class TileInfo {
stats.add(getTileResource()) // resource base
if (resource.building != null && city != null && city.cityConstructions.isBuilt(resource.building!!)) {
val resourceBuilding = tileMap.gameInfo.ruleSet.buildings[resource.building!!]
if (resourceBuilding != null && resourceBuilding.resourceBonusStats != null)
if (resourceBuilding?.resourceBonusStats != null)
stats.add(resourceBuilding.resourceBonusStats!!) // resource-specific building (eg forge, stable) bonus
}
}
@ -337,7 +336,7 @@ open class TileInfo {
improvement.hasUnique("Can be built outside your borders")
// citadel can be built only next to or within own borders
|| improvement.hasUnique("Can be built just outside your borders")
&& neighbors.any { it.getOwner() == civInfo } && !civInfo.cities.isEmpty()
&& neighbors.any { it.getOwner() == civInfo } && civInfo.cities.isNotEmpty()
) -> false
improvement.uniqueObjects.any {
it.placeholderText == "Obsolete with []" && civInfo.tech.isResearched(it.params[0])
@ -346,10 +345,10 @@ open class TileInfo {
}
}
/** Without regards to what civinfo it is, a lot of the checks are just for the improvement on the tile.
/** Without regards to what CivInfo it is, a lot of the checks are just for the improvement on the tile.
* Doubles as a check for the map editor.
*/
fun canImprovementBeBuiltHere(improvement: TileImprovement, resourceIsVisible: Boolean = resource != null): Boolean {
private fun canImprovementBeBuiltHere(improvement: TileImprovement, resourceIsVisible: Boolean = resource != null): Boolean {
val topTerrain = getLastTerrain()
return when {
@ -358,7 +357,7 @@ open class TileInfo {
"Cannot be built on bonus resource" in improvement.uniques && resource != null
&& getTileResource().resourceType == ResourceType.Bonus -> false
// Road improvements can change on tiles withh irremovable improvements - nothing else can, though.
// Road improvements can change on tiles with irremovable improvements - nothing else can, though.
improvement.name != RoadStatus.Railroad.name && improvement.name != RoadStatus.Railroad.name
&& improvement.name != "Remove Road" && improvement.name != "Remove Railroad"
&& getTileImprovement().let { it != null && it.hasUnique("Irremovable") } -> false
@ -371,7 +370,7 @@ open class TileInfo {
improvement.uniqueObjects.filter { it.placeholderText == "Must be next to []" }.any {
val filter = it.params[0]
if (filter == "River") return@any !isAdjacentToRiver()
else return@any !neighbors.any { it.matchesUniqueFilter(filter) }
else return@any !neighbors.any { neighbor -> neighbor.matchesUniqueFilter(filter) }
} -> false
improvement.name == "Road" && roadStatus == RoadStatus.None && !isWater -> true
improvement.name == "Railroad" && this.roadStatus != RoadStatus.Railroad && !isWater -> true
@ -448,8 +447,20 @@ open class TileInfo {
return min(distance, wrappedDistance).toInt()
}
override fun toString(): String { // for debugging, it helps to see what you're doing
return toString(null)
/** Shows important properties of this tile for debugging _only_, it helps to see what you're doing */
override fun toString(): String {
val lineList = arrayListOf("TileInfo @($position)")
if (isCityCenter()) lineList += getCity()!!.name
lineList += baseTerrain
for (terrainFeature in terrainFeatures) lineList += terrainFeature
if (resource != null ) lineList += resource!!
if (naturalWonder != null) lineList += naturalWonder!!
if (roadStatus !== RoadStatus.None && !isCityCenter()) lineList += roadStatus.name
if (improvement != null) lineList += improvement!!
if (civilianUnit != null) lineList += civilianUnit!!.name + " - " + civilianUnit!!.civInfo.civName
if (militaryUnit != null) lineList += militaryUnit!!.name + " - " + militaryUnit!!.civInfo.civName
if (isImpassible()) lineList += Constants.impassable
return lineList.joinToString()
}
/** The two tiles have a river between them */
@ -481,8 +492,8 @@ open class TileInfo {
return true
}
fun toString(viewingCiv: CivilizationInfo?): String {
val lineList = ArrayList<String>() // more readable than StringBuilder, with same performance for our use-case
fun toMarkup(viewingCiv: CivilizationInfo?): ArrayList<FormattedLine> {
val lineList = ArrayList<FormattedLine>() // more readable than StringBuilder, with same performance for our use-case
val isViewableToPlayer = viewingCiv == null || UncivGame.Current.viewEntireMapForDebug
|| viewingCiv.viewableTiles.contains(this)
@ -490,42 +501,46 @@ open class TileInfo {
val city = getCity()!!
var cityString = city.name.tr()
if (isViewableToPlayer) cityString += " (" + city.health + ")"
lineList += cityString
lineList += FormattedLine(cityString)
if (UncivGame.Current.viewEntireMapForDebug || city.civInfo == viewingCiv)
lineList += city.cityConstructions.getProductionForTileInfo()
lineList += city.cityConstructions.getProductionMarkup(ruleset)
}
lineList += baseTerrain.tr()
for (terrainFeature in terrainFeatures) lineList += terrainFeature.tr()
if (resource != null && (viewingCiv == null || hasViewableResource(viewingCiv))) lineList += resource!!.tr()
if (naturalWonder != null) lineList += naturalWonder!!.tr()
if (roadStatus !== RoadStatus.None && !isCityCenter()) lineList += roadStatus.name.tr()
if (improvement != null) lineList += improvement!!.tr()
lineList += FormattedLine(baseTerrain, link="Terrain/$baseTerrain")
for (terrainFeature in terrainFeatures)
lineList += FormattedLine(terrainFeature, link="Terrain/$terrainFeature")
if (resource != null && (viewingCiv == null || hasViewableResource(viewingCiv)))
lineList += FormattedLine(resource!!, link="Resource/$resource")
if (naturalWonder != null)
lineList += FormattedLine(naturalWonder!!, link="Terrain/$naturalWonder")
if (roadStatus !== RoadStatus.None && !isCityCenter())
lineList += FormattedLine(roadStatus.name, link="Improvement/${roadStatus.name}")
if (improvement != null)
lineList += FormattedLine(improvement!!, link="Improvement/$improvement")
if (improvementInProgress != null && isViewableToPlayer) {
var line = "{$improvementInProgress}"
if (turnsToImprovement > 0) line += " - $turnsToImprovement${Fonts.turn}"
else line += " ({Under construction})"
lineList += line.tr()
val line = "{$improvementInProgress}" +
if (turnsToImprovement > 0) " - $turnsToImprovement${Fonts.turn}" else " ({Under construction})"
lineList += FormattedLine(line, link="Improvement/$improvementInProgress")
}
if (civilianUnit != null && isViewableToPlayer)
lineList += civilianUnit!!.name.tr() + " - " + civilianUnit!!.civInfo.civName.tr()
lineList += FormattedLine(civilianUnit!!.name.tr() + " - " + civilianUnit!!.civInfo.civName.tr(),
link="Unit/${civilianUnit!!.name}")
if (militaryUnit != null && isViewableToPlayer) {
var milUnitString = militaryUnit!!.name.tr()
if (militaryUnit!!.health < 100) milUnitString += "(" + militaryUnit!!.health + ")"
milUnitString += " - " + militaryUnit!!.civInfo.civName.tr()
lineList += milUnitString
val milUnitString = militaryUnit!!.name.tr() +
(if (militaryUnit!!.health < 100) "(" + militaryUnit!!.health + ")" else "") +
" - " + militaryUnit!!.civInfo.civName.tr()
lineList += FormattedLine(milUnitString, link="Unit/${militaryUnit!!.name}")
}
val defenceBonus = getDefensiveBonus()
if (defenceBonus != 0f) {
var defencePercentString = (defenceBonus * 100).toInt().toString() + "%"
if (!defencePercentString.startsWith("-")) defencePercentString = "+$defencePercentString"
lineList += "[$defencePercentString] to unit defence".tr()
lineList += FormattedLine("[$defencePercentString] to unit defence")
}
if (isImpassible()) lineList += Constants.impassable.tr()
if (isImpassible()) lineList += FormattedLine(Constants.impassable)
return lineList.joinToString("\n")
return lineList
}
fun hasEnemyInvisibleUnit(viewingCiv: CivilizationInfo): Boolean {
val unitsInTile = getUnits()
if (unitsInTile.none()) return false
@ -649,7 +664,7 @@ open class TileInfo {
private fun normalizeTileImprovement(ruleset: Ruleset) {
if (improvement!!.startsWith("StartingLocation") == true) {
if (improvement!!.startsWith("StartingLocation")) {
if (!isLand || getLastTerrain().impassable) improvement = null
return
}

View File

@ -1,6 +1,6 @@
package com.unciv.models
enum class UncivSound(val value: String) {
private enum class UncivSoundConstants (val value: String) {
Click("click"),
Fortify("fortify"),
Promote("promote"),
@ -12,5 +12,64 @@ enum class UncivSound(val value: String) {
Policy("policy"),
Paper("paper"),
Whoosh("whoosh"),
Silent("")
Bombard("bombard"),
Slider("slider"),
Construction("construction"),
Silent(""),
Custom("")
}
/**
* Represents an Unciv Sound, either from a predefined set or custom with a specified filename.
*/
class UncivSound private constructor (
private val type: UncivSoundConstants,
filename: String? = null
) {
/** The base filename without extension. */
val value: String = filename ?: type.value
/*
init {
// Checking contract "use non-custom *w/o* filename OR custom *with* one
// Removed due to private constructor
if ((type == UncivSoundConstants.Custom) == filename.isNullOrEmpty()) {
throw IllegalArgumentException("Invalid UncivSound constructor arguments")
}
}
*/
companion object {
val Click = UncivSound(UncivSoundConstants.Click)
val Fortify = UncivSound(UncivSoundConstants.Fortify)
val Promote = UncivSound(UncivSoundConstants.Promote)
val Upgrade = UncivSound(UncivSoundConstants.Upgrade)
val Setup = UncivSound(UncivSoundConstants.Setup)
val Chimes = UncivSound(UncivSoundConstants.Chimes)
val Coin = UncivSound(UncivSoundConstants.Coin)
val Choir = UncivSound(UncivSoundConstants.Choir)
val Policy = UncivSound(UncivSoundConstants.Policy)
val Paper = UncivSound(UncivSoundConstants.Paper)
val Whoosh = UncivSound(UncivSoundConstants.Whoosh)
val Bombard = UncivSound(UncivSoundConstants.Bombard)
val Slider = UncivSound(UncivSoundConstants.Slider)
val Construction = UncivSound(UncivSoundConstants.Construction)
val Silent = UncivSound(UncivSoundConstants.Silent)
/** Creates an UncivSound instance for a custom sound.
* @param filename The base filename without extension.
*/
fun custom(filename: String) = UncivSound(UncivSoundConstants.Custom, filename)
}
// overrides ensure usability as hash key
override fun hashCode(): Int {
return type.hashCode() xor value.hashCode()
}
override fun equals(other: Any?): Boolean {
if (other == null || other !is UncivSound) return false
if (type != other.type) return false
return type != UncivSoundConstants.Custom || value == other.value
}
override fun toString(): String = value
}

View File

@ -34,6 +34,8 @@ class ModOptions {
var lastUpdated = ""
var modUrl = ""
var author = ""
var modSize = 0
}
class Ruleset {

View File

@ -284,7 +284,9 @@ class CityConstructionsTable(val cityScreen: CityScreen) : Table(CameraStageBase
if (!cannotAddConstructionToQueue(construction, cityScreen.city, cityScreen.city.cityConstructions)) {
val addToQueueButton = ImageGetter.getImage("OtherIcons/New").apply { color = Color.BLACK }.surroundWithCircle(40f)
addToQueueButton.onClick { addConstructionToQueue(construction, cityScreen.city.cityConstructions) }
addToQueueButton.onClick(getConstructionSound(construction)) {
addConstructionToQueue(construction, cityScreen.city.cityConstructions)
}
pickConstructionButton.add(addToQueueButton)
}
pickConstructionButton.row()
@ -338,7 +340,9 @@ class CityConstructionsTable(val cityScreen: CityScreen) : Table(CameraStageBase
|| cannotAddConstructionToQueue(construction, city, cityConstructions)) {
button.disable()
} else {
button.onClick { addConstructionToQueue(construction, cityConstructions) }
button.onClick(getConstructionSound(construction)) {
addConstructionToQueue(construction, cityConstructions)
}
}
}
@ -360,6 +364,15 @@ class CityConstructionsTable(val cityScreen: CityScreen) : Table(CameraStageBase
cityScreen.game.settings.addCompletedTutorialTask("Pick construction")
}
fun getConstructionSound(construction: IConstruction): UncivSound {
return when(construction) {
is Building -> UncivSound.Construction
is BaseUnit -> UncivSound.Promote
PerpetualConstruction.gold -> UncivSound.Coin
PerpetualConstruction.science -> UncivSound.Paper
else -> UncivSound.Click
}
}
fun purchaseConstruction(construction: IConstruction) {
val city = cityScreen.city

View File

@ -7,6 +7,8 @@ import com.unciv.logic.map.TileInfo
import com.unciv.models.UncivSound
import com.unciv.models.stats.Stats
import com.unciv.models.translations.tr
import com.unciv.ui.civilopedia.CivilopediaScreen
import com.unciv.ui.civilopedia.MarkupRenderer
import com.unciv.ui.utils.*
import kotlin.math.roundToInt
@ -32,7 +34,10 @@ class CityScreenTileTable(private val cityScreen: CityScreen): Table() {
val stats = selectedTile.getTileStats(city, city.civInfo)
innerTable.pad(5f)
innerTable.add(selectedTile.toString(city.civInfo).toLabel()).colspan(2)
innerTable.add( MarkupRenderer.render(selectedTile.toMarkup(city.civInfo)) {
// Sorry, this will leave the city screen
UncivGame.Current.setScreen(CivilopediaScreen(city.civInfo.gameInfo.ruleSet, link = it))
} ).colspan(2)
innerTable.row()
innerTable.add(getTileStatsTable(stats)).row()

View File

@ -28,7 +28,11 @@ object CivilopediaImageGetters {
}
TerrainType.TerrainFeature -> {
tileInfo.terrainFeatures.add(terrain.name)
tileInfo.baseTerrain = terrain.occursOn.lastOrNull() ?: Constants.grassland
tileInfo.baseTerrain =
if (terrain.occursOn.isEmpty() || terrain.occursOn.contains(Constants.grassland))
Constants.grassland
else
terrain.occursOn.lastOrNull()!!
}
else ->
tileInfo.baseTerrain = terrain.name

View File

@ -0,0 +1,112 @@
package com.unciv.ui.civilopedia
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.ui.utils.*
/** Represents a text line with optional linking capability.
* Special cases:
* - 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.
*/
class FormattedLine (
val text: String = "",
val link: String = "",
) {
// 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(),
// have no backing field, be `by lazy` or use @Transient, Thank you.
/** Link types that can be used for [FormattedLine.link] */
enum class LinkType {
None,
/** Link points to a Civilopedia entry in the form `category/item` **/
Internal,
/** Link opens as URL in external App - begins with `https://`, `http://` or `mailto:` **/
External
}
/** The type of the [link]'s destination */
val linkType: LinkType by lazy {
when {
link.hasProtocol() -> LinkType.External
link.isNotEmpty() -> LinkType.Internal
else -> LinkType.None
}
}
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() && link.isEmpty()
/** Extension: determines if a [String] looks like a link understood by the OS */
private fun String.hasProtocol() = startsWith("http://") || startsWith("https://") || startsWith("mailto:")
/**
* 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.
*/
fun render(labelWidth: Float): Actor {
val table = Table(CameraStageBaseScreen.skin)
if (textToDisplay.isNotEmpty()) {
val label = textToDisplay.toLabel()
label.wrap = labelWidth > 0f
if (labelWidth == 0f)
table.add(label)
else
table.add(label).width(labelWidth)
}
return table
}
}
/** Makes [renderer][render] available outside [ICivilopediaText] */
object MarkupRenderer {
private const val emptyLineHeight = 10f
private const val defaultPadding = 2.5f
/**
* 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 linkAction Delegate to call for internal links. Leave null to suppress linking.
*/
fun render(
lines: Collection<FormattedLine>,
labelWidth: Float = 0f,
linkAction: ((id: String) -> Unit)? = null
): Table {
val skin = CameraStageBaseScreen.skin
val table = Table(skin).apply { defaults().pad(defaultPadding).align(Align.left) }
for (line in lines) {
if (line.isEmpty()) {
table.add().padTop(emptyLineHeight).row()
continue
}
val actor = line.render(labelWidth)
if (line.linkType == FormattedLine.LinkType.Internal && linkAction != null)
actor.onClick {
linkAction(line.link)
}
else if (line.linkType == FormattedLine.LinkType.External)
actor.onClick {
Gdx.net.openURI(line.link)
}
if (labelWidth == 0f)
table.add(actor).row()
else
table.add(actor).width(labelWidth).row()
}
return table.apply { pack() }
}
}

View File

@ -1,12 +1,11 @@
package com.unciv.ui.pickerscreens
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextArea
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.ui.*
import com.badlogic.gdx.utils.Align
import com.badlogic.gdx.utils.Json
import com.unciv.JsonParser
@ -16,46 +15,137 @@ import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.translations.tr
import com.unciv.ui.utils.*
import com.unciv.ui.utils.UncivDateFormat.formatDate
import com.unciv.ui.utils.UncivDateFormat.parseDate
import com.unciv.ui.worldscreen.mainmenu.Github
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.HashMap
import kotlin.concurrent.thread
/**
* The Mod Management Screen - called only from [MainMenuScreen]
*/
// All picker screens auto-wrap the top table in a ScrollPane.
// Since we want the different parts to scroll separately, we disable the default ScrollPane, which would scroll everything at once.
class ModManagementScreen: PickerScreen(disableScroll = true) {
val modTable = Table().apply { defaults().pad(10f) }
val downloadTable = Table().apply { defaults().pad(10f) }
val modActionTable = Table().apply { defaults().pad(10f) }
private val modTable = Table().apply { defaults().pad(10f) }
private val scrollInstalledMods = ScrollPane(modTable)
private val downloadTable = Table().apply { defaults().pad(10f) }
private val scrollOnlineMods = ScrollPane(downloadTable)
private val modActionTable = Table().apply { defaults().pad(10f) }
val amountPerPage = 30
var lastSelectedButton: TextButton? = null
val modDescriptions: HashMap<String, String> = hashMapOf()
private var lastSelectedButton: Button? = null
private var lastSyncMarkedButton: Button? = null
private var selectedModName = ""
private var selectedAuthor = ""
// keep running count of mods fetched from online search for comparison to total count as reported by GitHub
private var downloadModCount = 0
// Description data from installed mods and online search
private val modDescriptionsInstalled: HashMap<String, String> = hashMapOf()
private val modDescriptionsOnline: HashMap<String, String> = hashMapOf()
private fun showModDescription(modName: String) {
val online = modDescriptionsOnline[modName] ?: ""
val installed = modDescriptionsInstalled[modName] ?: ""
val separator = if(online.isEmpty() || installed.isEmpty()) "" else "\n"
descriptionLabel.setText(online + separator + installed)
}
// Enable syncing entries in 'installed' and 'repo search ScrollPanes
private class ScrollToEntry(val y: Float, val height: Float, val button: Button)
private val installedScrollIndex = HashMap<String,ScrollToEntry>(30)
private val onlineScrollIndex = HashMap<String,ScrollToEntry>(30)
private var onlineScrollCurrentY = -1f
// cleanup - background processing needs to be stopped on exit and memory freed
private var runningSearchThread: Thread? = null
private var stopBackgroundTasks = false
override fun dispose() {
// make sure the worker threads will not continue trying their time-intensive job
runningSearchThread?.interrupt()
stopBackgroundTasks = true
super.dispose()
}
/** Helper class keeps references to decoration images of installed mods to enable dynamic visibility
* (actually we do not use isVisible but refill a container selectively which allows the aggregate height to adapt and the set to center vertically)
* @param container the table containing the indicators (one per mod, narrow, arranges up to three indicators vertically)
* @param visualImage image indicating _enabled as permanent visual mod_
* @param updatedImage image indicating _online mod has been updated_
*/
private class ModStateImages (
val container: Table,
isVisual: Boolean = false,
isUpdated: Boolean = false,
val visualImage: Image = ImageGetter.getImage("UnitPromotionIcons/Scouting"),
val updatedImage: Image = ImageGetter.getImage("OtherIcons/Mods")
) {
// mad but it's really initializing with the primary constructor parameter and not calling update()
var isVisual: Boolean = isVisual
set(value) { if(field!=value) { field = value; update() } }
var isUpdated: Boolean = isUpdated
set(value) { if(field!=value) { field = value; update() } }
private val spacer = Table().apply { width = 20f; height = 0f }
fun update() {
container.run {
clear()
if (isVisual) add(visualImage).row()
if (isUpdated) add(updatedImage).row()
if (!isVisual && !isUpdated) add(spacer)
pack()
}
}
}
private val modStateImages = HashMap<String,ModStateImages>(30)
init {
setDefaultCloseAction(MainMenuScreen())
refreshModTable()
refreshInstalledModTable()
topTable.add("Current mods".toLabel()).padRight(35f) // 35 = 10 default pad + 25 to compensate for permanent visual mod decoration icon
topTable.add("Downloadable mods".toLabel())
// topTable.add("Mod actions")
// Header row
topTable.add().expandX() // empty cols left and right for separator
topTable.add("Current mods".toLabel()).pad(5f).minWidth(200f).padLeft(25f)
// 30 = 5 default pad + 20 to compensate for 'permanent visual mod' decoration icon
topTable.add("Downloadable mods".toLabel()).pad(5f)
topTable.add("".toLabel()).minWidth(200f) // placeholder for "Mod actions"
topTable.add().expandX()
topTable.row()
topTable.add(ScrollPane(modTable)).pad(10f)
// horizontal separator looking like the SplitPane handle
val separator = Table(skin)
separator.background = skin.get("default-vertical", SplitPane.SplitPaneStyle::class.java).handle
topTable.add(separator).minHeight(3f).fillX().colspan(5).row()
downloadTable.add(getDownloadButton()).row()
tryDownloadPage(1)
topTable.add(ScrollPane(downloadTable))
// main row containing the three 'blocks' installed, online and information
topTable.add() // skip empty first column
topTable.add(scrollInstalledMods)
reloadOnlineMods()
topTable.add(scrollOnlineMods)
topTable.add(modActionTable)
}
fun tryDownloadPage(pageNum: Int) {
thread {
private fun reloadOnlineMods() {
onlineScrollCurrentY = -1f
downloadTable.clear()
onlineScrollIndex.clear()
downloadTable.add(getDownloadFromUrlButton()).padBottom(15f).row()
downloadTable.add("...".toLabel()).row()
tryDownloadPage(1)
}
/** background worker: querying GitHub for Mods (repos with 'unciv-mod' in its topics)
*
* calls itself for the next page of search results
*/
private fun tryDownloadPage(pageNum: Int) {
runningSearchThread = thread(name="GitHubSearch") {
val repoSearch: Github.RepoSearch
try {
repoSearch = Github.tryGetGithubReposWithTopic(amountPerPage, pageNum)!!
@ -63,83 +153,158 @@ class ModManagementScreen: PickerScreen(disableScroll = true) {
Gdx.app.postRunnable {
ToastPopup("Could not download mod list", this)
}
runningSearchThread = null
return@thread
}
Gdx.app.postRunnable {
// clear and hide last cell if it is the "..." indicator
val lastCell = downloadTable.cells.lastOrNull()
if (lastCell != null && lastCell.actor is Label && (lastCell.actor as Label).text.toString() == "...") {
lastCell.setActor<Actor>(null)
lastCell.pad(0f)
}
for (repo in repoSearch.items) {
if (stopBackgroundTasks) return@postRunnable
repo.name = repo.name.replace('-', ' ')
modDescriptions[repo.name] = repo.description + "\n" + "[${repo.stargazers_count}]✯".tr() +
if (modDescriptions.contains(repo.name))
"\n" + modDescriptions[repo.name]
else ""
modDescriptionsOnline[repo.name] =
(repo.description ?: "-{No description provided}-".tr()) +
"\n" + "[${repo.stargazers_count}]✯".tr()
var downloadButtonText = repo.name
val existingMod = RulesetCache.values.firstOrNull { it.name == repo.name }
if (existingMod != null) {
if (existingMod.modOptions.lastUpdated != "" && existingMod.modOptions.lastUpdated != repo.updated_at)
if (existingMod.modOptions.lastUpdated != "" && existingMod.modOptions.lastUpdated != repo.updated_at) {
downloadButtonText += " - {Updated}"
}
val downloadButton = downloadButtonText.toTextButton()
downloadButton.onClick {
lastSelectedButton?.color = Color.WHITE
downloadButton.color = Color.BLUE
lastSelectedButton = downloadButton
descriptionLabel.setText(modDescriptions[repo.name])
removeRightSideClickListeners()
rightSideButton.enable()
rightSideButton.setText("Download [${repo.name}]".tr())
rightSideButton.onClick {
rightSideButton.setText("Downloading...".tr())
rightSideButton.disable()
downloadMod(repo) {
rightSideButton.setText("Downloaded!".tr())
}
modStateImages[repo.name]?.isUpdated = true
}
if (existingMod.modOptions.author.isEmpty()) {
rewriteModOptions(repo, Gdx.files.local("mods").child(repo.name))
existingMod.modOptions.author = repo.owner.login
existingMod.modOptions.modSize = repo.size
}
}
val downloadButton = downloadButtonText.toTextButton()
downloadButton.onClick { onlineButtonAction(repo, downloadButton) }
modActionTable.clear()
addModInfoToActionTable(repo.html_url, repo.updated_at)
}
downloadTable.add(downloadButton).row()
val cell = downloadTable.add(downloadButton)
downloadTable.row()
if (onlineScrollCurrentY < 0f) onlineScrollCurrentY = cell.padTop
onlineScrollIndex[repo.name] = ScrollToEntry(onlineScrollCurrentY, cell.prefHeight, downloadButton)
onlineScrollCurrentY += cell.padBottom + cell.prefHeight + cell.padTop
downloadModCount++
}
if (repoSearch.items.size == amountPerPage) {
val nextPageButton = "Next page".toTextButton()
nextPageButton.onClick {
nextPageButton.remove()
tryDownloadPage(pageNum + 1)
// Now the tasks after the 'page' of search results has been fully processed
if (repoSearch.items.size < amountPerPage) {
// The search has reached the last page!
// Check: due to time passing between github calls it is not impossible we get a mod twice
val checkedMods: MutableSet<String> = mutableSetOf()
val duplicates: MutableList<Cell<Actor>> = mutableListOf()
downloadTable.cells.forEach {
cell->
cell.actor?.name?.apply {
if (checkedMods.contains(this)) {
duplicates.add(cell)
} else checkedMods.add(this)
}
}
downloadTable.add(nextPageButton).row()
duplicates.forEach {
it.setActor(null)
it.pad(0f) // the cell itself cannot be removed so stop it occupying height
}
downloadModCount -= duplicates.size
// Check: It is also not impossible we missed a mod - just inform user
if (repoSearch.total_count > downloadModCount || repoSearch.incomplete_results) {
val retryLabel = "Online query result is incomplete".toLabel(Color.RED)
retryLabel.touchable = Touchable.enabled
retryLabel.onClick { reloadOnlineMods() }
downloadTable.add(retryLabel)
}
} else {
// the page was full so there may be more pages.
// indicate that search will be continued
downloadTable.add("...".toLabel()).row()
}
downloadTable.pack()
// Shouldn't actor.parent.actor = actor be a no-op? No, it has side effects we need.
// See [commit for #3317](https://github.com/yairm210/Unciv/commit/315a55f972b8defe22e76d4a2d811c6e6b607e57)
(downloadTable.parent as ScrollPane).actor = downloadTable
// continue search unless last page was reached
if (repoSearch.items.size >= amountPerPage && !stopBackgroundTasks)
tryDownloadPage(pageNum + 1)
}
runningSearchThread = null
}
}
fun addModInfoToActionTable(repoUrl: String, updatedAt: String) {
private fun syncOnlineSelected(name: String, button: Button) {
syncSelected(name, button, installedScrollIndex, scrollInstalledMods)
}
private fun syncInstalledSelected(name: String, button: Button) {
syncSelected(name, button, onlineScrollIndex, scrollOnlineMods)
}
private fun syncSelected(name: String, button: Button, index: HashMap<String, ScrollToEntry>, scroll: ScrollPane) {
// manage selection color for user selection
lastSelectedButton?.color = Color.WHITE
button.color = Color.BLUE
lastSelectedButton = button
if (lastSelectedButton == lastSyncMarkedButton) lastSyncMarkedButton = null
// look for sync-able same mod in other list
val pos = index[name] ?: return
// scroll into view
scroll.scrollY = (pos.y + (pos.height - scroll.height) / 2).coerceIn(0f, scroll.maxY)
// and color it so it's easier to find. ROYAL and SLATE too dark.
lastSyncMarkedButton?.color = Color.WHITE
pos.button.color = Color.valueOf("7499ab") // about halfway between royal and sky
lastSyncMarkedButton = pos.button
}
/** Recreate the information part of the right-hand column
* @param repo: the repository instance as received from the GitHub api
*/
private fun addModInfoToActionTable(repo: Github.Repo) {
addModInfoToActionTable(repo.name, repo.html_url, repo.updated_at, repo.owner.login, repo.size)
}
/** Recreate the information part of the right-hand column
* @param modName: The mod name (name from the RuleSet)
* @param modOptions: The ModOptions as enriched by us with GitHub metadata when originally downloaded
*/
private fun addModInfoToActionTable(modName: String, modOptions: ModOptions) {
addModInfoToActionTable(modName, modOptions.modUrl, modOptions.lastUpdated, modOptions.author, modOptions.modSize)
}
private fun addModInfoToActionTable(modName: String, repoUrl: String, updatedAt: String, author: String, modSize: Int) {
// remember selected mod - for now needed only to display a background-fetched image while the user is watching
selectedModName = modName
selectedAuthor = author
// Display metadata
if (author.isNotEmpty())
modActionTable.add("Author: [$author]".toLabel()).row()
if (modSize > 0)
modActionTable.add("Size: [$modSize] kB".toLabel()).padBottom(15f).row()
// offer link to open the repo itself in a browser
if (repoUrl != "") {
modActionTable.add("Open Github page".toTextButton().onClick {
Gdx.net.openURI(repoUrl)
}).row()
}
if (updatedAt != "") {
// Everything under java.time is from Java 8 onwards, meaning older phones that use Java 7 won't be able to handle it :/
// So we're forced to use ancient Java 6 classes instead of the newer and nicer LocalDateTime.parse :(
// Direct solution from https://stackoverflow.com/questions/2201925/converting-iso-8601-compliant-string-to-java-util-date
val df2 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) // example: 2021-04-11T14:43:33Z
val date = df2.parse(updatedAt)
val updateString = "{Updated}: " +DateFormat.getDateInstance(DateFormat.SHORT).format(date)
modActionTable.add(updateString.toLabel())
// display "updated" date
if (updatedAt.isNotEmpty()) {
val date = updatedAt.parseDate()
val updateString = "{Updated}: " + date.formatDate()
modActionTable.add(updateString.toLabel()).row()
}
}
fun getDownloadButton(): TextButton {
/** Create the special "Download from URL" button */
private fun getDownloadFromUrlButton(): TextButton {
val downloadButton = "Download mod from URL".toTextButton()
downloadButton.onClick {
val popup = Popup(this)
@ -158,22 +323,42 @@ class ModManagementScreen: PickerScreen(disableScroll = true) {
return downloadButton
}
fun downloadMod(repo: Github.Repo, postAction: () -> Unit = {}) {
thread { // to avoid ANRs - we've learnt our lesson from previous download-related actions
/** Used as onClick handler for the online Mod list buttons */
private fun onlineButtonAction(repo: Github.Repo, button: Button) {
syncOnlineSelected(repo.name, button)
showModDescription(repo.name)
removeRightSideClickListeners()
rightSideButton.enable()
val label = if (modStateImages[repo.name]?.isUpdated == true)
"Update [${repo.name}]"
else "Download [${repo.name}]"
rightSideButton.setText(label.tr())
rightSideButton.onClick {
rightSideButton.setText("Downloading...".tr())
rightSideButton.disable()
downloadMod(repo) {
rightSideButton.setText("Downloaded!".tr())
}
}
modActionTable.clear()
addModInfoToActionTable(repo)
}
/** Download and install a mod in the background, called from the right-bottom button */
private fun downloadMod(repo: Github.Repo, postAction: () -> Unit = {}) {
thread(name="DownloadMod") { // to avoid ANRs - we've learnt our lesson from previous download-related actions
try {
val modFolder = Github.downloadAndExtract(repo.html_url, repo.default_branch,
Gdx.files.local("mods"))
if (modFolder == null) return@thread
// rewrite modOptions file
val modOptionsFile = modFolder.child("jsons/ModOptions.json")
val modOptions = if (modOptionsFile.exists()) JsonParser().getFromJson(ModOptions::class.java, modOptionsFile) else ModOptions()
modOptions.modUrl = repo.html_url
modOptions.lastUpdated = repo.updated_at
Json().toJson(modOptions, modOptionsFile)
Gdx.files.local("mods"))
?: return@thread
rewriteModOptions(repo, modFolder)
Gdx.app.postRunnable {
ToastPopup("Downloaded!", this)
RulesetCache.loadRulesets()
refreshModTable()
refreshInstalledModTable()
showModDescription(repo.name)
unMarkUpdatedMod(repo.name)
}
} catch (ex: Exception) {
Gdx.app.postRunnable {
@ -185,67 +370,125 @@ class ModManagementScreen: PickerScreen(disableScroll = true) {
}
}
fun refreshModActions(mod: Ruleset, decorationImage: Actor) {
/** Rewrite modOptions file for a mod we just installed to include metadata we got from the GitHub api
*
* (called on background thread)
*/
private fun rewriteModOptions(repo: Github.Repo, modFolder: FileHandle) {
val modOptionsFile = modFolder.child("jsons/ModOptions.json")
val modOptions = if (modOptionsFile.exists()) JsonParser().getFromJson(ModOptions::class.java, modOptionsFile) else ModOptions()
modOptions.modUrl = repo.html_url
modOptions.lastUpdated = repo.updated_at
modOptions.author = repo.owner.login
modOptions.modSize = repo.size
Json().toJson(modOptions, modOptionsFile)
}
/** Remove the visual indicators for an 'updated' mod after re-downloading it.
* (" - Updated" on the button text in the online mod list and the icon beside the installed mod's button)
* It should be up to date now (unless the repo's date is in the future relative to system time)
*
* (called under postRunnable posted by background thread)
*/
private fun unMarkUpdatedMod(name: String) {
modStateImages[name]?.isUpdated = false
val button = (onlineScrollIndex[name]?.button as? TextButton) ?: return
button.setText(name)
}
/** Rebuild the right-hand column for clicks on installed mods
* Display single mod metadata, offer additional actions (delete is elsewhere)
*/
private fun refreshModActions(mod: Ruleset) {
modActionTable.clear()
// show mod information first
addModInfoToActionTable(mod.name, mod.modOptions)
// offer 'permanent visual mod' toggle
val visualMods = game.settings.visualMods
if (!visualMods.contains(mod.name)) {
decorationImage.isVisible = false
val isVisual = visualMods.contains(mod.name)
modStateImages[mod.name]?.isVisual = isVisual
if (!isVisual) {
modActionTable.add("Enable as permanent visual mod".toTextButton().onClick {
visualMods.add(mod.name)
game.settings.save()
ImageGetter.setNewRuleset(ImageGetter.ruleset)
refreshModActions(mod, decorationImage)
refreshModActions(mod)
})
} else {
decorationImage.isVisible = true
modActionTable.add("Disable as permanent visual mod".toTextButton().onClick {
visualMods.remove(mod.name)
game.settings.save()
ImageGetter.setNewRuleset(ImageGetter.ruleset)
refreshModActions(mod, decorationImage)
refreshModActions(mod)
})
}
modActionTable.row()
addModInfoToActionTable(mod.modOptions.modUrl, mod.modOptions.lastUpdated)
}
fun refreshModTable() {
/** Rebuild the left-hand column containing all installed mods */
private fun refreshInstalledModTable() {
modTable.clear()
val currentMods = RulesetCache.values.filter { it.name != "" }
installedScrollIndex.clear()
var currentY = -1f
val currentMods = RulesetCache.values.asSequence().filter { it.name != "" }.sortedBy { it.name }
for (mod in currentMods) {
val summary = mod.getSummary()
modDescriptions[mod.name] = "Installed".tr() +
modDescriptionsInstalled[mod.name] = "Installed".tr() +
(if (summary.isEmpty()) "" else ": $summary")
val decorationImage = ImageGetter.getPromotionIcon("Scouting", 25f)
var imageMgr = modStateImages[mod.name]
val decorationTable =
if (imageMgr != null) imageMgr.container
else {
val table = Table().apply { defaults().size(20f).align(Align.topLeft) }
imageMgr = ModStateImages(table, isVisual = mod.name in game.settings.visualMods)
modStateImages[mod.name] = imageMgr
table
}
imageMgr.update() // rebuilds decorationTable content
val button = mod.name.toTextButton()
button.onClick {
lastSelectedButton?.color = Color.WHITE
button.color = Color.BLUE
lastSelectedButton = button
refreshModActions(mod, decorationImage)
syncInstalledSelected(mod.name, button)
refreshModActions(mod)
rightSideButton.setText("Delete [${mod.name}]".tr())
rightSideButton.enable()
descriptionLabel.setText(modDescriptions[mod.name])
rightSideButton.isEnabled = true
showModDescription(mod.name)
removeRightSideClickListeners()
rightSideButton.onClick {
YesNoPopup("Are you SURE you want to delete this mod?",
{ deleteMod(mod) }, this).open()
rightSideButton.isEnabled = false
YesNoPopup(
question = "Are you SURE you want to delete this mod?",
action = {
deleteMod(mod)
rightSideButton.setText("[${mod.name}] was deleted.".tr())
},
screen = this,
restoreDefault = { rightSideButton.isEnabled = true }
).open()
}
}
val decoratedButton = Table()
decoratedButton.add(button)
decorationImage.isVisible = game.settings.visualMods.contains(mod.name)
decoratedButton.add(decorationImage).align(Align.topLeft)
modTable.add(decoratedButton).row()
decoratedButton.add(decorationTable).align(Align.center+Align.left)
val cell = modTable.add(decoratedButton)
modTable.row()
if (currentY < 0f) currentY = cell.padTop
installedScrollIndex[mod.name] = ScrollToEntry(currentY, cell.prefHeight, button)
currentY += cell.padBottom + cell.prefHeight + cell.padTop
}
}
fun deleteMod(mod: Ruleset) {
/** Delete a Mod, refresh ruleset cache and update installed mod table */
private fun deleteMod(mod: Ruleset) {
val modFileHandle = Gdx.files.local("mods").child(mod.name)
if (modFileHandle.isDirectory) modFileHandle.deleteDirectory()
else modFileHandle.delete()
else modFileHandle.delete() // This should never happen
RulesetCache.loadRulesets()
refreshModTable()
modStateImages.remove(mod.name)
refreshInstalledModTable()
}
}

View File

@ -14,7 +14,7 @@ import com.unciv.logic.UncivShowableException
import com.unciv.models.translations.tr
import com.unciv.ui.pickerscreens.PickerScreen
import com.unciv.ui.utils.*
import java.text.SimpleDateFormat
import com.unciv.ui.utils.UncivDateFormat.formatDate
import java.util.*
import java.util.concurrent.CancellationException
import kotlin.concurrent.thread
@ -180,8 +180,7 @@ class LoadGameScreen(previousScreen:CameraStageBaseScreen) : PickerScreen(disabl
val savedAt = Date(save.lastModified())
var textToSet = save.name() +
"\n${"Saved at".tr()}: " + SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(savedAt)
var textToSet = save.name() + "\n${"Saved at".tr()}: " + savedAt.formatDate()
thread { // Even loading the game to get its metadata can take a long time on older phones
try {
val game = GameSaver.loadGamePreviewFromFile(save)

View File

@ -11,6 +11,7 @@ import com.unciv.logic.civilization.*
import com.unciv.logic.civilization.diplomacy.DiplomacyFlags
import com.unciv.logic.civilization.diplomacy.DiplomacyManager
import com.unciv.logic.civilization.diplomacy.DiplomaticModifiers.*
import com.unciv.logic.civilization.diplomacy.DiplomaticStatus
import com.unciv.logic.civilization.diplomacy.RelationshipLevel
import com.unciv.logic.trade.TradeLogic
import com.unciv.logic.trade.TradeOffer
@ -114,6 +115,12 @@ class DiplomacyScreen(val viewingCiv:CivilizationInfo):CameraStageBaseScreen() {
diplomacyTable.add(allyString.toLabel()).row()
}
val protectors = otherCiv.getProtectorCivs()
if (protectors.size > 0) {
val protectorString = "{Protected by}: " + protectors.map{it.civName}.joinToString(", ")
diplomacyTable.add(protectorString.toLabel()).row()
}
val nextLevelString = when {
otherCivDiplomacyManager.influence.toInt() < 30 -> "Reach 30 for friendship."
ally == viewingCiv.civName -> ""
@ -156,8 +163,32 @@ class DiplomacyScreen(val viewingCiv:CivilizationInfo):CameraStageBaseScreen() {
diplomacyTable.add(giftButton).row()
if (viewingCiv.gold < giftAmount || isNotPlayersTurn()) giftButton.disable()
val diplomacyManager = viewingCiv.getDiplomacyManager(otherCiv)
if (otherCivDiplomacyManager.diplomaticStatus == DiplomaticStatus.Protector){
val RevokeProtectionButton = "Revoke Protection".toTextButton()
RevokeProtectionButton.onClick{
YesNoPopup("Revoke protection for [${otherCiv.civName}]?".tr(), {
otherCiv.removeProtectorCiv(viewingCiv)
updateLeftSideTable()
updateRightSide(otherCiv)
}, this).open()
}
diplomacyTable.add(RevokeProtectionButton).row()
} else {
val ProtectionButton = "Pledge to protect".toTextButton()
ProtectionButton.onClick{
YesNoPopup("Declare Protection of [${otherCiv.civName}]?".tr(), {
otherCiv.addProtectorCiv(viewingCiv)
updateLeftSideTable()
updateRightSide(otherCiv)
}, this).open()
}
if(viewingCiv.isAtWarWith(otherCiv)) {
ProtectionButton.disable()
}
diplomacyTable.add(ProtectionButton).row()
}
val diplomacyManager = viewingCiv.getDiplomacyManager(otherCiv)
if (!viewingCiv.gameInfo.ruleSet.modOptions.uniques.contains(ModOptionsConstants.diplomaticRelationshipsCannotChange)) {
if (viewingCiv.isAtWarWith(otherCiv)) {
val peaceButton = "Negotiate Peace".toTextButton()

View File

@ -10,6 +10,8 @@ import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener
import com.unciv.models.UncivSound
import com.unciv.models.translations.tr
import java.text.SimpleDateFormat
import java.util.*
import kotlin.concurrent.thread
import kotlin.random.Random
@ -102,8 +104,7 @@ fun Table.addSeparator(): Cell<Image> {
fun Table.addSeparatorVertical(): Cell<Image> {
val image = ImageGetter.getWhiteDot()
val cell = add(image).width(2f).fillY()
return cell
return add(image).width(2f).fillY()
}
fun <T : Actor> Table.addCell(actor: T): Table {
@ -200,3 +201,28 @@ fun <T> List<T>.randomWeighted(weights: List<Float>, random: Random = Random): T
}
return this.last()
}
/**
* Standardize date formatting so dates are presented in a consistent style and all decisions
* to change date handling are encapsulated here
*/
object UncivDateFormat {
private val standardFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
/** Format a date to ISO format with minutes */
fun Date.formatDate(): String = standardFormat.format(this)
// Previously also used:
//val updateString = "{Updated}: " +DateFormat.getDateInstance(DateFormat.SHORT).format(date)
// Everything under java.time is from Java 8 onwards, meaning older phones that use Java 7 won't be able to handle it :/
// So we're forced to use ancient Java 6 classes instead of the newer and nicer LocalDateTime.parse :(
// Direct solution from https://stackoverflow.com/questions/2201925/converting-iso-8601-compliant-string-to-java-util-date
@Suppress("SpellCheckingInspection")
private val utcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
/** Parse an UTC date as passed by online API's
* example: `"2021-04-11T14:43:33Z".parseDate()`
*/
fun String.parseDate(): Date = utcFormat.parse(this)
}

View File

@ -2,22 +2,64 @@ package com.unciv.ui.utils
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.audio.Sound
import com.badlogic.gdx.files.FileHandle
import com.unciv.UncivGame
import com.unciv.models.UncivSound
import java.io.File
/**
* Generates Gdx [Sound] objects from [UncivSound] ones on demand, only once per key
* (two UncivSound custom instances with the same filename are considered equal).
*
* Gdx asks Sound usage to respect the Disposable contract, but since we're only caching
* a handful of them in memory we should be able to get away with keeping them alive for the
* app lifetime.
*/
object Sounds {
private val soundMap = HashMap<UncivSound, Sound>()
fun get(sound: UncivSound): Sound {
if (!soundMap.containsKey(sound)) {
soundMap[sound] = Gdx.audio.newSound(Gdx.files.internal("sounds/${sound.value}.mp3"))
private val soundMap = HashMap<UncivSound, Sound?>()
private val separator = File.separator // just a shorthand for readability
private var modListHash = Int.MIN_VALUE
/** Ensure cache is not outdated _and_ build list of folders to look for sounds */
private fun getFolders(): Sequence<String> {
if (!UncivGame.isCurrentInitialized() || !UncivGame.Current.isGameInfoInitialized()) // Allow sounds from main menu
return sequenceOf("")
// Allow mod sounds - preferentially so they can override built-in sounds
val modList = UncivGame.Current.gameInfo.ruleSet.mods
val newHash = modList.hashCode()
if (modListHash == Int.MIN_VALUE || modListHash != newHash) {
// Seems the mod list has changed - start over
for (sound in soundMap.values) sound?.dispose()
soundMap.clear()
modListHash = newHash
}
return soundMap[sound]!!
// Should we also look in UncivGame.Current.settings.visualMods?
return modList.asSequence()
.map { "mods$separator$it$separator" } +
sequenceOf("")
}
fun get(sound: UncivSound): Sound? {
if (sound in soundMap) return soundMap[sound]
val fileName = sound.value
var file: FileHandle? = null
for (modFolder in getFolders()) {
val path = "${modFolder}sounds$separator$fileName.mp3"
file = Gdx.files.internal(path)
if (file.exists()) break
}
val newSound =
if (file == null || !file.exists()) null
else Gdx.audio.newSound(file)
// Store Sound for reuse or remember that the actual file is missing
soundMap[sound] = newSound
return newSound
}
fun play(sound: UncivSound) {
val volume = UncivGame.Current.settings.soundEffectsVolume
if (sound == UncivSound.Silent || volume < 0.01) return
get(sound).play(volume)
get(sound)?.play(volume)
}
}

View File

@ -471,8 +471,15 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap
}
var blinkAction: Action? = null
fun setCenterPosition(vector: Vector2, immediately: Boolean = false, selectUnit: Boolean = true) {
val tileGroup = allWorldTileGroups.firstOrNull { it.tileInfo.position == vector } ?: return
/** Scrolls the world map to specified coordinates.
* @param vector Position to center on
* @param immediately Do so without animation
* @param selectUnit Select a unit at the destination
* @return `true` if scroll position was changed, `false` otherwise
*/
fun setCenterPosition(vector: Vector2, immediately: Boolean = false, selectUnit: Boolean = true): Boolean {
val tileGroup = allWorldTileGroups.firstOrNull { it.tileInfo.position == vector } ?: return false
selectedTile = tileGroup.tileInfo
if (selectUnit)
worldScreen.bottomUnitTable.tileSelected(selectedTile!!)
@ -487,6 +494,8 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap
// Here it's the same, only the Y axis is inverted - when at 0 we're at the top, not bottom - so we invert it back.
val finalScrollY = maxY - (tileGroup.y + tileGroup.width / 2 - height / 2)
if (finalScrollX == originalScrollX && finalScrollY == originalScrollY) return false
if (immediately) {
scrollX = finalScrollX
scrollY = finalScrollY
@ -513,6 +522,7 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap
addAction(blinkAction) // Don't set it on the group because it's an actionlss group
worldScreen.shouldUpdate = true
return true
}
override fun zoom(zoomScale: Float) {

View File

@ -207,7 +207,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() {
}
else {
attackButton.onClick {
attackButton.onClick(attacker.getAttackSound()) {
Battle.moveAndAttack(attacker, attackableTile)
worldScreen.mapHolder.removeUnitActionOverlay() // the overlay was one of attacking
worldScreen.shouldUpdate = true
@ -278,7 +278,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() {
attackButton.label.color = Color.GRAY
}
else {
attackButton.onClick {
attackButton.onClick(attacker.getAttackSound()) {
Battle.nuke(attacker, targetTile)
worldScreen.mapHolder.removeUnitActionOverlay() // the overlay was one of attacking
worldScreen.shouldUpdate = true

View File

@ -6,6 +6,8 @@ import com.badlogic.gdx.utils.Align
import com.unciv.UncivGame
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.TileInfo
import com.unciv.ui.civilopedia.CivilopediaScreen
import com.unciv.ui.civilopedia.MarkupRenderer
import com.unciv.ui.utils.CameraStageBaseScreen
import com.unciv.ui.utils.ImageGetter
import com.unciv.ui.utils.toLabel
@ -20,7 +22,9 @@ class TileInfoTable(private val viewingCiv :CivilizationInfo) : Table(CameraStag
if (tile != null && (UncivGame.Current.viewEntireMapForDebug || viewingCiv.exploredTiles.contains(tile.position)) ) {
add(getStatsTable(tile))
add(tile.toString(viewingCiv).toLabel()).colspan(2).pad(10f)
add( MarkupRenderer.render(tile.toMarkup(viewingCiv) ) {
UncivGame.Current.setScreen(CivilopediaScreen(viewingCiv.gameInfo.ruleSet, link = it))
} ).pad(10f)
// For debug only!
// add(tile.position.toString().toLabel()).colspan(2).pad(10f)
}
@ -40,4 +44,4 @@ class TileInfoTable(private val viewingCiv :CivilizationInfo) : Table(CameraStag
}
return table
}
}
}

View File

@ -1,6 +1,5 @@
package com.unciv.ui.worldscreen.mainmenu
import com.badlogic.gdx.files.FileHandle
import com.unciv.logic.GameInfo
import com.unciv.logic.GameSaver
import com.unciv.ui.saves.Gzip
@ -8,8 +7,6 @@ import java.io.*
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.Charset
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
object DropBox {
@ -19,6 +16,7 @@ object DropBox {
with(URL(url).openConnection() as HttpURLConnection) {
requestMethod = "POST" // default is GET
@Suppress("SpellCheckingInspection")
setRequestProperty("Authorization", "Bearer LTdBbopPUQ0AAAAAAAACxh4_Qd1eVMM7IBK3ULV3BgxzWZDMfhmgFbuUNF_rXQWb")
if (dropboxApiArg != "") setRequestProperty("Dropbox-API-Arg", dropboxApiArg)
@ -76,8 +74,7 @@ object DropBox {
fun downloadFileAsString(fileName: String): String {
val inputStream = downloadFile(fileName)
val text = BufferedReader(InputStreamReader(inputStream)).readText()
return text
return BufferedReader(InputStreamReader(inputStream)).readText()
}
fun uploadFile(fileName: String, data: String, overwrite: Boolean = false){
@ -98,13 +95,14 @@ object DropBox {
// return BufferedReader(InputStreamReader(result)).readText()
// }
@Suppress("PropertyName")
class FolderList{
var entries = ArrayList<FolderListEntry>()
var cursor = ""
var has_more = false
}
@Suppress("PropertyName")
class FolderListEntry{
var name=""
var path_display=""
@ -128,127 +126,10 @@ class OnlineMultiplayer {
/**
* WARNING!
* Does not initialize transitive GameInfo data.
* It is therefore stateless and save to call for Multiplayer Turn Notifier, unlike tryDownloadGame().
* It is therefore stateless and safe to call for Multiplayer Turn Notifier, unlike tryDownloadGame().
*/
fun tryDownloadGameUninitialized(gameId: String): GameInfo {
val zippedGameInfo = DropBox.downloadFileAsString(getGameLocation(gameId))
return GameSaver.gameInfoFromStringWithoutTransients(Gzip.unzip(zippedGameInfo))
}
}
object Github {
// Consider merging this with the Dropbox function
fun download(url: String, action: (HttpURLConnection) -> Unit = {}): InputStream? {
with(URL(url).openConnection() as HttpURLConnection)
{
action(this)
try {
return inputStream
} catch (ex: Exception) {
println(ex.message)
val reader = BufferedReader(InputStreamReader(errorStream))
println(reader.readText())
return null
}
}
}
// This took a long time to get just right, so if you're changing this, TEST IT THOROUGHLY on both Desktop and Phone
fun downloadAndExtract(gitRepoUrl:String, defaultBranch:String, folderFileHandle:FileHandle): FileHandle? {
val zipUrl = "$gitRepoUrl/archive/$defaultBranch.zip"
val inputStream = download(zipUrl)
if (inputStream == null) return null
val tempZipFileHandle = folderFileHandle.child("tempZip.zip")
tempZipFileHandle.write(inputStream, false)
val unzipDestination = tempZipFileHandle.sibling("tempZip") // folder, not file
Zip.extractFolder(tempZipFileHandle, unzipDestination)
val innerFolder = unzipDestination.list().first() // tempZip/<repoName>-master/
val finalDestinationName = innerFolder.name().replace("-$defaultBranch", "").replace('-', ' ')
val finalDestination = folderFileHandle.child(finalDestinationName)
finalDestination.mkdirs() // If we don't create this as a directory, it will think this is a file and nothing will work.
for (innerFileOrFolder in innerFolder.list()) {
innerFileOrFolder.moveTo(finalDestination)
}
tempZipFileHandle.delete()
unzipDestination.deleteDirectory()
return finalDestination
}
fun tryGetGithubReposWithTopic(amountPerPage:Int, page:Int): RepoSearch? {
// Default per-page is 30 - when we get to above 100 mods, we'll need to start search-queries
val inputStream = download("https://api.github.com/search/repositories?q=topic:unciv-mod&per_page=$amountPerPage&page=$page")
if (inputStream == null) return null
return GameSaver.json().fromJson(RepoSearch::class.java, inputStream.bufferedReader().readText())
}
class RepoSearch {
var items = ArrayList<Repo>()
}
class Repo {
var name = ""
var description = ""
var stargazers_count = 0
var default_branch = ""
var html_url = ""
var updated_at = ""
}
}
object Zip {
// I went through a lot of similar answers that didn't work until I got to this gem by NeilMonday
// (with mild changes to fit the FileHandles)
// https://stackoverflow.com/questions/981578/how-to-unzip-files-recursively-in-java
fun extractFolder(zipFile: FileHandle, unzipDestination: FileHandle) {
println(zipFile)
val BUFFER = 2048
val file = zipFile.file()
val zip = ZipFile(file)
unzipDestination.mkdirs()
val zipFileEntries = zip.entries()
// Process each entry
while (zipFileEntries.hasMoreElements()) {
// grab a zip file entry
val entry = zipFileEntries.nextElement() as ZipEntry
val currentEntry = entry.name
val destFile = unzipDestination.child(currentEntry)
val destinationParent = destFile.parent()
// create the parent directory structure if needed
destinationParent.mkdirs()
if (!entry.isDirectory) {
val inputStream = BufferedInputStream(zip
.getInputStream(entry))
var currentByte: Int
// establish buffer for writing file
val data = ByteArray(BUFFER)
// write the current file to disk
val fos = FileOutputStream(destFile.file())
val dest = BufferedOutputStream(fos,
BUFFER)
// read and write until last byte is encountered
while (inputStream.read(data, 0, BUFFER).also { currentByte = it } != -1) {
dest.write(data, 0, currentByte)
}
dest.flush()
dest.close()
inputStream.close()
}
if (currentEntry.endsWith(".zip")) {
// found a zip file, try to open
extractFolder(destFile, unzipDestination)
}
}
zip.close() // Needed so we can delete the zip file later
}
}

View File

@ -0,0 +1,336 @@
package com.unciv.ui.worldscreen.mainmenu
import com.badlogic.gdx.files.FileHandle
import com.unciv.logic.GameSaver
import java.io.*
import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Utility managing Github access (except the link in WorldScreenCommunityPopup)
*
* Singleton - RateLimit is shared app-wide and has local variables, and is not tested for thread safety.
* Therefore, additional effort is required should [tryGetGithubReposWithTopic] ever be called non-sequentially.
* [download] and [downloadAndExtract] should be thread-safe as they are self-contained.
* They do not join in the [RateLimit] handling because Github doc suggests each API
* has a separate limit (and I found none for cloning via a zip).
*/
object Github {
// Consider merging this with the Dropbox function
/**
* Helper opens am url and accesses its input stream, logging errors to the console
* @param url String representing a [URL] to download.
* @param action Optional callback that will be executed between opening the connection and
* accessing its data - passes the [connection][HttpURLConnection] and allows e.g. reading the response headers.
* @return The [InputStream] if successful, `null` otherwise.
*/
fun download(url: String, action: (HttpURLConnection) -> Unit = {}): InputStream? {
with(URL(url).openConnection() as HttpURLConnection)
{
action(this)
return try {
inputStream
} catch (ex: Exception) {
println(ex.message)
val reader = BufferedReader(InputStreamReader(errorStream))
println(reader.readText())
null
}
}
}
/**
* Download a mod and extract, deleting any pre-existing version.
* @param gitRepoUrl Url of the repository as delivered by the Github search query
* @param defaultBranch Branch name as delivered by the Github search query
* @param folderFileHandle Destination handle of mods folder - also controls Android internal/external
* @author **Warning**: This took a long time to get just right, so if you're changing this, ***TEST IT THOROUGHLY*** on _both_ Desktop _and_ Phone
* @return FileHandle for the downloaded Mod's folder or null if download failed
*/
fun downloadAndExtract(
gitRepoUrl: String,
defaultBranch: String,
folderFileHandle: FileHandle
): FileHandle? {
// Initiate download - the helper returns null when it fails
val zipUrl = "$gitRepoUrl/archive/$defaultBranch.zip"
val inputStream = download(zipUrl) ?: return null
// Download to temporary zip
val tempZipFileHandle = folderFileHandle.child("tempZip.zip")
tempZipFileHandle.write(inputStream, false)
// prepare temp unpacking folder
val unzipDestination = tempZipFileHandle.sibling("tempZip") // folder, not file
// prevent mixing new content with old - hopefully there will never be cadavers of our tempZip stuff
if (unzipDestination.exists())
if (unzipDestination.isDirectory) unzipDestination.deleteDirectory() else unzipDestination.delete()
Zip.extractFolder(tempZipFileHandle, unzipDestination)
val innerFolder = unzipDestination.list().first()
// innerFolder should now be "tempZip/$repoName-$defaultBranch/" - use this to get mod name
val finalDestinationName = innerFolder.name().replace("-$defaultBranch", "").replace('-', ' ')
// finalDestinationName is now the mod name as we display it. Folder name needs to be identical.
val finalDestination = folderFileHandle.child(finalDestinationName)
// prevent mixing new content with old
var tempBackup: FileHandle? = null
if (finalDestination.exists()) {
tempBackup = finalDestination.sibling("$finalDestinationName.updating")
finalDestination.moveTo(tempBackup)
}
// Move temp unpacked content to their final place
finalDestination.mkdirs() // If we don't create this as a directory, it will think this is a file and nothing will work.
// The move will reset the last modified time (recursively, at least on Linux)
// This sort will guarantee the desktop launcher will not re-pack textures and overwrite the atlas as delivered by the mod
for (innerFileOrFolder in innerFolder.list()
.sortedBy { file -> file.extension() == "atlas" } ) {
innerFileOrFolder.moveTo(finalDestination)
}
// clean up
tempZipFileHandle.delete()
unzipDestination.deleteDirectory()
if (tempBackup != null)
if (tempBackup.isDirectory) tempBackup.deleteDirectory() else tempBackup.delete()
return finalDestination
}
/**
* Implements the ability wo work with GitHub's rate limit, recognize blocks from previous attempts, wait and retry.
*/
object RateLimit {
// https://docs.github.com/en/rest/reference/search#rate-limit
const val maxRequestsPerInterval = 10
const val intervalInMilliSeconds = 60000L
private const val maxWaitLoop = 3
private var account = 0 // used requests
private var firstRequest = 0L // timestamp window start (java epoch millisecond)
/*
Github rate limits do not use sliding windows - you (if anonymous) get one window
which starts with the first request (if a window is not already active)
and ends 60s later, and a budget of 10 requests in that window. Once it expires,
everything is forgotten and the process starts from scratch
*/
private val millis: Long
get() = System.currentTimeMillis()
/** calculate required wait in ms
* @return Estimated number of milliseconds to wait for the rate limit window to expire
*/
private fun getWaitLength()
= (firstRequest + intervalInMilliSeconds - millis)
/** Maintain and check a rate-limit
* @return **true** if rate-limited, **false** if another request is allowed
*/
private fun isLimitReached(): Boolean {
val now = millis
val elapsed = if (firstRequest == 0L) intervalInMilliSeconds else now - firstRequest
if (elapsed >= intervalInMilliSeconds) {
firstRequest = now
account = 1
return false
}
if (account >= maxRequestsPerInterval) return true
account++
return false
}
/** If rate limit in effect, sleep long enough to allow next request.
*
* @return **true** if waiting did not clear isLimitReached() (can only happen if the clock is broken),
* or the wait has been interrupted by Thread.interrupt()
* **false** if we were below the limit or slept long enough to drop out of it.
*/
fun waitForLimit(): Boolean {
var loopCount = 0
while (isLimitReached()) {
val waitLength = getWaitLength()
try {
Thread.sleep(waitLength)
} catch ( ex: InterruptedException ) {
return true
}
if (++loopCount >= maxWaitLoop) return true
}
return false
}
/** http responses should be passed to this so the actual rate limit window can be evaluated and used.
* The very first response and all 403 ones are good candidates if they can be expected to contain GitHub's rate limit headers.
*
* see: https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
*/
fun notifyHttpResponse(response: HttpURLConnection) {
if (response.responseMessage != "rate limit exceeded" && response.responseCode != 200) return
fun getHeaderLong(name: String, default: Long = 0L) =
response.headerFields[name]?.get(0)?.toLongOrNull() ?: default
val limit = getHeaderLong("X-RateLimit-Limit", maxRequestsPerInterval.toLong()).toInt()
val remaining = getHeaderLong("X-RateLimit-Remaining").toInt()
val reset = getHeaderLong("X-RateLimit-Reset")
if (limit != maxRequestsPerInterval)
println("GitHub API Limit reported via http ($limit) not equal assumed value ($maxRequestsPerInterval)")
account = maxRequestsPerInterval - remaining
if (reset == 0L) return
firstRequest = (reset + 1L) * 1000L - intervalInMilliSeconds
}
}
/**
* Query GitHub for repositories marked "unciv-mod"
* @param amountPerPage Number of search results to return for this request.
* @param page The "page" number, starting at 1.
* @return Parsed [RepoSearch] json on success, `null` on failure.
* @see <a href="https://docs.github.com/en/rest/reference/search#search-repositories">Github API doc</a>
*/
fun tryGetGithubReposWithTopic(amountPerPage:Int, page:Int): RepoSearch? {
val link = "https://api.github.com/search/repositories?q=topic:unciv-mod&sort:stars&per_page=$amountPerPage&page=$page"
var retries = 2
while (retries > 0) {
retries--
// obey rate limit
if (RateLimit.waitForLimit()) return null
// try download
val inputStream = download(link) {
if (it.responseCode == 403 || it.responseCode == 200 && page == 1 && retries == 1) {
// Pass the response headers to the rate limit handler so it can process the rate limit headers
RateLimit.notifyHttpResponse(it)
retries++ // An extra retry so the 403 is ignored in the retry count
}
} ?: continue
return GameSaver.json().fromJson(RepoSearch::class.java, inputStream.bufferedReader().readText())
}
return null
}
/**
* Parsed GitHub repo search response
* @property total_count Total number of hits for the search (ignoring paging window)
* @property incomplete_results A flag set by github to indicate search was incomplete (never seen it on)
* @property items Array of [repositories][Repo]
* @see <a href="https://docs.github.com/en/rest/reference/search#search-repositories--code-samples">Github API doc</a>
*/
@Suppress("PropertyName")
class RepoSearch {
var total_count = 0
var incomplete_results = false
var items = ArrayList<Repo>()
}
/** Part of [RepoSearch] in Github API response - one repository entry in [items][RepoSearch.items] */
@Suppress("PropertyName")
class Repo {
var name = ""
var full_name = ""
var description: String? = null
var owner = RepoOwner()
var stargazers_count = 0
var default_branch = ""
var html_url = ""
var updated_at = ""
//var pushed_at = "" // if > updated_at might indicate an update soon?
var size = 0
//var stargazers_url = ""
//var homepage: String? = null // might use instead of go to repo?
//var has_wiki = false // a wiki could mean proper documentation for the mod?
}
/** Part of [Repo] in Github API response */
@Suppress("PropertyName")
class RepoOwner {
var login = ""
var avatar_url: String? = null
}
}
/** Utility - extract Zip archives
* @see [Zip.extractFolder]
*/
object Zip {
private const val bufferSize = 2048
/**
* Extract one Zip file recursively (nested Zip files are extracted in turn).
*
* The source Zip is not deleted, but successfully extracted nested ones are.
*
* **Warning**: Extracting into a non-empty destination folder will merge contents. Existing
* files also included in the archive will be partially overwritten, when the new data is shorter
* than the old you will get _mixed contents!_
*
* @param zipFile The Zip file to extract
* @param unzipDestination The folder to extract into, preferably empty (not enforced).
*/
fun extractFolder(zipFile: FileHandle, unzipDestination: FileHandle) {
// I went through a lot of similar answers that didn't work until I got to this gem by NeilMonday
// (with mild changes to fit the FileHandles)
// https://stackoverflow.com/questions/981578/how-to-unzip-files-recursively-in-java
println("Extracting $zipFile to $unzipDestination")
// establish buffer for writing file
val data = ByteArray(bufferSize)
fun streamCopy(fromStream: InputStream, toHandle: FileHandle) {
val inputStream = BufferedInputStream(fromStream)
var currentByte: Int
// write the current file to disk
val fos = FileOutputStream(toHandle.file())
val dest = BufferedOutputStream(fos, bufferSize)
// read and write until last byte is encountered
while (inputStream.read(data, 0, bufferSize).also { currentByte = it } != -1) {
dest.write(data, 0, currentByte)
}
dest.flush()
dest.close()
inputStream.close()
}
val file = zipFile.file()
val zip = ZipFile(file)
//unzipDestination.mkdirs()
val zipFileEntries = zip.entries()
// Process each entry
while (zipFileEntries.hasMoreElements()) {
// grab a zip file entry
val entry = zipFileEntries.nextElement() as ZipEntry
val currentEntry = entry.name
val destFile = unzipDestination.child(currentEntry)
val destinationParent = destFile.parent()
// create the parent directory structure if needed
destinationParent.mkdirs()
if (!entry.isDirectory) {
streamCopy ( zip.getInputStream(entry), destFile)
}
// The new file has a current last modification time
// and not the one stored in the archive - we could:
// 'destFile.file().setLastModified(entry.time)'
// but later handling will throw these away anyway,
// and GitHub sets all timestamps to the download time.
if (currentEntry.endsWith(".zip")) {
// found a zip file, try to open
extractFolder(destFile, destinationParent)
destFile.delete()
}
}
zip.close() // Needed so we can delete the zip file later
}
}

View File

@ -181,7 +181,7 @@ class OptionsPopup(val previousScreen:CameraStageBaseScreen) : Popup(previousScr
settings.minimapSize = size
}
settings.save()
Sounds.play(UncivSound.Click)
Sounds.play(UncivSound.Slider)
if (previousScreen is WorldScreen)
previousScreen.shouldUpdate = true
}
@ -276,7 +276,7 @@ class OptionsPopup(val previousScreen:CameraStageBaseScreen) : Popup(previousScr
soundEffectsVolumeSlider.onChange {
settings.soundEffectsVolume = soundEffectsVolumeSlider.value
settings.save()
Sounds.play(UncivSound.Click)
Sounds.play(UncivSound.Slider)
}
optionsTable.add(soundEffectsVolumeSlider).pad(5f).row()
}

View File

@ -13,6 +13,8 @@ import com.unciv.logic.city.CityInfo
import com.unciv.logic.map.MapUnit
import com.unciv.logic.map.TileInfo
import com.unciv.models.translations.tr
import com.unciv.ui.civilopedia.CivilopediaCategories
import com.unciv.ui.civilopedia.CivilopediaScreen
import com.unciv.ui.pickerscreens.PromotionPickerScreen
import com.unciv.ui.utils.*
import com.unciv.ui.worldscreen.WorldScreen
@ -78,7 +80,9 @@ class UnitTable(val worldScreen: WorldScreen) : Table(){
touchable = Touchable.enabled
onClick {
selectedUnit?.currentTile?.position?.let {
worldScreen.mapHolder.setCenterPosition(it, false, false)
if ( !worldScreen.mapHolder.setCenterPosition(it, false, false) && selectedUnit != null ) {
worldScreen.game.setScreen(CivilopediaScreen(worldScreen.gameInfo.ruleSet, CivilopediaCategories.Unit, selectedUnit!!.name))
}
}
}
}).expand()

View File

@ -57,6 +57,8 @@ internal object DesktopLauncher {
LwjglApplication(game, config)
}
// Work in Progress?
@Suppress("unused")
private fun startMultiplayerServer() {
// val games = HashMap<String, GameSetupInfo>()
val files = HashMap<String, String>()
@ -115,7 +117,7 @@ internal object DesktopLauncher {
// https://github.com/yairm210/UnCiv/issues/1340
/**
* These should be as big as possible in order to accommodate ALL the images together in one bug file.
* These should be as big as possible in order to accommodate ALL the images together in one big file.
* Why? Because the rendering function of the main screen renders all the images consecutively, and every time it needs to switch between textures,
* this causes a delay, leading to horrible lag if there are enough switches.
* The cost of this specific solution is that the entire game.png needs be be kept in-memory constantly.
@ -163,14 +165,14 @@ internal object DesktopLauncher {
private fun packImagesIfOutdated(settings: TexturePacker.Settings, input: String, output: String, packFileName: String) {
fun File.listTree(): Sequence<File> = when {
this.isFile -> sequenceOf(this)
this.isDirectory -> this.listFiles().asSequence().flatMap { it.listTree() }
this.isDirectory -> this.listFiles()!!.asSequence().flatMap { it.listTree() }
else -> sequenceOf()
}
val atlasFile = File("$output${File.separator}$packFileName.atlas")
if (atlasFile.exists() && File("$output${File.separator}$packFileName.png").exists()) {
val atlasModTime = atlasFile.lastModified()
if (!File(input).listTree().any { it.extension in listOf("png", "jpg", "jpeg") && it.lastModified() > atlasModTime }) return
if (File(input).listTree().none { it.extension in listOf("png", "jpg", "jpeg") && it.lastModified() > atlasModTime }) return
}
TexturePacker.process(settings, input, output, packFileName)

View File

@ -103,6 +103,7 @@ Unless otherwise specified, all the following are from [the Noun Project](https:
* [Manhattan Project](https://thenounproject.com/search/?q=Nuclear%20Bomb&i=2041074) By corpus delicti, GR
* [Nuclear Missile](https://thenounproject.com/marialuisa.iborra/collection/missiles-bombs/?i=1022574) By Lluisa Iborra, ES
* Icon for Carrier made by [JackRainy](https://github.com/JackRainy), based on [Aircraft Carrier](https://thenounproject.com/icolabs/collection/flat-icons-transport/?i=2332914) By IcoLabs, BR
* [Water Gun](https://thenounproject.com/term/water-gun/2121571) by ProSymbols for Marine
### Great People
@ -501,6 +502,7 @@ Unless otherwise specified, all the following are from [the Noun Project](https:
* Icon for Flight Deck is made by [JackRainy](https://github.com/JackRainy)
* Icon for Armor Plating is made by [JackRainy](https://github.com/JackRainy)
* [Slingshot](https://thenounproject.com/term/slingshot/9106/) by James Keuning for Slinger Withdraw
* [Anchor](https://thenounproject.com/term/anchor/676586) by Gregor Cresnar for Amphibious
## Others
@ -560,6 +562,11 @@ Sounds are from FreeSound.org and are either Creative Commons or Public Domain
* [Horse Neigh 2](https://freesound.org/people/GoodListener/sounds/322450/) By GoodListener as 'horse' for cavalry attack sounds
* [machine gun 001 - loop](https://freesound.org/people/pgi/sounds/212602/) By pgi as 'machinegun' for machine gun attack sound
* [uzzi_full_single](https://freesound.org/people/Deganoth/sounds/348685/) By Deganoth as 'shot' for bullet attacks
* [Grenade Launcher 2](https://soundbible.com/2140-Grenade-Launcher-2.html) By Daniel Simon as city bombard sound (CC Attribution 3.0 license)
* [Woosh](https://soundbible.com/2068-Woosh.html) by Mark DiAngelo as 'slider' sound (CC Attribution 3.0 license)
* [Tornado-Siren-II](https://soundbible.com/1937-Tornado-Siren-II.html) by Delilah as part of 'nuke' sound (CC Attribution 3.0 license)
* [Explosion-Ultra-Bass](https://soundbible.com/1807-Explosion-Ultra-Bass.html) by Mark DiAngelo as part of 'nuke' sound (CC Attribution 3.0 license)
* [Short Choir](https://freesound.org/people/Breviceps/sounds/444491/) by Breviceps as 'choir' for free great person pick
# Music