mirror of
https://github.com/yairm210/Unciv.git
synced 2025-01-20 09:17:47 +07:00
Merge branch 'master' of https://github.com/yairm210/Unciv
This commit is contained in:
commit
876bdf8f30
BIN
android/Images/UnitPromotionIcons/Amphibious.png
Normal file
BIN
android/Images/UnitPromotionIcons/Amphibious.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 KiB |
BIN
android/ImagesToPackSeparately/UnitIcons/Marine.png
Normal file
BIN
android/ImagesToPackSeparately/UnitIcons/Marine.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
@ -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 |
@ -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"
|
||||
},
|
||||
|
@ -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]%)"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
||||
|
BIN
android/assets/sounds/bombard.mp3
Normal file
BIN
android/assets/sounds/bombard.mp3
Normal file
Binary file not shown.
BIN
android/assets/sounds/construction.mp3
Normal file
BIN
android/assets/sounds/construction.mp3
Normal file
Binary file not shown.
BIN
android/assets/sounds/nuke.mp3
Normal file
BIN
android/assets/sounds/nuke.mp3
Normal file
Binary file not shown.
BIN
android/assets/sounds/slider.mp3
Normal file
BIN
android/assets/sounds/slider.mp3
Normal file
Binary file not shown.
@ -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()
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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 */
|
||||
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -34,6 +34,8 @@ class ModOptions {
|
||||
|
||||
var lastUpdated = ""
|
||||
var modUrl = ""
|
||||
var author = ""
|
||||
var modSize = 0
|
||||
}
|
||||
|
||||
class Ruleset {
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
112
core/src/com/unciv/ui/civilopedia/CivilopediaText.kt
Normal file
112
core/src/com/unciv/ui/civilopedia/CivilopediaText.kt
Normal 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() }
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
336
core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt
Normal file
336
core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt
Normal 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
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user