diff --git a/android/Images/UnitPromotionIcons/Amphibious.png b/android/Images/UnitPromotionIcons/Amphibious.png new file mode 100644 index 0000000000..fc53a07120 Binary files /dev/null and b/android/Images/UnitPromotionIcons/Amphibious.png differ diff --git a/android/ImagesToPackSeparately/UnitIcons/Marine.png b/android/ImagesToPackSeparately/UnitIcons/Marine.png new file mode 100644 index 0000000000..f631598ad9 Binary files /dev/null and b/android/ImagesToPackSeparately/UnitIcons/Marine.png differ diff --git a/android/assets/UnitIcons.atlas b/android/assets/UnitIcons.atlas index dbe74093ff..9136cc5b86 100644 --- a/android/assets/UnitIcons.atlas +++ b/android/assets/UnitIcons.atlas @@ -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 diff --git a/android/assets/UnitIcons.png b/android/assets/UnitIcons.png index 7636a30274..ba9e72ac87 100644 Binary files a/android/assets/UnitIcons.png and b/android/assets/UnitIcons.png differ diff --git a/android/assets/game.atlas b/android/assets/game.atlas index e8763e1340..04f3d4b5fd 100644 --- a/android/assets/game.atlas +++ b/android/assets/game.atlas @@ -6,14 +6,14 @@ filter: MipMapLinearLinear, MipMapLinearLinear repeat: none EmojiIcons/Production rotate: false - xy: 1224, 580 + xy: 1276, 580 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 EmojiIcons/Turn rotate: false - xy: 1688, 600 + xy: 1940, 1318 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -158,20 +158,6 @@ ImprovementIcons/Quarry orig: 100, 100 offset: 0, 0 index: -1 -ImprovementIcons/Railroad - rotate: false - xy: 1224, 1040 - size: 100, 100 - orig: 100, 100 - offset: 0, 0 - index: -1 -TileSets/Default/Railroad - rotate: false - xy: 1224, 1040 - size: 100, 100 - orig: 100, 100 - offset: 0, 0 - index: -1 ImprovementIcons/Road rotate: false xy: 1326, 836 @@ -384,7 +370,7 @@ OtherIcons/Aircraft index: -1 OtherIcons/BackArrow rotate: false - xy: 510, 1529 + xy: 1938, 1422 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -454,7 +440,7 @@ OtherIcons/DisbandUnit index: -1 OtherIcons/Down rotate: false - xy: 1582, 868 + xy: 1582, 816 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -608,7 +594,7 @@ OtherIcons/Triangle index: -1 OtherIcons/Up rotate: false - xy: 1940, 1266 + xy: 1940, 1214 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -629,273 +615,273 @@ OtherIcons/whiteDot index: -1 PolicyIcons/Aristocracy rotate: false - xy: 1632, 964 + xy: 1734, 1066 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Citizenship rotate: false - xy: 1428, 656 + xy: 1888, 1270 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Civil Society rotate: false - xy: 1888, 1270 + xy: 1888, 1218 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Collective Rule rotate: false - xy: 1888, 1166 + xy: 1480, 760 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Constitution rotate: false - xy: 1480, 760 + xy: 1480, 708 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Democracy rotate: false - xy: 1480, 656 + xy: 1582, 868 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Entrepreneurship rotate: false - xy: 1684, 964 + xy: 1786, 1066 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Fascism rotate: false - xy: 1532, 764 + xy: 1532, 712 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Free Religion rotate: false - xy: 1584, 764 + xy: 1584, 712 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Free Speech rotate: false - xy: 1584, 712 + xy: 1584, 660 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Free Thought rotate: false - xy: 1584, 660 + xy: 206, 530 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Humanism rotate: false - xy: 206, 374 + xy: 206, 322 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Landed Elite rotate: false - xy: 206, 166 + xy: 206, 114 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Legalism rotate: false - xy: 206, 114 + xy: 206, 62 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Mandate Of Heaven rotate: false - xy: 258, 530 + xy: 258, 478 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Mercantilism rotate: false - xy: 258, 374 + xy: 258, 322 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Meritocracy rotate: false - xy: 258, 322 + xy: 258, 270 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Militarism rotate: false - xy: 258, 270 + xy: 258, 218 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Military Caste rotate: false - xy: 258, 218 + xy: 258, 166 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Military Tradition rotate: false - xy: 258, 166 + xy: 258, 114 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Monarchy rotate: false - xy: 258, 62 + xy: 1428, 604 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Oligarchy rotate: false - xy: 1532, 608 + xy: 1584, 608 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Organized Religion rotate: false - xy: 1584, 608 + xy: 1990, 1474 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Patronage rotate: false - xy: 1990, 1474 + xy: 1990, 1422 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Police State rotate: false - xy: 1990, 1422 + xy: 1990, 1370 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Populism rotate: false - xy: 1990, 1370 + xy: 1224, 580 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Professional Army rotate: false - xy: 1276, 580 + xy: 1328, 580 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Protectionism rotate: false - xy: 1328, 580 + xy: 65, 2 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Reformation rotate: false - xy: 169, 2 + xy: 221, 10 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Representation rotate: false - xy: 273, 10 + xy: 1634, 912 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Republic rotate: false - xy: 1634, 912 + xy: 1634, 860 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Scientific Revolution rotate: false - xy: 1634, 860 + xy: 1686, 912 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Secularism rotate: false - xy: 1686, 860 + xy: 1636, 808 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Sovereignty rotate: false - xy: 1636, 652 + xy: 1688, 808 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Theocracy rotate: false - xy: 1688, 704 + xy: 1688, 652 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Total War rotate: false - xy: 1688, 652 + xy: 1636, 600 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Trade Unions rotate: false - xy: 1636, 600 + xy: 1688, 600 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Universal Suffrage rotate: false - xy: 1940, 1318 + xy: 1940, 1266 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Warrior Code rotate: false - xy: 1992, 1318 + xy: 1992, 1266 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -1147,7 +1133,7 @@ StatIcons/Happiness index: -1 StatIcons/InterceptRange rotate: false - xy: 206, 270 + xy: 206, 218 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -1161,7 +1147,7 @@ StatIcons/Malcontent index: -1 StatIcons/Movement rotate: false - xy: 1480, 604 + xy: 1532, 608 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -1182,14 +1168,14 @@ StatIcons/Production index: -1 StatIcons/Range rotate: false - xy: 65, 2 + xy: 117, 2 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 StatIcons/RangedStrength rotate: false - xy: 117, 2 + xy: 169, 2 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -1217,7 +1203,7 @@ StatIcons/Specialist index: -1 StatIcons/Strength rotate: false - xy: 1688, 808 + xy: 1688, 756 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -1644,7 +1630,7 @@ TechIcons/Radio index: -1 TechIcons/Railroad rotate: false - xy: 1326, 1142 + xy: 1224, 1040 size: 100, 100 orig: 100, 100 offset: 0, 0 @@ -1866,44 +1852,58 @@ TileSets/Default/OasisOverlay orig: 100, 100 offset: 0, 0 index: -1 +TileSets/Default/Railroad + rotate: false + xy: 1326, 1142 + size: 100, 100 + orig: 100, 100 + offset: 0, 0 + index: -1 +ImprovementIcons/Railroad + rotate: false + xy: 1326, 1142 + size: 100, 100 + orig: 100, 100 + offset: 0, 0 + index: -1 TileSets/Default/Tiles/River-Bottom rotate: false - xy: 412, 334 + xy: 1270, 490 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/River-Bottom rotate: false - xy: 412, 334 + xy: 1270, 490 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/Default/Tiles/River-BottomLeft rotate: false - xy: 446, 368 + xy: 1304, 482 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/River-BottomLeft rotate: false - xy: 446, 368 + xy: 1304, 482 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/Default/Tiles/River-BottomRight rotate: false - xy: 412, 304 + xy: 1338, 460 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/River-BottomRight rotate: false - xy: 412, 304 + xy: 1338, 460 size: 32, 28 orig: 32, 28 offset: 0, 0 @@ -1938,21 +1938,21 @@ TileSets/FantasyHex/Tiles/Academy index: -1 TileSets/FantasyHex/Tiles/Academy-Snow rotate: false - xy: 1836, 1129 + xy: 1380, 595 size: 32, 35 orig: 32, 35 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Aluminum rotate: false - xy: 2, 11 + xy: 1836, 1136 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ancient ruins rotate: false - xy: 1940, 1184 + xy: 1992, 1184 size: 32, 28 orig: 32, 28 offset: 0, 0 @@ -1966,14 +1966,14 @@ TileSets/FantasyHex/Tiles/Ancient ruins-Jungle index: -1 TileSets/FantasyHex/Tiles/Ancient ruins-Sand rotate: false - xy: 1974, 1184 + xy: 1802, 1036 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ancient ruins-Snow rotate: false - xy: 1802, 1036 + xy: 1904, 1136 size: 32, 28 orig: 32, 28 offset: 0, 0 @@ -1987,1806 +1987,1806 @@ TileSets/FantasyHex/Tiles/Ancient ruins2 index: -1 TileSets/FantasyHex/Tiles/Atoll rotate: false - xy: 2008, 1154 + xy: 1738, 908 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Bananas rotate: false - xy: 1872, 1102 + xy: 1770, 976 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Barbarian encampment rotate: false - xy: 1872, 1072 + xy: 1804, 1006 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Barbarian encampment-Snow rotate: false - xy: 1906, 1105 + xy: 1804, 975 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Barringer Crater rotate: false - xy: 1906, 1075 + xy: 2013, 1802 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Cattle rotate: false - xy: 2013, 1742 + xy: 1550, 578 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Cattle+Pasture rotate: false - xy: 2013, 1708 + xy: 1584, 574 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Cerro de Potosi rotate: false - xy: 2013, 1648 + xy: 1740, 818 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Citadel rotate: false - xy: 2013, 1551 + xy: 1740, 721 size: 32, 35 orig: 32, 35 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Citadel-Snow rotate: false - xy: 1940, 1122 + xy: 1740, 689 size: 32, 30 orig: 32, 30 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center rotate: false - xy: 1940, 1085 + xy: 1740, 652 size: 32, 35 orig: 32, 35 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Ancient era rotate: false - xy: 1974, 1120 + xy: 1740, 618 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Classical era rotate: false - xy: 1974, 1086 + xy: 1740, 584 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Future era rotate: false - xy: 2008, 1118 + xy: 284, 642 size: 32, 34 orig: 32, 34 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Industrial era rotate: false - xy: 2008, 1083 + xy: 318, 643 size: 32, 33 orig: 32, 33 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Information era rotate: false - xy: 1740, 840 + xy: 352, 640 size: 32, 36 orig: 32, 36 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Medieval era rotate: false - xy: 1740, 806 + xy: 288, 608 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Modern era rotate: false - xy: 1740, 770 + xy: 386, 640 size: 32, 34 orig: 32, 34 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Renaissance era rotate: false - xy: 1740, 736 + xy: 420, 642 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City ruins rotate: false - xy: 1740, 706 + xy: 454, 646 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Coal rotate: false - xy: 1740, 646 + xy: 1448, 544 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Coast rotate: false - xy: 1740, 616 + xy: 1482, 544 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Cotton rotate: false - xy: 1974, 1056 + xy: 1686, 570 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Customs house rotate: false - xy: 1872, 1035 + xy: 1686, 533 size: 32, 35 orig: 32, 35 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Deer rotate: false - xy: 1838, 1039 + xy: 1652, 512 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Deer+Camp rotate: false - xy: 1838, 1009 + xy: 1686, 503 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Desert rotate: false - xy: 1838, 979 + xy: 1720, 554 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Desert+Farm rotate: false - xy: 1872, 1005 + xy: 1720, 524 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Wheat+Farm rotate: false - xy: 1872, 1005 + xy: 1720, 524 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Desert+Flood plains+Farm rotate: false - xy: 1906, 1017 + xy: 1720, 494 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Dyes rotate: false - xy: 2008, 1023 + xy: 1754, 494 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Dyes+Plantation rotate: false - xy: 1872, 975 + xy: 1904, 1106 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/El Dorado rotate: false - xy: 1906, 986 + xy: 1938, 1101 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Fallout rotate: false - xy: 1940, 988 + xy: 1972, 1117 size: 32, 35 orig: 32, 35 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Fish rotate: false - xy: 1974, 996 + xy: 1972, 1087 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Fishing Boats rotate: false - xy: 2008, 993 + xy: 2006, 1124 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Flood plains rotate: false - xy: 1906, 956 + xy: 2006, 1094 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Forest rotate: false - xy: 1974, 962 + xy: 1584, 510 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Fort rotate: false - xy: 2008, 958 + xy: 1618, 505 size: 32, 33 orig: 32, 33 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Fountain of Youth rotate: false - xy: 284, 644 + xy: 1652, 478 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Furs rotate: false - xy: 352, 648 + xy: 1720, 464 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Furs+Camp rotate: false - xy: 288, 614 + xy: 1754, 464 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Gems rotate: false - xy: 356, 618 + xy: 454, 586 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Gold Ore rotate: false - xy: 356, 588 + xy: 488, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grand Mesa rotate: false - xy: 310, 550 + xy: 522, 576 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland rotate: false - xy: 310, 520 + xy: 556, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Farm rotate: false - xy: 310, 490 + xy: 590, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Forest+Camp rotate: false - xy: 310, 457 + xy: 624, 577 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Forest+Deer+Camp rotate: false - xy: 310, 424 + xy: 658, 577 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Forest+Furs+Camp rotate: false - xy: 310, 391 + xy: 692, 577 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Forest+Lumber mill rotate: false - xy: 310, 358 + xy: 726, 577 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Hill+Farm rotate: false - xy: 310, 328 + xy: 760, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Hill+Forest+Camp rotate: false - xy: 310, 298 + xy: 794, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Hill+Forest+Lumber mill rotate: false - xy: 310, 268 + xy: 828, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Hill+Forest+Trading post rotate: false - xy: 310, 238 + xy: 862, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Jungle+Trading post rotate: false - xy: 310, 204 + xy: 896, 576 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/GrasslandForest rotate: false - xy: 310, 171 + xy: 930, 577 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Great Barrier Reef rotate: false - xy: 310, 78 + xy: 1032, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Hill rotate: false - xy: 344, 221 + xy: 794, 546 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/HillForest+Lumber mill rotate: false - xy: 344, 191 + xy: 828, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/HillMarbleQuarry rotate: false - xy: 344, 161 + xy: 862, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/HillMine rotate: false - xy: 344, 131 + xy: 896, 546 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/HillStoneQuarry rotate: false - xy: 344, 101 + xy: 930, 547 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Horses rotate: false - xy: 1906, 926 + xy: 1066, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Horses+Pasture rotate: false - xy: 1940, 924 + xy: 1100, 546 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ice rotate: false - xy: 1974, 901 + xy: 1202, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Incense rotate: false - xy: 378, 528 + xy: 1304, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Incense+Plantation rotate: false - xy: 378, 498 + xy: 1338, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Iron rotate: false - xy: 378, 438 + xy: 794, 516 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ivory rotate: false - xy: 378, 378 + xy: 862, 520 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ivory+Camp rotate: false - xy: 378, 348 + xy: 896, 516 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Jungle rotate: false - xy: 378, 254 + xy: 998, 516 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Krakatoa rotate: false - xy: 378, 129 + xy: 1134, 514 size: 32, 30 orig: 32, 30 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Lakes rotate: false - xy: 378, 69 + xy: 1202, 520 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Landmark rotate: false - xy: 1618, 562 + xy: 1304, 512 size: 32, 36 orig: 32, 36 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Manufactory rotate: false - xy: 1720, 514 + xy: 1508, 502 size: 32, 39 orig: 32, 39 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Marble rotate: false - xy: 1754, 495 + xy: 1406, 484 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Marsh rotate: false - xy: 1686, 509 + xy: 1474, 483 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Mine rotate: false - xy: 1448, 544 + xy: 1542, 457 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Moai rotate: false - xy: 1550, 517 + xy: 1610, 444 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Mount Fuji rotate: false - xy: 1618, 470 + xy: 1746, 432 size: 32, 30 orig: 32, 30 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Mountain rotate: false - xy: 1652, 472 + xy: 1406, 446 size: 32, 36 orig: 32, 36 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Oasis rotate: false - xy: 1720, 424 + xy: 1610, 414 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ocean rotate: false - xy: 1686, 419 + xy: 1644, 418 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Offshore Platform rotate: false - xy: 1754, 405 + xy: 1678, 413 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Oil rotate: false - xy: 1720, 394 + xy: 1712, 404 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Oil well rotate: false - xy: 1754, 375 + xy: 1746, 402 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Old Faithful rotate: false - xy: 325, 37 + xy: 1440, 420 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Pasture rotate: false - xy: 359, 5 + xy: 1542, 393 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Pearls rotate: false - xy: 393, 9 + xy: 1610, 384 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plains rotate: false - xy: 424, 616 + xy: 1746, 372 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plains+Farm rotate: false - xy: 458, 616 + xy: 1474, 393 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plains+Forest+Camp rotate: false - xy: 412, 582 + xy: 1508, 378 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plains+Forest+Lumber mill rotate: false - xy: 412, 548 + xy: 1542, 359 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plains+Jungle+Trading post rotate: false - xy: 446, 582 + xy: 1576, 356 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/PlainsForest rotate: false - xy: 412, 514 + xy: 1610, 350 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/PlainsJungle rotate: false - xy: 446, 548 + xy: 1644, 354 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plantation rotate: false - xy: 412, 484 + xy: 1678, 353 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plantation+Bananas rotate: false - xy: 446, 518 + xy: 1712, 344 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plantation+Cotton rotate: false - xy: 412, 454 + xy: 1746, 342 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Quarry rotate: false - xy: 412, 424 + xy: 1100, 486 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Quarry+Marble rotate: false - xy: 446, 428 + xy: 1134, 484 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Quarry+Stone rotate: false - xy: 412, 394 + xy: 1168, 490 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Rock of Gibraltar rotate: false - xy: 446, 334 + xy: 1372, 441 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Sheep rotate: false - xy: 446, 244 + xy: 1576, 326 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Sheep+Pasture rotate: false - xy: 412, 179 + xy: 1610, 316 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Silk rotate: false - xy: 446, 183 + xy: 1712, 314 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Silk+Plantation rotate: false - xy: 412, 119 + xy: 1746, 312 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Silver rotate: false - xy: 446, 153 + xy: 1644, 293 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Snow rotate: false - xy: 446, 93 + xy: 1746, 282 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Snow+Farm rotate: false - xy: 446, 63 + xy: 1168, 460 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Spices rotate: false - xy: 427, 3 + xy: 1236, 460 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Spices+Plantation rotate: false - xy: 461, 33 + xy: 1270, 460 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Stone rotate: false - xy: 461, 3 + xy: 1304, 452 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Sugar rotate: false - xy: 480, 552 + xy: 1372, 411 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Sugar+Plantation rotate: false - xy: 514, 580 + xy: 1406, 386 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Terrace farm rotate: false - xy: 480, 492 + xy: 1542, 298 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Trading post rotate: false - xy: 514, 520 + xy: 1610, 286 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra rotate: false - xy: 582, 550 + xy: 1712, 254 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra+Farm rotate: false - xy: 514, 490 + xy: 1746, 252 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra+Forest+Camp rotate: false - xy: 548, 516 + xy: 1780, 430 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra+Forest+Camp+Furs rotate: false - xy: 616, 576 + xy: 1780, 396 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra+Forest+Deer+Camp rotate: false - xy: 480, 428 + xy: 1780, 362 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra+Forest+Lumber mill rotate: false - xy: 514, 456 + xy: 1780, 328 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/TundraForest rotate: false - xy: 582, 516 + xy: 1780, 294 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Uranium rotate: false - xy: 650, 580 + xy: 1780, 234 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Whales rotate: false - xy: 582, 486 + xy: 964, 487 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Whales+Fishing Boats rotate: false - xy: 548, 456 + xy: 998, 486 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Wheat rotate: false - xy: 684, 580 + xy: 1032, 460 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Wine rotate: false - xy: 616, 516 + xy: 1066, 457 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Wine+Plantation rotate: false - xy: 684, 550 + xy: 1100, 456 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/TopBorder rotate: false - xy: 548, 550 + xy: 1576, 296 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/African Forest Elephant rotate: false - xy: 1380, 601 + xy: 2, 10 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Anti-Aircraft Gun rotate: false - xy: 1904, 1136 + xy: 1938, 1132 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Anti-Tank Gun rotate: false - xy: 2008, 1184 + xy: 1992, 1154 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Archer rotate: false - xy: 1380, 571 + xy: 1736, 998 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Artillery rotate: false - xy: 1940, 1154 + xy: 1736, 968 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Atlatlist rotate: false - xy: 1974, 1154 + xy: 1738, 938 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Axe Thrower rotate: false - xy: 1838, 1099 + xy: 1738, 878 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Ballista rotate: false - xy: 1838, 1069 + xy: 1770, 1006 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Battering Ram rotate: false - xy: 1736, 998 + xy: 2013, 1772 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Battleship rotate: false - xy: 1736, 968 + xy: 2013, 1742 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Bazooka rotate: false - xy: 1738, 938 + xy: 2013, 1712 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Berber Cavalry rotate: false - xy: 1738, 908 + xy: 2013, 1682 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Berserker rotate: false - xy: 1738, 878 + xy: 2013, 1652 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Bowman rotate: false - xy: 1770, 1006 + xy: 2013, 1622 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Brute rotate: false - xy: 1770, 976 + xy: 2013, 1592 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Camel Archer rotate: false - xy: 1804, 1005 + xy: 2013, 1561 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Cannon rotate: false - xy: 1804, 975 + xy: 2013, 1531 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Caravel rotate: false - xy: 1414, 574 + xy: 1380, 565 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Carolean rotate: false - xy: 1448, 574 + xy: 1414, 574 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Carrier rotate: false - xy: 1482, 574 + xy: 1448, 574 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Cataphract rotate: false - xy: 2013, 1802 + xy: 1482, 574 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Catapult rotate: false - xy: 2013, 1772 + xy: 1516, 574 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Cavalry rotate: false - xy: 2013, 1678 + xy: 1740, 848 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Chariot Archer rotate: false - xy: 2013, 1618 + xy: 1740, 788 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Chu-Ko-Nu rotate: false - xy: 2013, 1588 + xy: 1740, 758 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/CivilianLandUnit rotate: false - xy: 1740, 676 + xy: 1414, 544 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Comanche Rider rotate: false - xy: 1740, 585 + xy: 1516, 543 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Companion Cavalry rotate: false - xy: 1516, 573 + xy: 1550, 547 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Composite Bowman rotate: false - xy: 1550, 578 + xy: 1584, 544 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Conquistador rotate: false - xy: 1584, 578 + xy: 1618, 570 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Cossack rotate: false - xy: 1940, 1055 + xy: 1652, 570 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Crossbowman rotate: false - xy: 2008, 1053 + xy: 1618, 540 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Cruiser rotate: false - xy: 1906, 1047 + xy: 1652, 542 size: 32, 26 orig: 32, 26 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Destroyer rotate: false - xy: 1940, 1025 + xy: 1754, 554 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Dromon rotate: false - xy: 1974, 1026 + xy: 1754, 524 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Foreign Legion rotate: false - xy: 1940, 958 + xy: 1550, 517 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Frigate rotate: false - xy: 318, 648 + xy: 1686, 473 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Galleass rotate: false - xy: 288, 584 + xy: 2006, 1064 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Galley rotate: false - xy: 322, 618 + xy: 454, 616 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Gatling Gun rotate: false - xy: 322, 588 + xy: 420, 612 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Admiral rotate: false - xy: 310, 138 + xy: 964, 577 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Artist rotate: false - xy: 310, 108 + xy: 998, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Engineer rotate: false - xy: 344, 558 + xy: 1066, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Galleass rotate: false - xy: 344, 528 + xy: 1100, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great General rotate: false - xy: 344, 495 + xy: 1134, 577 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Merchant rotate: false - xy: 344, 465 + xy: 1168, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Musician rotate: false - xy: 344, 435 + xy: 556, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Prophet rotate: false - xy: 344, 405 + xy: 590, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Scientist rotate: false - xy: 344, 375 + xy: 624, 547 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great War Infantry rotate: false - xy: 344, 345 + xy: 658, 547 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Writer rotate: false - xy: 344, 315 + xy: 692, 547 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Hakkapeliitta rotate: false - xy: 344, 285 + xy: 726, 547 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Helicopter Gunship rotate: false - xy: 344, 255 + xy: 760, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Hoplite rotate: false - xy: 344, 71 + xy: 964, 547 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Horse Archer rotate: false - xy: 1838, 949 + xy: 998, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Horseman rotate: false - xy: 1872, 945 + xy: 1032, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Hussar rotate: false - xy: 1974, 931 + xy: 1134, 546 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Hwach'a rotate: false - xy: 2008, 928 + xy: 1168, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Immortal rotate: false - xy: 2008, 898 + xy: 1236, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Impi rotate: false - xy: 378, 558 + xy: 1270, 550 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Infantry rotate: false - xy: 378, 468 + xy: 760, 520 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Ironclad rotate: false - xy: 378, 408 + xy: 828, 520 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Jaguar rotate: false - xy: 378, 318 + xy: 930, 517 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Janissary rotate: false - xy: 378, 288 + xy: 964, 517 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Keshik rotate: false - xy: 378, 224 + xy: 1032, 520 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Khan rotate: false - xy: 378, 191 + xy: 1066, 517 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Knight rotate: false - xy: 378, 161 + xy: 1100, 516 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Kris Swordsman rotate: false - xy: 378, 99 + xy: 1168, 520 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Lancer rotate: false - xy: 1550, 548 + xy: 1236, 520 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/LandUnit rotate: false - xy: 1584, 548 + xy: 1270, 520 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Landship rotate: false - xy: 1652, 570 + xy: 1338, 520 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Landsknecht rotate: false - xy: 1686, 570 + xy: 1372, 535 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Legion rotate: false - xy: 1618, 532 + xy: 1372, 505 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Longbowman rotate: false - xy: 1652, 540 + xy: 1338, 490 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Longswordsman rotate: false - xy: 1686, 540 + xy: 1406, 514 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Machine Gun rotate: false - xy: 1720, 555 + xy: 1440, 514 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Mandekalu Cavalry rotate: false - xy: 1754, 555 + xy: 1474, 514 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Maori Warrior rotate: false - xy: 1754, 525 + xy: 1372, 475 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Marine rotate: false - xy: 1652, 510 + xy: 1440, 484 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Mechanized Infantry rotate: false - xy: 1720, 484 + xy: 1508, 472 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Mehal Sefari rotate: false - xy: 1754, 465 + xy: 1542, 487 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Merchant Of Venice rotate: false - xy: 1414, 544 + xy: 1576, 480 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Minuteman rotate: false - xy: 1482, 544 + xy: 1576, 450 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Missile Cruiser rotate: false - xy: 1516, 543 + xy: 1610, 475 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Mobile SAM rotate: false - xy: 1584, 518 + xy: 1644, 448 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Modern Armor rotate: false - xy: 1618, 502 + xy: 1678, 443 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Mohawk Warrior rotate: false - xy: 1584, 488 + xy: 1712, 434 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Musketeer rotate: false - xy: 1686, 479 + xy: 1440, 454 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Musketman rotate: false - xy: 1720, 454 + xy: 1474, 453 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Naresuan's Elephant rotate: false - xy: 1686, 449 + xy: 1508, 442 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Nau rotate: false - xy: 1652, 442 + xy: 1542, 427 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Norwegian Ski Infantry rotate: false - xy: 1754, 435 + xy: 1576, 420 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Panzer rotate: false - xy: 325, 7 + xy: 1474, 423 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Paratrooper rotate: false - xy: 359, 39 + xy: 1508, 412 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Pathfinder rotate: false - xy: 393, 39 + xy: 1576, 390 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Persian Immortal rotate: false - xy: 412, 646 + xy: 1644, 388 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Pictish Warrior rotate: false - xy: 446, 646 + xy: 1678, 383 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Pikeman rotate: false - xy: 390, 616 + xy: 1712, 374 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Pracinha rotate: false - xy: 446, 488 + xy: 1032, 490 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Privateer rotate: false - xy: 446, 458 + xy: 1066, 487 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Quinquereme rotate: false - xy: 412, 364 + xy: 1202, 490 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Rifleman rotate: false - xy: 446, 398 + xy: 1236, 490 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Rocket Artillery rotate: false - xy: 412, 274 + xy: 1406, 416 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Samurai rotate: false - xy: 446, 304 + xy: 1440, 390 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Scout rotate: false - xy: 412, 244 + xy: 1474, 363 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Sea Beggar rotate: false - xy: 446, 274 + xy: 1508, 348 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Settler rotate: false - xy: 412, 213 + xy: 1542, 328 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Ship of the Line rotate: false - xy: 446, 213 + xy: 1644, 323 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Siege Tower rotate: false - xy: 412, 149 + xy: 1678, 323 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Sipahi rotate: false - xy: 412, 87 + xy: 1678, 291 size: 32, 30 orig: 32, 30 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Slinger rotate: false - xy: 446, 123 + xy: 1712, 284 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Spearman rotate: false - xy: 427, 33 + xy: 1202, 460 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Submarine rotate: false - xy: 480, 582 + xy: 1338, 432 size: 32, 26 orig: 32, 26 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Swordsman rotate: false - xy: 480, 522 + xy: 1440, 360 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Tank rotate: false - xy: 514, 550 + xy: 1474, 333 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Tercio rotate: false - xy: 548, 580 + xy: 1508, 318 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Trebuchet rotate: false - xy: 582, 580 + xy: 1644, 263 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Trireme rotate: false - xy: 480, 462 + xy: 1678, 261 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Turtle Ship rotate: false - xy: 548, 486 + xy: 1780, 264 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/War Chariot rotate: false - xy: 616, 546 + xy: 828, 490 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/War Elephant rotate: false - xy: 650, 550 + xy: 862, 490 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Warrior rotate: false - xy: 480, 398 + xy: 896, 486 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/WaterUnit rotate: false - xy: 514, 428 + xy: 930, 489 size: 32, 26 orig: 32, 26 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Winged Hussar rotate: false - xy: 650, 520 + xy: 1134, 454 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Work Boats rotate: false - xy: 480, 368 + xy: 1168, 430 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Worker rotate: false - xy: 514, 398 + xy: 1202, 430 size: 32, 28 orig: 32, 28 offset: 0, 0 @@ -3812,275 +3812,282 @@ UnitPromotionIcons/Ambush orig: 50, 50 offset: 0, 0 index: -1 +UnitPromotionIcons/Amphibious + rotate: false + xy: 1632, 964 + size: 50, 50 + orig: 50, 50 + offset: 0, 0 + index: -1 UnitPromotionIcons/Armor Plating rotate: false - xy: 1734, 1066 + xy: 510, 1529 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Barrage rotate: false - xy: 1938, 1422 + xy: 1836, 1218 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Besiege rotate: false - xy: 1836, 1218 + xy: 1428, 708 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Siege rotate: false - xy: 1836, 1218 + xy: 1428, 708 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Blitz rotate: false - xy: 1428, 708 + xy: 1530, 816 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Boarding Party rotate: false - xy: 1530, 816 + xy: 1938, 1370 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Bombardment rotate: false - xy: 1938, 1370 + xy: 1836, 1166 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Charge rotate: false - xy: 1836, 1166 + xy: 1428, 656 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Coastal Raider rotate: false - xy: 1888, 1218 + xy: 1888, 1166 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Cover rotate: false - xy: 1480, 708 + xy: 1480, 656 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 PolicyIcons/Discipline rotate: false - xy: 1480, 708 + xy: 1480, 656 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Drill rotate: false - xy: 1582, 816 + xy: 1684, 964 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Evasion rotate: false - xy: 1786, 1066 + xy: 236, 582 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Extended Range rotate: false - xy: 236, 582 + xy: 1532, 764 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Operational Range rotate: false - xy: 236, 582 + xy: 1532, 764 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Flight Deck rotate: false - xy: 1532, 712 + xy: 1532, 660 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Formation rotate: false - xy: 1532, 660 + xy: 1584, 764 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Great Generals rotate: false - xy: 206, 530 + xy: 206, 478 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Quick Study rotate: false - xy: 206, 530 + xy: 206, 478 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Haka War Dance rotate: false - xy: 206, 478 + xy: 206, 426 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Heal Instantly rotate: false - xy: 206, 426 + xy: 206, 374 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Indirect Fire rotate: false - xy: 206, 322 + xy: 206, 270 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Interception rotate: false - xy: 206, 218 + xy: 206, 166 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Logistics rotate: false - xy: 206, 62 + xy: 258, 530 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/March rotate: false - xy: 258, 478 + xy: 258, 426 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Medic rotate: false - xy: 258, 426 + xy: 258, 374 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Mobility rotate: false - xy: 258, 114 + xy: 258, 62 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Morale rotate: false - xy: 1428, 604 + xy: 1480, 604 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Rejuvenation rotate: false - xy: 221, 10 + xy: 273, 10 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Scouting rotate: false - xy: 1686, 912 + xy: 1686, 860 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Sentry rotate: false - xy: 1686, 912 + xy: 1686, 860 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Shock rotate: false - xy: 1636, 808 + xy: 1636, 756 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Slinger Withdraw rotate: false - xy: 1636, 756 + xy: 1636, 704 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Sortie rotate: false - xy: 1636, 704 + xy: 1636, 652 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Targeting rotate: false - xy: 1688, 756 + xy: 1688, 704 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Targeting I (air) rotate: false - xy: 1688, 756 + xy: 1688, 704 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Volley rotate: false - xy: 1940, 1214 + xy: 1992, 1318 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Wolfpack rotate: false - xy: 1992, 1266 + xy: 1992, 1214 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 UnitPromotionIcons/Woodsman rotate: false - xy: 1992, 1214 + xy: 1940, 1162 size: 50, 50 orig: 50, 50 offset: 0, 0 diff --git a/android/assets/game.png b/android/assets/game.png index be283536d9..d6c1f2f54a 100644 Binary files a/android/assets/game.png and b/android/assets/game.png differ diff --git a/android/assets/jsons/Civ V - Vanilla/Techs.json b/android/assets/jsons/Civ V - Vanilla/Techs.json index 49dae84d38..97cfd5216e 100644 --- a/android/assets/jsons/Civ V - Vanilla/Techs.json +++ b/android/assets/jsons/Civ V - Vanilla/Techs.json @@ -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" }, diff --git a/android/assets/jsons/Civ V - Vanilla/UnitPromotions.json b/android/assets/jsons/Civ V - Vanilla/UnitPromotions.json index 6ae0e1e391..44374c1d93 100644 --- a/android/assets/jsons/Civ V - Vanilla/UnitPromotions.json +++ b/android/assets/jsons/Civ V - Vanilla/UnitPromotions.json @@ -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]%)" } -] \ No newline at end of file +] diff --git a/android/assets/jsons/Civ V - Vanilla/Units.json b/android/assets/jsons/Civ V - Vanilla/Units.json index c2b41346bc..a42d0aa759 100644 --- a/android/assets/jsons/Civ V - Vanilla/Units.json +++ b/android/assets/jsons/Civ V - Vanilla/Units.json @@ -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", diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 55f83bbd61..0be55ba60c 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -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 diff --git a/android/assets/sounds/bombard.mp3 b/android/assets/sounds/bombard.mp3 new file mode 100644 index 0000000000..73deec93ad Binary files /dev/null and b/android/assets/sounds/bombard.mp3 differ diff --git a/android/assets/sounds/construction.mp3 b/android/assets/sounds/construction.mp3 new file mode 100644 index 0000000000..870b3eece6 Binary files /dev/null and b/android/assets/sounds/construction.mp3 differ diff --git a/android/assets/sounds/nuke.mp3 b/android/assets/sounds/nuke.mp3 new file mode 100644 index 0000000000..af94136aa6 Binary files /dev/null and b/android/assets/sounds/nuke.mp3 differ diff --git a/android/assets/sounds/slider.mp3 b/android/assets/sounds/slider.mp3 new file mode 100644 index 0000000000..2e6a1d951c Binary files /dev/null and b/android/assets/sounds/slider.mp3 differ diff --git a/core/src/com/unciv/logic/automation/NextTurnAutomation.kt b/core/src/com/unciv/logic/automation/NextTurnAutomation.kt index fa6b93f0e3..e58fad6401 100644 --- a/core/src/com/unciv/logic/automation/NextTurnAutomation.kt +++ b/core/src/com/unciv/logic/automation/NextTurnAutomation.kt @@ -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() } diff --git a/core/src/com/unciv/logic/battle/BattleDamage.kt b/core/src/com/unciv/logic/battle/BattleDamage.kt index a6a074f4ad..d2a4013b4e 100644 --- a/core/src/com/unciv/logic/battle/BattleDamage.kt +++ b/core/src/com/unciv/logic/battle/BattleDamage.kt @@ -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) { diff --git a/core/src/com/unciv/logic/battle/CityCombatant.kt b/core/src/com/unciv/logic/battle/CityCombatant.kt index d4b50b125c..47cbdc14e1 100644 --- a/core/src/com/unciv/logic/battle/CityCombatant.kt +++ b/core/src/com/unciv/logic/battle/CityCombatant.kt @@ -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 diff --git a/core/src/com/unciv/logic/battle/ICombatant.kt b/core/src/com/unciv/logic/battle/ICombatant.kt index da6d183946..efa74bafdc 100644 --- a/core/src/com/unciv/logic/battle/ICombatant.kt +++ b/core/src/com/unciv/logic/battle/ICombatant.kt @@ -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() diff --git a/core/src/com/unciv/logic/battle/MapUnitCombatant.kt b/core/src/com/unciv/logic/battle/MapUnitCombatant.kt index 12ee64d754..79241548a5 100644 --- a/core/src/com/unciv/logic/battle/MapUnitCombatant.kt +++ b/core/src/com/unciv/logic/battle/MapUnitCombatant.kt @@ -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 diff --git a/core/src/com/unciv/logic/city/CityConstructions.kt b/core/src/com/unciv/logic/city/CityConstructions.kt index 840f53f71c..e493fc9eb9 100644 --- a/core/src/com/unciv/logic/city/CityConstructions.kt +++ b/core/src/com/unciv/logic/city/CityConstructions.kt @@ -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) diff --git a/core/src/com/unciv/logic/civilization/CivilizationInfo.kt b/core/src/com/unciv/logic/civilization/CivilizationInfo.kt index 6bbdbf1640..23031b4223 100644 --- a/core/src/com/unciv/logic/civilization/CivilizationInfo.kt +++ b/core/src/com/unciv/logic/civilization/CivilizationInfo.kt @@ -653,6 +653,30 @@ class CivilizationInfo { fun getAllyCiv() = allyCivName + fun getProtectorCivs() : List { + 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 diff --git a/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyManager.kt b/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyManager.kt index f688bd2b9b..9b69a917f7 100644 --- a/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyManager.kt +++ b/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyManager.kt @@ -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 */ diff --git a/core/src/com/unciv/logic/civilization/diplomacy/DiplomaticStatus.kt b/core/src/com/unciv/logic/civilization/diplomacy/DiplomaticStatus.kt index 168f08ca34..2adc79db31 100644 --- a/core/src/com/unciv/logic/civilization/diplomacy/DiplomaticStatus.kt +++ b/core/src/com/unciv/logic/civilization/diplomacy/DiplomaticStatus.kt @@ -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 } \ No newline at end of file diff --git a/core/src/com/unciv/logic/map/TileInfo.kt b/core/src/com/unciv/logic/map/TileInfo.kt index 24482d40b3..c594a28699 100644 --- a/core/src/com/unciv/logic/map/TileInfo.kt +++ b/core/src/com/unciv/logic/map/TileInfo.kt @@ -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() // more readable than StringBuilder, with same performance for our use-case + fun toMarkup(viewingCiv: CivilizationInfo?): ArrayList { + val lineList = ArrayList() // 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 } diff --git a/core/src/com/unciv/models/UncivSound.kt b/core/src/com/unciv/models/UncivSound.kt index a992885e59..edd8c38da6 100644 --- a/core/src/com/unciv/models/UncivSound.kt +++ b/core/src/com/unciv/models/UncivSound.kt @@ -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 } \ No newline at end of file diff --git a/core/src/com/unciv/models/ruleset/Ruleset.kt b/core/src/com/unciv/models/ruleset/Ruleset.kt index 491a7a2ccb..ec14fad2cc 100644 --- a/core/src/com/unciv/models/ruleset/Ruleset.kt +++ b/core/src/com/unciv/models/ruleset/Ruleset.kt @@ -34,6 +34,8 @@ class ModOptions { var lastUpdated = "" var modUrl = "" + var author = "" + var modSize = 0 } class Ruleset { diff --git a/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt b/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt index d1f1d4e236..3cf8d7b12e 100644 --- a/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt +++ b/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt @@ -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 diff --git a/core/src/com/unciv/ui/cityscreen/CityScreenTileTable.kt b/core/src/com/unciv/ui/cityscreen/CityScreenTileTable.kt index 31911355b1..78905781d3 100644 --- a/core/src/com/unciv/ui/cityscreen/CityScreenTileTable.kt +++ b/core/src/com/unciv/ui/cityscreen/CityScreenTileTable.kt @@ -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() diff --git a/core/src/com/unciv/ui/civilopedia/CivilopediaCategories.kt b/core/src/com/unciv/ui/civilopedia/CivilopediaCategories.kt index b6ba28b3f4..9babae818a 100644 --- a/core/src/com/unciv/ui/civilopedia/CivilopediaCategories.kt +++ b/core/src/com/unciv/ui/civilopedia/CivilopediaCategories.kt @@ -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 diff --git a/core/src/com/unciv/ui/civilopedia/CivilopediaText.kt b/core/src/com/unciv/ui/civilopedia/CivilopediaText.kt new file mode 100644 index 0000000000..ca879bcb1d --- /dev/null +++ b/core/src/com/unciv/ui/civilopedia/CivilopediaText.kt @@ -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, + 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() } + } +} diff --git a/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt b/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt index 47f9442bd0..843dc85ef3 100644 --- a/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt +++ b/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt @@ -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 = 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 = hashMapOf() + private val modDescriptionsOnline: HashMap = 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(30) + private val onlineScrollIndex = HashMap(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(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(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 = mutableSetOf() + val duplicates: MutableList> = 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, 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() } } diff --git a/core/src/com/unciv/ui/saves/LoadGameScreen.kt b/core/src/com/unciv/ui/saves/LoadGameScreen.kt index 7465adb7e4..1c78d5b4ae 100644 --- a/core/src/com/unciv/ui/saves/LoadGameScreen.kt +++ b/core/src/com/unciv/ui/saves/LoadGameScreen.kt @@ -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) diff --git a/core/src/com/unciv/ui/trade/DiplomacyScreen.kt b/core/src/com/unciv/ui/trade/DiplomacyScreen.kt index cdd51d8ead..2023288606 100644 --- a/core/src/com/unciv/ui/trade/DiplomacyScreen.kt +++ b/core/src/com/unciv/ui/trade/DiplomacyScreen.kt @@ -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() diff --git a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt index 33fe4e3c52..c5d46bdd18 100644 --- a/core/src/com/unciv/ui/utils/ExtensionFunctions.kt +++ b/core/src/com/unciv/ui/utils/ExtensionFunctions.kt @@ -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 { fun Table.addSeparatorVertical(): Cell { val image = ImageGetter.getWhiteDot() - val cell = add(image).width(2f).fillY() - return cell + return add(image).width(2f).fillY() } fun Table.addCell(actor: T): Table { @@ -200,3 +201,28 @@ fun List.randomWeighted(weights: List, 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) +} diff --git a/core/src/com/unciv/ui/utils/Sounds.kt b/core/src/com/unciv/ui/utils/Sounds.kt index 24a9e2a136..fd19edb897 100644 --- a/core/src/com/unciv/ui/utils/Sounds.kt +++ b/core/src/com/unciv/ui/utils/Sounds.kt @@ -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() - - 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() + + 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 { + 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) } } \ No newline at end of file diff --git a/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt b/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt index a17487da48..d5a9367c9b 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt @@ -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) { diff --git a/core/src/com/unciv/ui/worldscreen/bottombar/BattleTable.kt b/core/src/com/unciv/ui/worldscreen/bottombar/BattleTable.kt index 71343c3eed..9d96c654a1 100644 --- a/core/src/com/unciv/ui/worldscreen/bottombar/BattleTable.kt +++ b/core/src/com/unciv/ui/worldscreen/bottombar/BattleTable.kt @@ -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 diff --git a/core/src/com/unciv/ui/worldscreen/bottombar/TileInfoTable.kt b/core/src/com/unciv/ui/worldscreen/bottombar/TileInfoTable.kt index a49b9b6a2d..1ce5da0843 100644 --- a/core/src/com/unciv/ui/worldscreen/bottombar/TileInfoTable.kt +++ b/core/src/com/unciv/ui/worldscreen/bottombar/TileInfoTable.kt @@ -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 } -} \ No newline at end of file +} diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/DropBox.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/DropBox.kt index 341060f5b1..f3634ce067 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/DropBox.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/DropBox.kt @@ -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() 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/-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() - } - - 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 - } -} \ No newline at end of file diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt new file mode 100644 index 0000000000..eaaaf460fa --- /dev/null +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt @@ -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 Github API doc + */ + 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 Github API doc + */ + @Suppress("PropertyName") + class RepoSearch { + var total_count = 0 + var incomplete_results = false + var items = ArrayList() + } + + /** 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 + } +} diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt index d53406709b..92a0a49b59 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt @@ -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() } diff --git a/core/src/com/unciv/ui/worldscreen/unit/UnitTable.kt b/core/src/com/unciv/ui/worldscreen/unit/UnitTable.kt index 278bc4f264..04e2af0617 100644 --- a/core/src/com/unciv/ui/worldscreen/unit/UnitTable.kt +++ b/core/src/com/unciv/ui/worldscreen/unit/UnitTable.kt @@ -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() diff --git a/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt b/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt index fbdbe103fe..2a4f46b9e6 100644 --- a/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt +++ b/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt @@ -57,6 +57,8 @@ internal object DesktopLauncher { LwjglApplication(game, config) } + // Work in Progress? + @Suppress("unused") private fun startMultiplayerServer() { // val games = HashMap() val files = HashMap() @@ -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 = 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) diff --git a/docs/Credits.md b/docs/Credits.md index 9dd1f7dc77..6e325784bb 100644 --- a/docs/Credits.md +++ b/docs/Credits.md @@ -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