diff --git a/android/Images/OtherIcons/Loading.png b/android/Images/OtherIcons/Loading.png new file mode 100644 index 0000000000..ad1235c513 Binary files /dev/null and b/android/Images/OtherIcons/Loading.png differ diff --git a/android/assets/game.atlas b/android/assets/game.atlas index f074cf48a3..58a409bab6 100644 --- a/android/assets/game.atlas +++ b/android/assets/game.atlas @@ -6,56 +6,56 @@ filter: MipMapLinearLinear, MipMapLinearLinear repeat: none EmojiIcons/Culture rotate: false - xy: 868, 170 + xy: 940, 482 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 EmojiIcons/Faith rotate: false - xy: 940, 234 + xy: 1012, 554 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 EmojiIcons/Food rotate: false - xy: 1012, 298 + xy: 1084, 618 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 EmojiIcons/Gold rotate: false - xy: 796, 48 + xy: 1156, 688 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 EmojiIcons/Happiness rotate: false - xy: 1084, 350 + xy: 940, 424 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 EmojiIcons/Production rotate: false - xy: 1156, 834 + xy: 940, 134 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 EmojiIcons/Science rotate: false - xy: 1156, 660 + xy: 1228, 942 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 EmojiIcons/Turn rotate: false - xy: 1156, 486 + xy: 796, 47 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -153,105 +153,105 @@ ImprovementIcons/Landmark index: -1 ImprovementIcons/Lumber mill rotate: false - xy: 296, 1758 + xy: 289, 1650 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Manufactory rotate: false - xy: 289, 1542 + xy: 285, 1434 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Mine rotate: false - xy: 829, 1620 + xy: 937, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Moai rotate: false - xy: 937, 1620 + xy: 1045, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Offshore Platform rotate: false - xy: 1693, 1620 + xy: 1801, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Oil well rotate: false - xy: 1909, 1620 + xy: 505, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Pasture rotate: false - xy: 613, 1512 + xy: 721, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Plantation rotate: false - xy: 1153, 1512 + xy: 1261, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Polder rotate: false - xy: 1261, 1512 + xy: 1369, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Quarry rotate: false - xy: 1909, 1512 + xy: 501, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Railroad rotate: false - xy: 717, 1404 + xy: 825, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 TileSets/Default/Railroad rotate: false - xy: 717, 1404 + xy: 825, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Road rotate: false - xy: 1473, 1404 + xy: 1581, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Terrace farm rotate: false - xy: 1702, 1296 + xy: 1810, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ImprovementIcons/Trading post rotate: false - xy: 1810, 1296 + xy: 1918, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 @@ -321,7 +321,7 @@ OtherIcons/Cities index: -1 OtherIcons/CityState rotate: false - xy: 796, 106 + xy: 1156, 746 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -405,14 +405,14 @@ TileSets/FantasyHex/Hexagon index: -1 OtherIcons/Improvements rotate: false - xy: 1156, 1066 + xy: 940, 366 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 OtherIcons/Link rotate: false - xy: 1156, 950 + xy: 940, 250 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -424,226 +424,233 @@ OtherIcons/Load orig: 100, 100 offset: 0, 0 index: -1 -OtherIcons/Lock +OtherIcons/Loading rotate: false xy: 1886, 1728 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 +OtherIcons/Lock + rotate: false + xy: 296, 1758 + size: 100, 100 + orig: 100, 100 + offset: 0, 0 + index: -1 OtherIcons/MapEditor rotate: false - xy: 285, 1434 + xy: 397, 1650 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Maritime rotate: false - xy: 397, 1542 + xy: 393, 1434 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/MenuIcon rotate: false - xy: 505, 1620 + xy: 613, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Mercantile rotate: false - xy: 613, 1620 + xy: 721, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Militaristic rotate: false - xy: 721, 1620 + xy: 829, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Mods rotate: false - xy: 1045, 1620 + xy: 1153, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Multiplayer rotate: false - xy: 1261, 1620 + xy: 1369, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Nations rotate: false - xy: 1156, 892 + xy: 940, 192 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 OtherIcons/New rotate: false - xy: 1477, 1620 + xy: 1585, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Options rotate: false - xy: 505, 1512 + xy: 613, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Pencil rotate: false - xy: 829, 1512 + xy: 937, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Pentagon rotate: false - xy: 937, 1512 + xy: 1045, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Pillage rotate: false - xy: 1045, 1512 + xy: 1153, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Present rotate: false - xy: 1585, 1512 + xy: 1693, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Puppet rotate: false - xy: 1801, 1512 + xy: 1909, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Quest rotate: false - xy: 501, 1404 + xy: 609, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Quickstart rotate: false - xy: 609, 1404 + xy: 717, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Religious rotate: false - xy: 825, 1404 + xy: 933, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Remove Heresy rotate: false - xy: 1041, 1404 + xy: 1149, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Resources rotate: false - xy: 1257, 1404 + xy: 1365, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Resume rotate: false - xy: 1365, 1404 + xy: 1473, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Search rotate: false - xy: 1797, 1404 + xy: 1905, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/SecretOptions rotate: false - xy: 1905, 1404 + xy: 298, 1326 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Settings rotate: false - xy: 298, 1326 + xy: 298, 1218 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Shield rotate: false - xy: 406, 1296 + xy: 514, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Sleep rotate: false - xy: 730, 1296 + xy: 838, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Speaker rotate: false - xy: 838, 1296 + xy: 946, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Star rotate: false - xy: 1162, 1302 + xy: 1270, 1302 size: 100, 94 orig: 100, 94 offset: 0, 0 index: -1 OtherIcons/Stop rotate: false - xy: 1378, 1296 + xy: 1486, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Swap rotate: false - xy: 1594, 1296 + xy: 1702, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Terrains rotate: false - xy: 1156, 544 + xy: 1228, 826 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -657,28 +664,28 @@ OtherIcons/Triangle index: -1 OtherIcons/Turn right rotate: false - xy: 406, 1188 + xy: 514, 1188 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Tyrannosaurus rotate: false - xy: 514, 1188 + xy: 622, 1188 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/Wonders rotate: false - xy: 1270, 1188 + xy: 1378, 1188 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 OtherIcons/whiteDot rotate: false - xy: 1276, 441 + xy: 473, 1785 size: 1, 1 orig: 1, 1 offset: 0, 0 @@ -818,112 +825,112 @@ ResourceIcons/Jewelry index: -1 ResourceIcons/Marble rotate: false - xy: 397, 1650 + xy: 397, 1542 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Oil rotate: false - xy: 1801, 1620 + xy: 1909, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Pearls rotate: false - xy: 721, 1512 + xy: 829, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Porcelain rotate: false - xy: 1477, 1512 + xy: 1585, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Salt rotate: false - xy: 1581, 1404 + xy: 1689, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Sheep rotate: false - xy: 298, 1218 + xy: 406, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Silk rotate: false - xy: 514, 1296 + xy: 622, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Silver rotate: false - xy: 622, 1296 + xy: 730, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Spices rotate: false - xy: 1054, 1296 + xy: 1162, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Stone rotate: false - xy: 1270, 1296 + xy: 1378, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Sugar rotate: false - xy: 1486, 1296 + xy: 1594, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Truffles rotate: false - xy: 1918, 1296 + xy: 406, 1188 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Uranium rotate: false - xy: 838, 1188 + xy: 946, 1188 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Whales rotate: false - xy: 946, 1188 + xy: 1054, 1188 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Wheat rotate: false - xy: 1054, 1188 + xy: 1162, 1188 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 ResourceIcons/Wine rotate: false - xy: 1162, 1194 + xy: 1270, 1194 size: 100, 100 orig: 100, 100 offset: 0, 0 @@ -979,77 +986,77 @@ StatIcons/Happiness index: -1 StatIcons/InterceptRange rotate: false - xy: 1156, 1008 + xy: 940, 308 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 StatIcons/Malcontent rotate: false - xy: 289, 1650 + xy: 289, 1542 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 StatIcons/Population rotate: false - xy: 1369, 1512 + xy: 1477, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 StatIcons/Production rotate: false - xy: 1693, 1512 + xy: 1801, 1512 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 StatIcons/Range rotate: false - xy: 1156, 776 + xy: 1232, 1058 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 StatIcons/RangedStrength rotate: false - xy: 1156, 718 + xy: 1228, 1000 size: 50, 50 orig: 50, 50 offset: 0, 0 index: -1 StatIcons/ReligiousStrength rotate: false - xy: 933, 1404 + xy: 1041, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 StatIcons/Resistance rotate: false - xy: 1149, 1404 + xy: 1257, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 StatIcons/Science rotate: false - xy: 1689, 1404 + xy: 1797, 1404 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 StatIcons/Specialist rotate: false - xy: 946, 1296 + xy: 1054, 1296 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 StatIcons/Strength rotate: false - xy: 1156, 602 + xy: 1228, 884 size: 50, 50 orig: 50, 50 offset: 0, 0 @@ -1133,28 +1140,28 @@ TileSets/FantasyHex/Arrows/UnitMoving index: -1 TileSets/Default/Arrows/UnitTeleported rotate: false - xy: 622, 1228 + xy: 730, 1228 size: 100, 60 orig: 100, 60 offset: 0, 0 index: -1 TileSets/FantasyHex/Arrows/UnitTeleported rotate: false - xy: 622, 1228 + xy: 730, 1228 size: 100, 60 orig: 100, 60 offset: 0, 0 index: -1 TileSets/Default/Arrows/UnitWithdrew rotate: false - xy: 730, 1228 + xy: 838, 1228 size: 100, 60 orig: 100, 60 offset: 0, 0 index: -1 TileSets/FantasyHex/Arrows/UnitWithdrew rotate: false - xy: 730, 1228 + xy: 838, 1228 size: 100, 60 orig: 100, 60 offset: 0, 0 @@ -1196,28 +1203,28 @@ TileSets/FantasyHex/Borders/ConcaveConvexOuter index: -1 TileSets/Default/Borders/ConcaveInner rotate: false - xy: 622, 1205 + xy: 730, 1205 size: 81, 15 orig: 81, 15 offset: 0, 0 index: -1 TileSets/FantasyHex/Borders/ConcaveInner rotate: false - xy: 622, 1205 + xy: 730, 1205 size: 81, 15 orig: 81, 15 offset: 0, 0 index: -1 TileSets/Default/Borders/ConcaveOuter rotate: false - xy: 1378, 1273 + xy: 1486, 1273 size: 81, 15 orig: 81, 15 offset: 0, 0 index: -1 TileSets/FantasyHex/Borders/ConcaveOuter rotate: false - xy: 1378, 1273 + xy: 1486, 1273 size: 81, 15 orig: 81, 15 offset: 0, 0 @@ -1238,42 +1245,42 @@ TileSets/FantasyHex/Borders/ConvexConcaveInner index: -1 TileSets/Default/Borders/ConvexConcaveOuter rotate: false - xy: 711, 1205 + xy: 819, 1205 size: 81, 15 orig: 81, 15 offset: 0, 0 index: -1 TileSets/FantasyHex/Borders/ConvexConcaveOuter rotate: false - xy: 711, 1205 + xy: 819, 1205 size: 81, 15 orig: 81, 15 offset: 0, 0 index: -1 TileSets/Default/Borders/ConvexInner rotate: false - xy: 1378, 1250 + xy: 1486, 1250 size: 81, 15 orig: 81, 15 offset: 0, 0 index: -1 TileSets/FantasyHex/Borders/ConvexInner rotate: false - xy: 1378, 1250 + xy: 1486, 1250 size: 81, 15 orig: 81, 15 offset: 0, 0 index: -1 TileSets/Default/Borders/ConvexOuter rotate: false - xy: 1467, 1273 + xy: 1575, 1273 size: 81, 15 orig: 81, 15 offset: 0, 0 index: -1 TileSets/FantasyHex/Borders/ConvexOuter rotate: false - xy: 1467, 1273 + xy: 1575, 1273 size: 81, 15 orig: 81, 15 offset: 0, 0 @@ -1378,28 +1385,28 @@ TileSets/Default/LakesOverlay index: -1 TileSets/Default/MarshOverlay rotate: false - xy: 393, 1434 + xy: 505, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 TileSets/Default/MountainOverlay rotate: false - xy: 1153, 1620 + xy: 1261, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 TileSets/Default/NaturalWonderOverlay rotate: false - xy: 1369, 1620 + xy: 1477, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 index: -1 TileSets/Default/OasisOverlay rotate: false - xy: 1585, 1620 + xy: 1693, 1620 size: 100, 100 orig: 100, 100 offset: 0, 0 @@ -1413,21 +1420,21 @@ TileSets/Default/Road index: -1 TileSets/Default/Tiles/River-Bottom rotate: false - xy: 1534, 694 + xy: 1574, 596 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/Default/Tiles/River-BottomLeft rotate: false - xy: 1534, 658 + xy: 1574, 524 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/Default/Tiles/River-BottomRight rotate: false - xy: 1534, 622 + xy: 1614, 701 size: 32, 28 orig: 32, 28 offset: 0, 0 @@ -1448,1134 +1455,1134 @@ TileSets/FantasyHex/Road index: -1 TileSets/FantasyHex/Tiles/Academy rotate: false - xy: 796, 4 + xy: 2010, 2008 size: 32, 36 orig: 32, 36 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Academy-Snow rotate: false - xy: 2010, 2009 + xy: 2010, 1965 size: 32, 35 orig: 32, 35 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Aluminum rotate: false - xy: 1156, 450 + xy: 940, 98 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ancient ruins rotate: false - xy: 2010, 1973 + xy: 724, 16 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ancient ruins-Jungle rotate: false - xy: 1988, 1256 + xy: 1214, 750 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ancient ruins-Sand rotate: false - xy: 1214, 1094 + xy: 1214, 714 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ancient ruins-Snow rotate: false - xy: 1214, 1058 + xy: 1214, 678 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ancient ruins2 rotate: false - xy: 1156, 414 + xy: 1228, 790 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Atoll rotate: false - xy: 1214, 806 + xy: 998, 302 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Bananas rotate: false - xy: 1214, 698 + xy: 998, 194 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Barbarian encampment rotate: false - xy: 1214, 662 + xy: 998, 158 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Barbarian encampment-Snow rotate: false - xy: 1214, 625 + xy: 998, 121 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Barringer Crater rotate: false - xy: 1214, 589 + xy: 1268, 790 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Bison rotate: false - xy: 1638, 1068 + xy: 1038, 446 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Bison+Camp rotate: false - xy: 1678, 1068 + xy: 1038, 410 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Cattle rotate: false - xy: 1254, 1088 + xy: 1290, 1080 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Cattle+Pasture rotate: false - xy: 1254, 1048 + xy: 1330, 1082 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Cerro de Potosi rotate: false - xy: 1254, 1012 + xy: 1330, 1046 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Citadel rotate: false - xy: 1294, 1009 + xy: 1286, 929 size: 32, 35 orig: 32, 35 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Citadel-Snow rotate: false - xy: 1254, 938 + xy: 1286, 891 size: 32, 30 orig: 32, 30 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Citrus rotate: false - xy: 1294, 973 + xy: 1286, 855 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Citrus+Plantation rotate: false - xy: 1254, 902 + xy: 854, 69 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center rotate: false - xy: 1294, 930 + xy: 764, 4 size: 32, 35 orig: 32, 35 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Ancient era rotate: false - xy: 1254, 862 + xy: 804, 7 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Atomic era rotate: false - xy: 1294, 888 + xy: 894, 67 size: 32, 34 orig: 32, 34 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Classical era rotate: false - xy: 1254, 822 + xy: 934, 58 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Future era rotate: false - xy: 1294, 846 + xy: 854, 27 size: 32, 34 orig: 32, 34 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Industrial era rotate: false - xy: 1254, 781 + xy: 894, 26 size: 32, 33 orig: 32, 33 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Information era rotate: false - xy: 836, 4 + xy: 934, 14 size: 32, 36 orig: 32, 36 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Medieval era rotate: false - xy: 1294, 806 + xy: 1294, 750 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Modern era rotate: false - xy: 1254, 739 + xy: 1294, 708 size: 32, 34 orig: 32, 34 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City center-Renaissance era rotate: false - xy: 1294, 766 + xy: 1294, 668 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/City ruins rotate: false - xy: 1254, 703 + xy: 1254, 646 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Coal rotate: false - xy: 1254, 667 + xy: 1070, 576 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Coast rotate: false - xy: 1294, 694 + xy: 1078, 482 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Cocoa rotate: false - xy: 1254, 631 + xy: 1078, 446 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Cocoa+Plantation rotate: false - xy: 1294, 658 + xy: 1078, 410 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Copper rotate: false - xy: 1254, 522 + xy: 1078, 228 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Cotton rotate: false - xy: 1254, 486 + xy: 1078, 156 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Crab rotate: false - xy: 1294, 513 + xy: 1078, 120 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Customs house rotate: false - xy: 1334, 1043 + xy: 1370, 1079 size: 32, 35 orig: 32, 35 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Deer rotate: false - xy: 1374, 1086 + xy: 1410, 1088 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Deer+Camp rotate: false - xy: 1334, 1007 + xy: 1450, 1086 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Desert rotate: false - xy: 1374, 1050 + xy: 1490, 1086 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Desert+Farm rotate: false - xy: 1414, 1086 + xy: 1530, 1086 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Desert+Flood plains+Farm rotate: false - xy: 1334, 971 + xy: 1370, 1043 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Dyes rotate: false - xy: 1334, 935 + xy: 1490, 1050 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Dyes+Plantation rotate: false - xy: 1374, 978 + xy: 1530, 1050 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/El Dorado rotate: false - xy: 1414, 1013 + xy: 1410, 1015 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Fallout rotate: false - xy: 1334, 892 + xy: 1450, 1007 size: 32, 35 orig: 32, 35 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Fish rotate: false - xy: 1374, 942 + xy: 1490, 1014 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Fishing Boats rotate: false - xy: 1414, 977 + xy: 1530, 1014 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Flood plains rotate: false - xy: 1334, 856 + xy: 1570, 1045 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Forest rotate: false - xy: 1414, 937 + xy: 1570, 1005 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Fort rotate: false - xy: 1334, 815 + xy: 1610, 1004 size: 32, 33 orig: 32, 33 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Fountain of Youth rotate: false - xy: 1374, 866 + xy: 1650, 992 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Furs rotate: false - xy: 1334, 779 + xy: 1738, 1004 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Furs+Camp rotate: false - xy: 1374, 830 + xy: 1490, 978 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Gems rotate: false - xy: 1414, 829 + xy: 1650, 956 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Gold Ore rotate: false - xy: 1374, 758 + xy: 1730, 968 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grand Mesa rotate: false - xy: 1414, 789 + xy: 1730, 928 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland rotate: false - xy: 1334, 668 + xy: 1690, 921 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Farm rotate: false - xy: 1374, 722 + xy: 1730, 892 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Forest+Camp rotate: false - xy: 1414, 750 + xy: 1770, 965 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Forest+Deer+Camp rotate: false - xy: 1334, 629 + xy: 1770, 926 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Forest+Furs+Camp rotate: false - xy: 1374, 683 + xy: 1770, 887 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Forest+Lumber mill rotate: false - xy: 1414, 711 + xy: 974, 59 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Forest+Truffles+Camp rotate: false - xy: 1334, 590 + xy: 974, 20 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Hill+Farm rotate: false - xy: 1374, 647 + xy: 1014, 85 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Hill+Forest+Camp rotate: false - xy: 1414, 675 + xy: 1014, 49 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Hill+Forest+Lumber mill rotate: false - xy: 1334, 554 + xy: 1014, 13 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Hill+Forest+Trading post rotate: false - xy: 1374, 611 + xy: 1054, 84 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Grassland+Jungle+Trading post rotate: false - xy: 1414, 635 + xy: 1054, 44 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/GrasslandForest rotate: false - xy: 1334, 515 + xy: 1054, 5 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Great Barrier Reef rotate: false - xy: 1374, 536 + xy: 1094, 9 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Hill rotate: false - xy: 1110, 274 + xy: 1450, 967 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/HillForest+Lumber mill rotate: false - xy: 1254, 450 + xy: 1406, 943 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/HillMarbleQuarry rotate: false - xy: 1294, 441 + xy: 1406, 907 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/HillMine rotate: false - xy: 1334, 440 + xy: 1406, 871 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/HillStoneQuarry rotate: false - xy: 1374, 428 + xy: 1446, 931 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Holy site rotate: false - xy: 1414, 411 + xy: 1446, 887 size: 32, 36 orig: 32, 36 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Horses rotate: false - xy: 1236, 378 + xy: 1490, 942 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Horses+Pasture rotate: false - xy: 1276, 401 + xy: 1530, 938 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ice rotate: false - xy: 1276, 328 + xy: 1486, 833 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Incense rotate: false - xy: 1316, 332 + xy: 1650, 920 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Incense+Plantation rotate: false - xy: 1356, 392 + xy: 1526, 902 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Iron rotate: false - xy: 1396, 375 + xy: 1566, 897 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ivory rotate: false - xy: 1396, 303 + xy: 1606, 896 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ivory+Camp rotate: false - xy: 1316, 296 + xy: 1606, 860 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Jungle rotate: false - xy: 1436, 371 + xy: 1606, 820 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Krakatoa rotate: false - xy: 926, 190 + xy: 1646, 810 size: 32, 30 orig: 32, 30 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Lakes rotate: false - xy: 926, 154 + xy: 1486, 797 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Landmark rotate: false - xy: 1990, 1176 + xy: 1606, 776 size: 32, 36 orig: 32, 36 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Manufactory rotate: false - xy: 1006, 209 + xy: 1766, 768 size: 32, 39 orig: 32, 39 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Marble rotate: false - xy: 966, 126 + xy: 1326, 827 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Marsh rotate: false - xy: 876, 19 + xy: 1308, 790 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Mine rotate: false - xy: 1454, 973 + xy: 1486, 761 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Moai rotate: false - xy: 1454, 900 + xy: 1646, 737 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Mount Fuji rotate: false - xy: 1454, 826 + xy: 1766, 694 size: 32, 30 orig: 32, 30 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Mount Kailash rotate: false - xy: 1494, 861 + xy: 1726, 672 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Mount Sinai rotate: false - xy: 1454, 786 + xy: 1766, 654 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Mountain rotate: false - xy: 1494, 817 + xy: 1334, 746 size: 32, 36 orig: 32, 36 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Oasis rotate: false - xy: 1454, 642 + xy: 1374, 647 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Ocean rotate: false - xy: 1494, 673 + xy: 1374, 611 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Offshore Platform rotate: false - xy: 1454, 606 + xy: 1334, 602 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Oil rotate: false - xy: 1494, 637 + xy: 1374, 575 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Oil well rotate: false - xy: 1454, 570 + xy: 1414, 743 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Old Faithful rotate: false - xy: 1494, 597 + xy: 1414, 703 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Pasture rotate: false - xy: 1454, 494 + xy: 1414, 591 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Pearls rotate: false - xy: 1454, 458 + xy: 1454, 725 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plains rotate: false - xy: 1494, 417 + xy: 1454, 581 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plains+Farm rotate: false - xy: 1534, 1031 + xy: 1454, 545 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plains+Forest+Camp rotate: false - xy: 1534, 991 + xy: 1494, 718 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plains+Forest+Lumber mill rotate: false - xy: 1574, 1027 + xy: 1494, 678 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plains+Jungle+Trading post rotate: false - xy: 1534, 951 + xy: 1494, 638 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/PlainsForest rotate: false - xy: 1574, 987 + xy: 1494, 598 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/PlainsJungle rotate: false - xy: 1534, 911 + xy: 1494, 558 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plantation rotate: false - xy: 1574, 951 + xy: 1494, 522 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plantation+Bananas rotate: false - xy: 1534, 875 + xy: 1534, 717 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Plantation+Cotton rotate: false - xy: 1574, 915 + xy: 1534, 681 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Polder rotate: false - xy: 1534, 838 + xy: 1534, 644 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Quarry rotate: false - xy: 1574, 843 + xy: 1534, 536 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Quarry+Marble rotate: false - xy: 1534, 766 + xy: 1534, 500 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Quarry+Stone rotate: false - xy: 1574, 807 + xy: 1574, 704 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/River-Bottom rotate: false - xy: 1574, 735 + xy: 1574, 560 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/River-BottomLeft rotate: false - xy: 1574, 699 + xy: 1574, 488 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/River-BottomRight rotate: false - xy: 1574, 663 + xy: 1614, 665 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Rock of Gibraltar rotate: false - xy: 1534, 582 + xy: 1614, 625 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Salt rotate: false - xy: 1534, 474 + xy: 1654, 630 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Sheep rotate: false - xy: 1534, 402 + xy: 1694, 636 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Sheep+Pasture rotate: false - xy: 1574, 406 + xy: 1694, 596 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Silk rotate: false - xy: 1614, 958 + xy: 1694, 487 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Silk+Plantation rotate: false - xy: 1614, 922 + xy: 1654, 449 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Silver rotate: false - xy: 1614, 886 + xy: 1694, 451 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Snow rotate: false - xy: 1614, 740 + xy: 1734, 508 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Snow+Farm rotate: false - xy: 1614, 704 + xy: 1734, 472 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Spices rotate: false - xy: 1614, 632 + xy: 1774, 582 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Spices+Plantation rotate: false - xy: 1614, 596 + xy: 1774, 546 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Sri Pada rotate: false - xy: 1614, 556 + xy: 1774, 506 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Stone rotate: false - xy: 1614, 520 + xy: 1774, 470 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Sugar rotate: false - xy: 1614, 450 + xy: 1818, 1002 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Sugar+Plantation rotate: false - xy: 1614, 414 + xy: 1858, 1002 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Terrace farm rotate: false - xy: 1734, 1032 + xy: 1850, 966 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Trading post rotate: false - xy: 1694, 996 + xy: 1850, 930 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Truffles rotate: false - xy: 1734, 996 + xy: 1850, 894 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Truffles+Camp rotate: false - xy: 1654, 888 + xy: 1890, 930 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra rotate: false - xy: 1694, 924 + xy: 1850, 858 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra+Farm rotate: false - xy: 1734, 960 + xy: 1890, 894 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra+Forest+Camp rotate: false - xy: 1654, 848 + xy: 1890, 854 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra+Forest+Camp+Furs rotate: false - xy: 1694, 884 + xy: 1734, 432 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra+Forest+Deer+Camp rotate: false - xy: 1734, 920 + xy: 1774, 430 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra+Forest+Lumber mill rotate: false - xy: 1654, 808 + xy: 1694, 411 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Tundra+Forest+Truffles+Camp rotate: false - xy: 1694, 844 + xy: 1734, 392 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/TundraForest rotate: false - xy: 1734, 880 + xy: 1774, 390 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Uluru rotate: false - xy: 1694, 804 + xy: 1978, 992 size: 32, 32 orig: 32, 32 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Uranium rotate: false - xy: 1734, 844 + xy: 1930, 960 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Whales rotate: false - xy: 1694, 732 + xy: 1970, 922 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Whales+Fishing Boats rotate: false - xy: 1734, 772 + xy: 1970, 886 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Wheat rotate: false - xy: 1654, 666 + xy: 1970, 850 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Wine rotate: false - xy: 1694, 696 + xy: 1810, 822 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Tiles/Wine+Plantation rotate: false - xy: 1734, 736 + xy: 1850, 822 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/TopBorder rotate: false - xy: 1654, 960 + xy: 1810, 894 size: 32, 28 orig: 32, 28 offset: 0, 0 @@ -2589,119 +2596,119 @@ TileSets/FantasyHex/Units/African Forest Elephant index: -1 TileSets/FantasyHex/Units/Anti-Aircraft Gun rotate: false - xy: 1214, 1022 + xy: 1012, 518 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Anti-Tank Gun rotate: false - xy: 1214, 986 + xy: 998, 482 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Archaeologist rotate: false - xy: 1214, 950 + xy: 998, 446 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Archer rotate: false - xy: 1214, 914 + xy: 998, 410 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Artillery rotate: false - xy: 1214, 878 + xy: 998, 374 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Atlatlist rotate: false - xy: 1214, 842 + xy: 998, 338 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Axe Thrower rotate: false - xy: 1214, 770 + xy: 998, 266 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Ballista rotate: false - xy: 1214, 734 + xy: 998, 230 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Battering Ram rotate: false - xy: 1214, 553 + xy: 1254, 754 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Battleship rotate: false - xy: 1214, 517 + xy: 1254, 718 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Bazooka rotate: false - xy: 1214, 481 + xy: 1254, 682 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Berber Cavalry rotate: false - xy: 1558, 1067 + xy: 1052, 518 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Berserker rotate: false - xy: 1598, 1067 + xy: 1038, 482 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Bowman rotate: false - xy: 1718, 1068 + xy: 1038, 374 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Brute rotate: false - xy: 1758, 1068 + xy: 1038, 338 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Camel Archer rotate: false - xy: 1798, 1067 + xy: 1038, 301 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Cannon rotate: false - xy: 1838, 1068 + xy: 1038, 265 size: 32, 28 orig: 32, 28 offset: 0, 0 @@ -2715,112 +2722,112 @@ TileSets/FantasyHex/Units/Caravan index: -1 TileSets/FantasyHex/Units/Caravel rotate: false - xy: 1878, 1068 + xy: 1038, 229 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Cargo Ship rotate: false - xy: 1918, 1068 + xy: 1038, 193 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Carolean rotate: false - xy: 1958, 1068 + xy: 1038, 157 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Carrier rotate: false - xy: 1070, 314 + xy: 1038, 121 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Cataphract rotate: false - xy: 1110, 314 + xy: 1156, 652 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Catapult rotate: false - xy: 724, 7 + xy: 1142, 616 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Cavalry rotate: false - xy: 1294, 1088 + xy: 1290, 1044 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Chariot Archer rotate: false - xy: 1294, 1052 + xy: 1286, 1008 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Chu-Ko-Nu rotate: false - xy: 1254, 976 + xy: 1286, 972 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/CivilianLandUnit rotate: false - xy: 1294, 730 + xy: 1294, 632 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Comanche Rider rotate: false - xy: 1254, 594 + xy: 1078, 373 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Companion Cavalry rotate: false - xy: 1294, 621 + xy: 1078, 336 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Composite Bowman rotate: false - xy: 1254, 558 + xy: 1078, 300 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Conquistador rotate: false - xy: 1294, 585 + xy: 1078, 264 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Cossack rotate: false - xy: 1294, 549 + xy: 1078, 192 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Crossbowman rotate: false - xy: 1334, 1086 + xy: 1182, 616 size: 32, 28 orig: 32, 28 offset: 0, 0 @@ -2834,721 +2841,721 @@ TileSets/FantasyHex/Units/Cruiser index: -1 TileSets/FantasyHex/Units/Destroyer rotate: false - xy: 1374, 1014 + xy: 1410, 1052 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Dromon rotate: false - xy: 1414, 1050 + xy: 1450, 1050 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Foreign Legion rotate: false - xy: 1374, 906 + xy: 1610, 1045 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Frigate rotate: false - xy: 1414, 901 + xy: 1690, 996 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Galleass rotate: false - xy: 1414, 865 + xy: 1530, 978 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Galley rotate: false - xy: 1334, 743 + xy: 1570, 969 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Gatling Gun rotate: false - xy: 1374, 794 + xy: 1610, 968 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Giant Death Robot rotate: false - xy: 1334, 704 + xy: 1690, 957 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Admiral rotate: false - xy: 1374, 572 + xy: 1094, 81 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Artist rotate: false - xy: 1414, 599 + xy: 1094, 45 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Engineer rotate: false - xy: 1414, 563 + xy: 1330, 1010 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Galleass rotate: false - xy: 1294, 477 + xy: 1370, 1007 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great General rotate: false - xy: 1334, 476 + xy: 1326, 971 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Merchant rotate: false - xy: 1374, 500 + xy: 1326, 935 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Musician rotate: false - xy: 1414, 527 + xy: 1326, 899 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Prophet rotate: false - xy: 1414, 491 + xy: 1326, 863 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Scientist rotate: false - xy: 1374, 464 + xy: 1366, 971 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great War Infantry rotate: false - xy: 1414, 455 + xy: 1366, 935 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Great Writer rotate: false - xy: 1196, 445 + xy: 1366, 899 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Hakkapeliitta rotate: false - xy: 1196, 409 + xy: 1366, 863 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Helicopter Gunship rotate: false - xy: 1070, 278 + xy: 1410, 979 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Hoplite rotate: false - xy: 1236, 414 + xy: 1446, 851 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Horse Archer rotate: false - xy: 1156, 378 + xy: 1406, 835 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Horseman rotate: false - xy: 1196, 373 + xy: 1446, 815 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Hussar rotate: false - xy: 1276, 364 + xy: 1486, 905 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Hwach'a rotate: false - xy: 1236, 342 + xy: 1486, 869 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Immortal rotate: false - xy: 1316, 404 + xy: 1570, 933 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Impi rotate: false - xy: 1316, 368 + xy: 1610, 932 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Infantry rotate: false - xy: 1356, 356 + xy: 1526, 866 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Inquisitor rotate: false - xy: 1356, 320 + xy: 1526, 830 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Ironclad rotate: false - xy: 1396, 339 + xy: 1566, 861 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Jaguar rotate: false - xy: 1356, 284 + xy: 1646, 884 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Janissary rotate: false - xy: 1396, 267 + xy: 1566, 825 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Keshik rotate: false - xy: 1436, 335 + xy: 1646, 848 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Khan rotate: false - xy: 1436, 296 + xy: 1690, 882 size: 32, 31 orig: 32, 31 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Knight rotate: false - xy: 1436, 260 + xy: 1686, 846 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Kris Swordsman rotate: false - xy: 966, 198 + xy: 1686, 810 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Lancer rotate: false - xy: 966, 162 + xy: 1526, 794 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/LandUnit rotate: false - xy: 1990, 1220 + xy: 1566, 789 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Landship rotate: false - xy: 1990, 1140 + xy: 1646, 774 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Landsknecht rotate: false - xy: 1990, 1104 + xy: 1686, 774 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Legion rotate: false - xy: 1998, 1068 + xy: 1730, 856 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Longbowman rotate: false - xy: 998, 256 + xy: 1726, 820 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Longswordsman rotate: false - xy: 854, 128 + xy: 1726, 784 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Machine Gun rotate: false - xy: 854, 92 + xy: 1770, 851 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Mandekalu Cavalry rotate: false - xy: 854, 56 + xy: 1766, 815 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Maori Warrior rotate: false - xy: 1006, 173 + xy: 1726, 748 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Marauder rotate: false - xy: 1006, 137 + xy: 1766, 732 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Marine rotate: false - xy: 1006, 101 + xy: 1366, 827 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Mechanized Infantry rotate: false - xy: 1454, 1045 + xy: 1348, 791 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Mehal Sefari rotate: false - xy: 1454, 1009 + xy: 1406, 799 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Merchant Of Venice rotate: false - xy: 1494, 1045 + xy: 1446, 779 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Minuteman rotate: false - xy: 1494, 1009 + xy: 1526, 758 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Missile Cruiser rotate: false - xy: 1454, 937 + xy: 1566, 753 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Missionary rotate: false - xy: 1494, 973 + xy: 1606, 740 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Mobile SAM rotate: false - xy: 1494, 937 + xy: 1686, 738 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Modern Armor rotate: false - xy: 1454, 864 + xy: 1726, 712 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Mohawk Warrior rotate: false - xy: 1494, 901 + xy: 1686, 702 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Musketeer rotate: false - xy: 1454, 750 + xy: 1334, 710 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Musketman rotate: false - xy: 1494, 781 + xy: 1334, 674 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Naresuan's Elephant rotate: false - xy: 1454, 714 + xy: 1334, 638 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Nau rotate: false - xy: 1494, 745 + xy: 1374, 755 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Norwegian Ski Infantry rotate: false - xy: 1454, 678 + xy: 1374, 719 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Nuclear Submarine rotate: false - xy: 1494, 709 + xy: 1374, 683 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Panzer rotate: false - xy: 1454, 534 + xy: 1414, 667 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Paratrooper rotate: false - xy: 1494, 561 + xy: 1414, 631 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Pathfinder rotate: false - xy: 1494, 525 + xy: 1414, 555 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Persian Immortal rotate: false - xy: 1494, 489 + xy: 1454, 689 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Pictish Warrior rotate: false - xy: 1454, 422 + xy: 1454, 653 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Pikeman rotate: false - xy: 1494, 453 + xy: 1454, 617 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Pracinha rotate: false - xy: 1574, 879 + xy: 1534, 608 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Privateer rotate: false - xy: 1534, 802 + xy: 1534, 572 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Quinquereme rotate: false - xy: 1534, 730 + xy: 1574, 668 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Rifleman rotate: false - xy: 1574, 771 + xy: 1574, 632 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Rocket Artillery rotate: false - xy: 1574, 627 + xy: 1614, 589 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/SS Booster rotate: false - xy: 1534, 546 + xy: 1614, 553 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/SS Cockpit rotate: false - xy: 1574, 591 + xy: 1614, 517 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/SS Engine rotate: false - xy: 1534, 510 + xy: 1614, 481 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/SS Stasis Chamber rotate: false - xy: 1574, 555 + xy: 1654, 666 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Samurai rotate: false - xy: 1574, 519 + xy: 1654, 594 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Scout rotate: false - xy: 1534, 438 + xy: 1654, 558 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Sea Beggar rotate: false - xy: 1574, 483 + xy: 1654, 522 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Settler rotate: false - xy: 1574, 446 + xy: 1654, 485 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Ship of the Line rotate: false - xy: 1614, 1030 + xy: 1694, 559 size: 32, 29 orig: 32, 29 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Siege Tower rotate: false - xy: 1614, 994 + xy: 1694, 523 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Sipahi rotate: false - xy: 1614, 848 + xy: 1734, 616 size: 32, 30 orig: 32, 30 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Skirmisher rotate: false - xy: 1614, 812 + xy: 1734, 580 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Slinger rotate: false - xy: 1614, 776 + xy: 1734, 544 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Spearman rotate: false - xy: 1614, 668 + xy: 1774, 618 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Submarine rotate: false - xy: 1614, 486 + xy: 1778, 1004 size: 32, 26 orig: 32, 26 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Swordsman rotate: false - xy: 1654, 1032 + xy: 1898, 1002 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Tank rotate: false - xy: 1694, 1032 + xy: 1810, 966 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Tercio rotate: false - xy: 1654, 996 + xy: 1810, 930 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Trebuchet rotate: false - xy: 1654, 924 + xy: 1890, 966 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Trireme rotate: false - xy: 1694, 960 + xy: 1810, 858 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Turtle Ship rotate: false - xy: 1654, 772 + xy: 1938, 996 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/War Chariot rotate: false - xy: 1654, 736 + xy: 1930, 924 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/War Elephant rotate: false - xy: 1694, 768 + xy: 1930, 888 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Warrior rotate: false - xy: 1734, 808 + xy: 1930, 852 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/WaterUnit rotate: false - xy: 1654, 702 + xy: 1970, 958 size: 32, 26 orig: 32, 26 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Winged Hussar rotate: false - xy: 1654, 630 + xy: 1890, 818 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Work Boats rotate: false - xy: 1694, 660 + xy: 1930, 816 size: 32, 28 orig: 32, 28 offset: 0, 0 index: -1 TileSets/FantasyHex/Units/Worker rotate: false - xy: 1734, 700 + xy: 1970, 814 size: 32, 28 orig: 32, 28 offset: 0, 0 @@ -3842,7 +3849,7 @@ TileSets/HexaRealm/Tiles/City ruins index: -1 TileSets/HexaRealm/Tiles/Coal rotate: false - xy: 1378, 1186 + xy: 1486, 1186 size: 64, 56 orig: 64, 56 offset: 0, 0 @@ -4311,1701 +4318,1701 @@ TileSets/HexaRealm/Tiles/ForestTLow index: -1 TileSets/HexaRealm/Tiles/ForestTUp rotate: false - xy: 1467, 1209 + xy: 1575, 1209 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Fort rotate: false - xy: 1556, 1232 + xy: 1664, 1232 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Fort-Atomic era rotate: false - xy: 1628, 1232 + xy: 1736, 1232 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Fort-Future era rotate: false - xy: 1628, 1232 + xy: 1736, 1232 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Fort-Information era rotate: false - xy: 1628, 1232 + xy: 1736, 1232 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Fort-Industrial era rotate: false - xy: 1700, 1232 + xy: 1808, 1232 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Fort-Modern era rotate: false - xy: 1700, 1232 + xy: 1808, 1232 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Fountain of Youth rotate: false - xy: 1772, 1232 + xy: 1880, 1232 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Furs rotate: false - xy: 1844, 1232 + xy: 1952, 1232 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Furs+Camp rotate: false - xy: 1916, 1232 + xy: 584, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Furs+Camp-Atomic era rotate: false - xy: 622, 1141 + xy: 656, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Furs+Camp-Future era rotate: false - xy: 622, 1141 + xy: 656, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Furs+Camp-Industrial era rotate: false - xy: 622, 1141 + xy: 656, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Furs+Camp-Information era rotate: false - xy: 622, 1141 + xy: 656, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Furs+Camp-Modern era rotate: false - xy: 622, 1141 + xy: 656, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/FursT rotate: false - xy: 694, 1141 + xy: 580, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/FursT+Camp rotate: false - xy: 766, 1141 + xy: 580, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/FursT+Camp-Atomic era rotate: false - xy: 838, 1124 + xy: 652, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/FursT+Camp-Future era rotate: false - xy: 838, 1124 + xy: 652, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/FursT+Camp-Industrial era rotate: false - xy: 838, 1124 + xy: 652, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/FursT+Camp-Information era rotate: false - xy: 838, 1124 + xy: 652, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/FursT+Camp-Modern era rotate: false - xy: 838, 1124 + xy: 652, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Gems rotate: false - xy: 910, 1124 + xy: 580, 932 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Gold Ore rotate: false - xy: 982, 1124 + xy: 652, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Grand Mesa rotate: false - xy: 1054, 1124 + xy: 580, 868 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Grassland rotate: false - xy: 1126, 1124 + xy: 652, 932 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Grassland+Hill rotate: false - xy: 1342, 1122 + xy: 580, 740 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Grassland+Hill+Fallout rotate: false - xy: 580, 1077 + xy: 580, 676 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Grassland+Hill+Fallout2 rotate: false - xy: 652, 1077 + xy: 652, 740 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Grassland+Hill2 rotate: false - xy: 1414, 1122 + xy: 652, 804 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Grassland2 rotate: false - xy: 1198, 1130 + xy: 580, 804 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Grassland3 rotate: false - xy: 1270, 1124 + xy: 652, 868 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Great Barrier Reef rotate: false - xy: 580, 1013 + xy: 580, 612 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Hill rotate: false - xy: 724, 1077 + xy: 652, 676 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Holy site rotate: false - xy: 580, 941 + xy: 580, 540 size: 64, 64 orig: 64, 64 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Holy siteT rotate: false - xy: 652, 1005 + xy: 652, 604 size: 64, 64 orig: 64, 64 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Horses rotate: false - xy: 580, 877 + xy: 580, 476 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Horses+Pasture rotate: false - xy: 652, 941 + xy: 652, 540 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Horses+Pasture-Atomic era rotate: false - xy: 724, 1013 + xy: 580, 412 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Horses+Pasture-Future era rotate: false - xy: 724, 1013 + xy: 580, 412 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Horses+Pasture-Industrial era rotate: false - xy: 724, 1013 + xy: 580, 412 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Horses+Pasture-Information era rotate: false - xy: 724, 1013 + xy: 580, 412 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Horses+Pasture-Modern era rotate: false - xy: 724, 1013 + xy: 580, 412 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Ice rotate: false - xy: 580, 813 + xy: 652, 476 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Incense rotate: false - xy: 652, 877 + xy: 580, 348 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Incense+Plantation rotate: false - xy: 724, 949 + xy: 652, 412 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Iron rotate: false - xy: 580, 749 + xy: 580, 284 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Ivory rotate: false - xy: 652, 813 + xy: 652, 348 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Ivory+Camp rotate: false - xy: 724, 885 + xy: 580, 220 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Ivory+Camp-Atomic era rotate: false - xy: 580, 685 + xy: 652, 284 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Ivory+Camp-Future era rotate: false - xy: 580, 685 + xy: 652, 284 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Ivory+Camp-Industrial era rotate: false - xy: 580, 685 + xy: 652, 284 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Ivory+Camp-Information era rotate: false - xy: 580, 685 + xy: 652, 284 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Ivory+Camp-Modern era rotate: false - xy: 580, 685 + xy: 652, 284 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/JungleG rotate: false - xy: 652, 749 + xy: 580, 156 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/JungleGLow rotate: false - xy: 724, 821 + xy: 652, 220 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/JungleGUp rotate: false - xy: 580, 621 + xy: 580, 92 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/JungleP rotate: false - xy: 652, 685 + xy: 652, 156 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/JunglePLow rotate: false - xy: 724, 757 + xy: 580, 28 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/JunglePUp rotate: false - xy: 580, 557 + xy: 652, 92 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Krakatoa rotate: false - xy: 652, 621 + xy: 652, 28 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lakes rotate: false - xy: 724, 693 + xy: 730, 1141 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Landmark rotate: false - xy: 580, 485 + xy: 802, 1133 size: 64, 64 orig: 64, 64 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/LandmarkT rotate: false - xy: 652, 549 + xy: 874, 1133 size: 64, 64 orig: 64, 64 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lumber mill rotate: false - xy: 724, 629 + xy: 946, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lumber mill-Atomic era rotate: false - xy: 580, 421 + xy: 1018, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lumber mill-Future era rotate: false - xy: 580, 421 + xy: 1018, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lumber mill-Industrial era rotate: false - xy: 580, 421 + xy: 1018, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lumber mill-Information era rotate: false - xy: 580, 421 + xy: 1018, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lumber mill-Modern era rotate: false - xy: 580, 421 + xy: 1018, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lumber millT rotate: false - xy: 652, 485 + xy: 1090, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lumber millT-Atomic era rotate: false - xy: 724, 565 + xy: 1162, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lumber millT-Future era rotate: false - xy: 724, 565 + xy: 1162, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lumber millT-Industrial era rotate: false - xy: 724, 565 + xy: 1162, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lumber millT-Information era rotate: false - xy: 724, 565 + xy: 1162, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Lumber millT-Modern era rotate: false - xy: 724, 565 + xy: 1162, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Manufactory rotate: false - xy: 580, 349 + xy: 1234, 1116 size: 64, 64 orig: 64, 64 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/ManufactoryT rotate: false - xy: 652, 413 + xy: 1306, 1122 size: 64, 64 orig: 64, 64 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble rotate: false - xy: 724, 501 + xy: 1378, 1124 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble+Quarry rotate: false - xy: 580, 285 + xy: 1450, 1122 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble+Quarry-Atomic era rotate: false - xy: 652, 349 + xy: 1522, 1122 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble+Quarry-Future era rotate: false - xy: 652, 349 + xy: 1522, 1122 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble+Quarry-Industrial era rotate: false - xy: 652, 349 + xy: 1522, 1122 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble+Quarry-Information era rotate: false - xy: 652, 349 + xy: 1522, 1122 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble+Quarry-Modern era rotate: false - xy: 652, 349 + xy: 1522, 1122 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble+QuarryT rotate: false - xy: 724, 437 + xy: 1594, 1145 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble+QuarryT-Atomic era rotate: false - xy: 580, 221 + xy: 1666, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble+QuarryT-Future era rotate: false - xy: 580, 221 + xy: 1666, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble+QuarryT-Industrial era rotate: false - xy: 580, 221 + xy: 1666, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble+QuarryT-Information era rotate: false - xy: 580, 221 + xy: 1666, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marble+QuarryT-Modern era rotate: false - xy: 580, 221 + xy: 1666, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Marsh rotate: false - xy: 652, 285 + xy: 1738, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Mine rotate: false - xy: 724, 373 + xy: 1810, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Mine-Atomic era rotate: false - xy: 580, 157 + xy: 1882, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Mine-Future era rotate: false - xy: 580, 157 + xy: 1882, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Mine-Industrial era rotate: false - xy: 580, 157 + xy: 1882, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Mine-Information era rotate: false - xy: 580, 157 + xy: 1882, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Mine-Modern era rotate: false - xy: 580, 157 + xy: 1882, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/MineT rotate: false - xy: 652, 221 + xy: 1954, 1168 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/MineT-Atomic era rotate: false - xy: 724, 309 + xy: 1594, 1081 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/MineT-Future era rotate: false - xy: 724, 309 + xy: 1594, 1081 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/MineT-Industrial era rotate: false - xy: 724, 309 + xy: 1594, 1081 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/MineT-Information era rotate: false - xy: 724, 309 + xy: 1594, 1081 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/MineT-Modern era rotate: false - xy: 724, 309 + xy: 1594, 1081 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Moai rotate: false - xy: 580, 93 + xy: 1666, 1104 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Mount Fuji rotate: false - xy: 652, 157 + xy: 1738, 1104 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Mount Kailash rotate: false - xy: 724, 243 + xy: 1810, 1102 size: 64, 58 orig: 64, 58 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Mount Sinai rotate: false - xy: 580, 27 + xy: 1882, 1102 size: 64, 58 orig: 64, 58 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Mountain rotate: false - xy: 652, 85 + xy: 1954, 1096 size: 64, 64 orig: 64, 64 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Mountain-1 rotate: false - xy: 652, 85 + xy: 1954, 1096 size: 64, 64 orig: 64, 64 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Mountain2 rotate: false - xy: 724, 171 + xy: 1666, 1032 size: 64, 64 orig: 64, 64 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Oasis rotate: false - xy: 724, 107 + xy: 1738, 1040 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Ocean rotate: false - xy: 652, 21 + xy: 1810, 1038 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Offshore Platform rotate: false - xy: 724, 43 + xy: 1882, 1038 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Oil+Offshore Platform rotate: false - xy: 724, 43 + xy: 1882, 1038 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Oil rotate: false - xy: 1486, 1145 + xy: 1954, 1032 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Oil well rotate: false - xy: 1558, 1167 + xy: 728, 1076 size: 64, 57 orig: 64, 57 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Oil+Oil well rotate: false - xy: 1558, 1167 + xy: 728, 1076 size: 64, 57 orig: 64, 57 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/OilS rotate: false - xy: 1630, 1168 + xy: 724, 1012 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Old Faithful rotate: false - xy: 1702, 1168 + xy: 724, 948 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Pasture rotate: false - xy: 1774, 1168 + xy: 724, 884 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Pasture-Atomic era rotate: false - xy: 1846, 1168 + xy: 724, 820 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Pasture-Future era rotate: false - xy: 1846, 1168 + xy: 724, 820 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Pasture-Industrial era rotate: false - xy: 1846, 1168 + xy: 724, 820 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Pasture-Information era rotate: false - xy: 1846, 1168 + xy: 724, 820 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Pasture-Modern era rotate: false - xy: 1846, 1168 + xy: 724, 820 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Pearls rotate: false - xy: 1918, 1168 + xy: 724, 756 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Plains rotate: false - xy: 1486, 1081 + xy: 724, 692 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Plains+Hill rotate: false - xy: 1702, 1104 + xy: 724, 500 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Plains+Hill+Fallout rotate: false - xy: 1846, 1104 + xy: 724, 372 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Plains+Hill+Fallout2 rotate: false - xy: 1918, 1104 + xy: 724, 308 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Plains+Hill2 rotate: false - xy: 1774, 1104 + xy: 724, 436 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Plains2 rotate: false - xy: 1558, 1103 + xy: 724, 628 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Plains3 rotate: false - xy: 1630, 1104 + xy: 724, 564 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Plantation rotate: false - xy: 796, 1060 + xy: 724, 244 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Polder rotate: false - xy: 868, 1060 + xy: 724, 180 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Polder-Atomic era rotate: false - xy: 796, 996 + xy: 724, 116 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Polder-Future era rotate: false - xy: 796, 996 + xy: 724, 116 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Polder-Industrial era rotate: false - xy: 796, 996 + xy: 724, 116 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Polder-Information era rotate: false - xy: 796, 996 + xy: 724, 116 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Polder-Modern era rotate: false - xy: 796, 996 + xy: 724, 116 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Quarry rotate: false - xy: 940, 1060 + xy: 724, 52 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Quarry-Atomic era rotate: false - xy: 796, 932 + xy: 800, 1069 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Quarry-Future era rotate: false - xy: 796, 932 + xy: 800, 1069 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Quarry-Industrial era rotate: false - xy: 796, 932 + xy: 800, 1069 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Quarry-Information era rotate: false - xy: 796, 932 + xy: 800, 1069 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Quarry-Modern era rotate: false - xy: 796, 932 + xy: 800, 1069 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/QuarryT rotate: false - xy: 868, 996 + xy: 872, 1069 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/River-Bottom rotate: false - xy: 1012, 1060 + xy: 796, 1005 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/River-BottomLeft rotate: false - xy: 796, 868 + xy: 796, 941 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/River-BottomRight rotate: false - xy: 868, 932 + xy: 868, 1005 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Rock of Gibraltar rotate: false - xy: 940, 996 + xy: 796, 877 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/RuinsD rotate: false - xy: 1084, 1060 + xy: 868, 941 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/RuinsG rotate: false - xy: 796, 804 + xy: 796, 813 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/RuinsT rotate: false - xy: 868, 868 + xy: 868, 877 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Salt rotate: false - xy: 940, 932 + xy: 796, 749 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Sheep rotate: false - xy: 1012, 996 + xy: 868, 813 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Sheep+Pasture rotate: false - xy: 796, 740 + xy: 796, 685 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Sheep+Pasture-Atomic era rotate: false - xy: 868, 804 + xy: 868, 749 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Sheep+Pasture-Future era rotate: false - xy: 868, 804 + xy: 868, 749 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Sheep+Pasture-Industrial era rotate: false - xy: 868, 804 + xy: 868, 749 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Sheep+Pasture-Information era rotate: false - xy: 868, 804 + xy: 868, 749 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Sheep+Pasture-Modern era rotate: false - xy: 868, 804 + xy: 868, 749 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Silk rotate: false - xy: 940, 868 + xy: 796, 621 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Silk+Plantation rotate: false - xy: 1012, 932 + xy: 868, 685 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Silver rotate: false - xy: 1084, 996 + xy: 796, 557 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Snow rotate: false - xy: 796, 676 + xy: 868, 621 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Snow+Hill rotate: false - xy: 1012, 868 + xy: 796, 429 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Snow+Hill+Fallout rotate: false - xy: 796, 612 + xy: 796, 365 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Snow+Hill+Fallout2 rotate: false - xy: 868, 676 + xy: 868, 429 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Snow+Hill2 rotate: false - xy: 1084, 932 + xy: 868, 493 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Snow2 rotate: false - xy: 868, 740 + xy: 796, 493 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Snow3 rotate: false - xy: 940, 804 + xy: 868, 557 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Spices rotate: false - xy: 940, 740 + xy: 796, 301 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Spices+Plantation rotate: false - xy: 1012, 804 + xy: 868, 365 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Sri Pada rotate: false - xy: 1084, 864 + xy: 796, 233 size: 64, 60 orig: 64, 60 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone rotate: false - xy: 796, 548 + xy: 868, 301 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone+Quarry rotate: false - xy: 868, 612 + xy: 796, 169 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone+Quarry-Atomic era rotate: false - xy: 940, 676 + xy: 868, 237 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone+Quarry-Future era rotate: false - xy: 940, 676 + xy: 868, 237 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone+Quarry-Industrial era rotate: false - xy: 940, 676 + xy: 868, 237 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone+Quarry-Information era rotate: false - xy: 940, 676 + xy: 868, 237 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone+Quarry-Modern era rotate: false - xy: 940, 676 + xy: 868, 237 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone+QuarryT rotate: false - xy: 1012, 740 + xy: 796, 105 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone+QuarryT-Atomic era rotate: false - xy: 1084, 800 + xy: 868, 173 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone+QuarryT-Future era rotate: false - xy: 1084, 800 + xy: 868, 173 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone+QuarryT-Industrial era rotate: false - xy: 1084, 800 + xy: 868, 173 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone+QuarryT-Information era rotate: false - xy: 1084, 800 + xy: 868, 173 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Stone+QuarryT-Modern era rotate: false - xy: 1084, 800 + xy: 868, 173 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/StoneD rotate: false - xy: 796, 484 + xy: 868, 109 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/StoneD+Quarry rotate: false - xy: 868, 548 + xy: 944, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/StoneD+Quarry-Atomic era rotate: false - xy: 940, 612 + xy: 1016, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/StoneD+Quarry-Future era rotate: false - xy: 940, 612 + xy: 1016, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/StoneD+Quarry-Industrial era rotate: false - xy: 940, 612 + xy: 1016, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/StoneD+Quarry-Information era rotate: false - xy: 940, 612 + xy: 1016, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/StoneD+Quarry-Modern era rotate: false - xy: 940, 612 + xy: 1016, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Sugar rotate: false - xy: 1012, 676 + xy: 1088, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Sugar+Plantation rotate: false - xy: 1084, 728 + xy: 940, 988 size: 64, 64 orig: 64, 64 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Terrace farm rotate: false - xy: 796, 420 + xy: 1160, 1060 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Trading post rotate: false - xy: 868, 484 + xy: 940, 924 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Trading post-Atomic era rotate: false - xy: 940, 548 + xy: 1012, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Trading post-Future era rotate: false - xy: 940, 548 + xy: 1012, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Trading post-Industrial era rotate: false - xy: 940, 548 + xy: 1012, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Trading post-Information era rotate: false - xy: 940, 548 + xy: 1012, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Trading post-Modern era rotate: false - xy: 940, 548 + xy: 1012, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Trading postT rotate: false - xy: 1012, 612 + xy: 940, 860 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Trading postT-Atomic era rotate: false - xy: 1084, 664 + xy: 1012, 932 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Trading postT-Future era rotate: false - xy: 1084, 664 + xy: 1012, 932 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Trading postT-Industrial era rotate: false - xy: 1084, 664 + xy: 1012, 932 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Trading postT-Information era rotate: false - xy: 1084, 664 + xy: 1012, 932 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Trading postT-Modern era rotate: false - xy: 1084, 664 + xy: 1012, 932 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles rotate: false - xy: 796, 356 + xy: 1084, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles+Camp rotate: false - xy: 868, 420 + xy: 940, 796 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles+Camp-Atomic era rotate: false - xy: 940, 484 + xy: 1012, 868 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles+Camp-Future era rotate: false - xy: 940, 484 + xy: 1012, 868 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles+Camp-Industrial era rotate: false - xy: 940, 484 + xy: 1012, 868 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles+Camp-Information era rotate: false - xy: 940, 484 + xy: 1012, 868 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles+Camp-Modern era rotate: false - xy: 940, 484 + xy: 1012, 868 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles+CampT rotate: false - xy: 1012, 548 + xy: 1084, 932 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles+CampT-Atomic era rotate: false - xy: 1084, 600 + xy: 1156, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles+CampT-Future era rotate: false - xy: 1084, 600 + xy: 1156, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles+CampT-Industrial era rotate: false - xy: 1084, 600 + xy: 1156, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles+CampT-Information era rotate: false - xy: 1084, 600 + xy: 1156, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Truffles+CampT-Modern era rotate: false - xy: 1084, 600 + xy: 1156, 996 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Tundra rotate: false - xy: 796, 292 + xy: 940, 732 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Tundra+Hill rotate: false - xy: 1012, 484 + xy: 1156, 932 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Tundra+Hill+Fallout rotate: false - xy: 796, 228 + xy: 1012, 740 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Tundra+Hill+Fallout2 rotate: false - xy: 868, 292 + xy: 1084, 804 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Tundra+Hill2 rotate: false - xy: 1084, 536 + xy: 940, 668 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Tundra2 rotate: false - xy: 868, 356 + xy: 1012, 804 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Tundra3 rotate: false - xy: 940, 420 + xy: 1084, 868 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Uluru rotate: false - xy: 940, 356 + xy: 1156, 868 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Uranium rotate: false - xy: 1012, 420 + xy: 940, 604 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Whales rotate: false - xy: 1084, 472 + xy: 1012, 676 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Whales+Fishing Boats rotate: false - xy: 796, 164 + xy: 1084, 740 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Whales+Fishing Boats-Atomic era rotate: false - xy: 868, 228 + xy: 1156, 804 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Whales+Fishing Boats-Future era rotate: false - xy: 868, 228 + xy: 1156, 804 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Whales+Fishing Boats-Industrial era rotate: false - xy: 868, 228 + xy: 1156, 804 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Whales+Fishing Boats-Information era rotate: false - xy: 868, 228 + xy: 1156, 804 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Whales+Fishing Boats-Modern era rotate: false - xy: 868, 228 + xy: 1156, 804 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Wheat rotate: false - xy: 940, 292 + xy: 940, 540 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Wine rotate: false - xy: 1012, 356 + xy: 1012, 612 size: 64, 56 orig: 64, 56 offset: 0, 0 index: -1 TileSets/HexaRealm/Tiles/Wine+Plantation rotate: false - xy: 1084, 408 + xy: 1084, 676 size: 64, 56 orig: 64, 56 offset: 0, 0 diff --git a/android/assets/game.png b/android/assets/game.png index 456c9000cf..5880b80050 100644 Binary files a/android/assets/game.png and b/android/assets/game.png differ diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 0a3cd91e40..8f523cc8a4 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -542,6 +542,7 @@ Username = Multiplayer = Could not download game! = Could not upload game! = +Retry = Join game = Invalid game ID! = Copy user ID = @@ -577,6 +578,7 @@ You can only resign if it's your turn = [civName] resigned and is now controlled by AI = Last refresh: [time] [timeUnit] ago = Current Turn: [civName] since [time] [timeUnit] ago = +Seconds = Minutes = Hours = Days = @@ -1346,7 +1348,7 @@ Choose name for [unitName] = # Multiplayer Turn Checker Service Enable out-of-game turn notifications = -Time between turn checks out-of-game (in minutes) = +Out-of-game, update status of all games every: = Show persistent notification for turn notifier service = Take user ID from clipboard = Doing this will reset your current user ID to the clipboard contents - are you sure? = @@ -1355,6 +1357,9 @@ Invalid ID! = # Multiplayer options menu +Enable multiplayer status button in singleplayer games = +Update status of currently played game every: = +In-game, update status of all games every: = Server address = Reset to Dropbox = Check connection to server = diff --git a/android/src/com/unciv/app/AndroidLauncher.kt b/android/src/com/unciv/app/AndroidLauncher.kt index 258a27fb48..99ade90bc8 100644 --- a/android/src/com/unciv/app/AndroidLauncher.kt +++ b/android/src/com/unciv/app/AndroidLauncher.kt @@ -69,9 +69,10 @@ open class AndroidLauncher : AndroidApplication() { override fun onPause() { if (UncivGame.isCurrentInitialized() && UncivGame.Current.isGameInfoInitialized() - && UncivGame.Current.settings.multiplayerTurnCheckerEnabled + && UncivGame.Current.settings.multiplayer.turnCheckerEnabled && UncivGame.Current.gameSaver.getMultiplayerSaves().any()) { - MultiplayerTurnCheckWorker.startTurnChecker(applicationContext, UncivGame.Current.gameSaver, UncivGame.Current.gameInfo, UncivGame.Current.settings) + MultiplayerTurnCheckWorker.startTurnChecker(applicationContext, UncivGame.Current.gameSaver, + UncivGame.Current.gameInfo, UncivGame.Current.settings.multiplayer) } super.onPause() } diff --git a/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt b/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt index dbb4c21a91..df2b9d0fa8 100644 --- a/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt +++ b/android/src/com/unciv/app/MultiplayerTurnCheckWorker.kt @@ -24,11 +24,13 @@ import com.unciv.logic.GameSaver import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.models.metadata.GameSettings import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver +import com.unciv.models.metadata.GameSettingsMultiplayer import kotlinx.coroutines.runBlocking import java.io.FileNotFoundException import java.io.PrintWriter import java.io.StringWriter import java.io.Writer +import java.time.Duration import java.util.* import java.util.concurrent.TimeUnit @@ -59,8 +61,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame private const val PERSISTENT_NOTIFICATION_ENABLED = "PERSISTENT_NOTIFICATION_ENABLED" private const val FILE_STORAGE = "FILE_STORAGE" - fun enqueue(appContext: Context, - delayInMinutes: Int, inputData: Data) { + fun enqueue(appContext: Context, delay: Duration, inputData: Data) { val constraints = Constraints.Builder() // If no internet is available, worker waits before becoming active. @@ -69,7 +70,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame val checkTurnWork = OneTimeWorkRequestBuilder() .setConstraints(constraints) - .setInitialDelay(delayInMinutes.toLong(), TimeUnit.MINUTES) + .setInitialDelay(delay.seconds, TimeUnit.SECONDS) .addTag(WORK_TAG) .setInputData(inputData) .build() @@ -123,7 +124,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame * The persistent notification is purely for informational reasons. * It is not technically necessary for the Worker, since it is not a Service. */ - fun showPersistentNotification(appContext: Context, lastTimeChecked: String, checkPeriod: String) { + fun showPersistentNotification(appContext: Context, lastTimeChecked: String, checkPeriod: Duration) { val flags = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) FLAG_IMMUTABLE else 0) or FLAG_UPDATE_CURRENT val pendingIntent: PendingIntent = @@ -136,7 +137,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame .setContentTitle(appContext.resources.getString(R.string.Notify_Persist_Short) + " " + lastTimeChecked) .setStyle(NotificationCompat.BigTextStyle() .bigText(appContext.resources.getString(R.string.Notify_Persist_Long_P1) + " " + - appContext.resources.getString(R.string.Notify_Persist_Long_P2) + " " + checkPeriod + " " + appContext.resources.getString(R.string.Notify_Persist_Long_P2) + " " + checkPeriod.seconds / 60f + " " + appContext.resources.getString(R.string.Notify_Persist_Long_P3) + " " + appContext.resources.getString(R.string.Notify_Persist_Long_P4))) .setSmallIcon(R.drawable.uncivnotification) @@ -180,7 +181,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame } } - fun startTurnChecker(applicationContext: Context, gameSaver: GameSaver, currentGameInfo: GameInfo, settings: GameSettings) { + fun startTurnChecker(applicationContext: Context, gameSaver: GameSaver, currentGameInfo: GameInfo, settings: GameSettingsMultiplayer) { Log.i(LOG_TAG, "startTurnChecker") val gameFiles = gameSaver.getMultiplayerSaves() val gameIds = Array(gameFiles.count()) {""} @@ -211,17 +212,16 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame } } else { val inputData = workDataOf(Pair(FAIL_COUNT, 0), Pair(GAME_ID, gameIds), Pair(GAME_NAME, gameNames), - Pair(USER_ID, settings.userId), Pair(CONFIGURED_DELAY, settings.multiplayerTurnCheckerDelayInMinutes), - Pair(PERSISTENT_NOTIFICATION_ENABLED, settings.multiplayerTurnCheckerPersistentNotificationEnabled), - Pair(FILE_STORAGE, settings.multiplayerServer)) + Pair(USER_ID, settings.userId), Pair(CONFIGURED_DELAY, settings.turnCheckerDelay.seconds), + Pair(PERSISTENT_NOTIFICATION_ENABLED, settings.turnCheckerPersistentNotificationEnabled), + Pair(FILE_STORAGE, settings.server)) - if (settings.multiplayerTurnCheckerPersistentNotificationEnabled) { - showPersistentNotification(applicationContext, - "—", settings.multiplayerTurnCheckerDelayInMinutes.toString()) + if (settings.turnCheckerPersistentNotificationEnabled) { + showPersistentNotification(applicationContext, "—", settings.turnCheckerDelay) } Log.d(LOG_TAG, "startTurnChecker enqueue") // Initial check always happens after a minute, ignoring delay config. Better user experience this way. - enqueue(applicationContext, 1, inputData) + enqueue(applicationContext, Duration.ofMinutes(1), inputData) } } @@ -247,6 +247,11 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame } } } + + private fun getConfiguredDelay(inputData: Data): Duration { + val delay = inputData.getLong(CONFIGURED_DELAY, Duration.ofMinutes(5).seconds) + return Duration.ofSeconds(delay) + } } /** @@ -270,7 +275,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame override fun doWork(): Result = runBlocking { Log.i(LOG_TAG, "doWork") val showPersistNotific = inputData.getBoolean(PERSISTENT_NOTIFICATION_ENABLED, true) - val configuredDelay = inputData.getInt(CONFIGURED_DELAY, 5) + val configuredDelay = getConfiguredDelay(inputData) val fileStorage = inputData.getString(FILE_STORAGE) try { @@ -350,13 +355,12 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame } return@runBlocking Result.failure() } else { - if (showPersistNotific) { showPersistentNotification(applicationContext, - applicationContext.resources.getString(R.string.Notify_Error_Retrying), configuredDelay.toString()) } + if (showPersistNotific) { showPersistentNotification(applicationContext, applicationContext.resources.getString(R.string.Notify_Error_Retrying), configuredDelay) } // If check fails, retry in one minute. // Makes sense, since checks only happen if Internet is available in principle. // Therefore a failure means either a problem with the GameInfo or with Dropbox. val inputDataFailIncrease = Data.Builder().putAll(inputData).putInt(FAIL_COUNT, failCount + 1).build() - enqueue(applicationContext, 1, inputDataFailIncrease) + enqueue(applicationContext, Duration.ofMinutes(1), inputDataFailIncrease) } } catch (outOfMemory: OutOfMemoryError){ // no point in trying multiple times if this was an oom error return@runBlocking Result.failure() @@ -379,8 +383,7 @@ class MultiplayerTurnCheckWorker(appContext: Context, workerParams: WorkerParame } val displayTime = "$hour:$minute" - showPersistentNotification(applicationContext, displayTime, - inputData.getInt(CONFIGURED_DELAY, 5).toString()) + showPersistentNotification(applicationContext, displayTime, getConfiguredDelay(inputData)) } private fun showErrorNotification(stackTraceString: String) { diff --git a/build.gradle.kts b/build.gradle.kts index 132990978c..6cb83107a4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -121,6 +121,7 @@ project(":core") { dependencies { "implementation"("com.badlogicgames.gdx:gdx:$gdxVersion") "implementation"("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1") + "implementation"("org.jetbrains.kotlin:kotlin-reflect:${com.unciv.build.BuildConfig.kotlinVersion}") } diff --git a/core/src/com/unciv/MainMenuScreen.kt b/core/src/com/unciv/MainMenuScreen.kt index 5ab159b8d2..25a4093caa 100644 --- a/core/src/com/unciv/MainMenuScreen.kt +++ b/core/src/com/unciv/MainMenuScreen.kt @@ -201,13 +201,20 @@ class MainMenuScreen: BaseScreen() { return@launchCrashHandling } - postCrashHandlingRunnable { /// ... and load it into the screen on main thread for GL context + if (savedGame.gameParameters.isOnlineMultiplayer) { try { - game.loadGame(savedGame) - dispose() + game.onlineMultiplayer.loadGame(savedGame) } catch (oom: OutOfMemoryError) { outOfMemory() } + } else { + postCrashHandlingRunnable { /// ... and load it into the screen on main thread for GL context + try { + game.loadGame(savedGame) + } catch (oom: OutOfMemoryError) { + outOfMemory() + } + } } } } diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 1c31c8c32f..4147cdb446 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -13,19 +13,20 @@ import com.unciv.models.metadata.GameSettings import com.unciv.models.ruleset.RulesetCache import com.unciv.models.tilesets.TileSetCache import com.unciv.models.translations.Translations -import com.unciv.ui.LanguagePickerScreen import com.unciv.ui.audio.MusicController import com.unciv.ui.audio.MusicMood import com.unciv.ui.utils.* import com.unciv.ui.worldscreen.PlayerReadyScreen import com.unciv.ui.worldscreen.WorldScreen import com.unciv.logic.multiplayer.OnlineMultiplayer +import com.unciv.ui.LanguagePickerScreen import com.unciv.ui.audio.Sounds import com.unciv.ui.crashhandling.closeExecutors import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.multiplayer.LoadDeepLinkScreen +import com.unciv.ui.multiplayer.MultiplayerHelpers import com.unciv.ui.popup.Popup import kotlinx.coroutines.runBlocking import java.util.* @@ -128,8 +129,8 @@ class UncivGame(parameters: UncivGameParameters) : Game() { translations.loadPercentageCompleteOfLanguages() TileSetCache.loadTileSetConfigs(printOutput = true) - if (settings.userId.isEmpty()) { // assign permanent user id - settings.userId = UUID.randomUUID().toString() + if (settings.multiplayer.userId.isEmpty()) { // assign permanent user id + settings.multiplayer.userId = UUID.randomUUID().toString() settings.save() } @@ -201,7 +202,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { val mainMenu = MainMenuScreen() setScreen(mainMenu) val popup = Popup(mainMenu) - popup.addGoodSizedLabel("Failed to load multiplayer game: ${ex.message ?: ex::class.simpleName}") + popup.addGoodSizedLabel(MultiplayerHelpers.getLoadExceptionMessage(ex)) popup.row() popup.addCloseButton() popup.open() diff --git a/core/src/com/unciv/json/DurationSerializer.kt b/core/src/com/unciv/json/DurationSerializer.kt new file mode 100644 index 0000000000..8be2ddcec4 --- /dev/null +++ b/core/src/com/unciv/json/DurationSerializer.kt @@ -0,0 +1,16 @@ +package com.unciv.json + +import com.badlogic.gdx.utils.Json +import com.badlogic.gdx.utils.Json.Serializer +import com.badlogic.gdx.utils.JsonValue +import java.time.Duration + +class DurationSerializer : Serializer { + override fun write(json: Json, duration: Duration, knownType: Class<*>?) { + json.writeValue(duration.toString()) + } + + override fun read(json: Json, jsonData: JsonValue, type: Class<*>?): Duration { + return Duration.parse(jsonData.asString()) + } +} diff --git a/core/src/com/unciv/json/UncivJson.kt b/core/src/com/unciv/json/UncivJson.kt index 69da6bcb9d..2fc6742400 100644 --- a/core/src/com/unciv/json/UncivJson.kt +++ b/core/src/com/unciv/json/UncivJson.kt @@ -3,6 +3,7 @@ package com.unciv.json import com.badlogic.gdx.Gdx import com.badlogic.gdx.files.FileHandle import com.badlogic.gdx.utils.Json +import java.time.Duration /** @@ -13,6 +14,7 @@ fun json() = Json().apply { ignoreUnknownFields = true setSerializer(HashMapVector2.getSerializerClass(), HashMapVector2.createSerializer()) + setSerializer(Duration::class.java, DurationSerializer()) } /** @@ -30,4 +32,4 @@ fun Json.fromJsonFile(tClass: Class, file: FileHandle): T { } catch (exception:Exception){ throw Exception("Could not parse json of file ${file.name()}", exception) } -} \ No newline at end of file +} diff --git a/core/src/com/unciv/logic/GameInfo.kt b/core/src/com/unciv/logic/GameInfo.kt index c03f073ba7..5b9c52551b 100644 --- a/core/src/com/unciv/logic/GameInfo.kt +++ b/core/src/com/unciv/logic/GameInfo.kt @@ -103,7 +103,7 @@ class GameInfo { fun getPlayerToViewAs(): CivilizationInfo { if (!gameParameters.isOnlineMultiplayer) return currentPlayerCiv // non-online, play as human player - val userId = UncivGame.Current.settings.userId + val userId = UncivGame.Current.settings.multiplayer.userId // Iterating on all civs, starting from the the current player, gives us the one that will have the next turn // This allows multiple civs from the same UserID @@ -227,7 +227,7 @@ class GameInfo { || turns < simulateMaxTurns && simulateUntilWin // For multiplayer, if there are 3+ players and one is defeated or spectator, // we'll want to skip over their turn - || gameParameters.isOnlineMultiplayer && (thisPlayer.isDefeated() || thisPlayer.isSpectator() && thisPlayer.playerId != UncivGame.Current.settings.userId) + || gameParameters.isOnlineMultiplayer && (thisPlayer.isDefeated() || thisPlayer.isSpectator() && thisPlayer.playerId != UncivGame.Current.settings.multiplayer.userId) ) { if (!thisPlayer.isDefeated() || thisPlayer.isBarbarian()) { NextTurnAutomation.automateCivMoves(thisPlayer) diff --git a/core/src/com/unciv/logic/GameSaver.kt b/core/src/com/unciv/logic/GameSaver.kt index eb7544cb00..e0abf4e104 100644 --- a/core/src/com/unciv/logic/GameSaver.kt +++ b/core/src/com/unciv/logic/GameSaver.kt @@ -3,10 +3,13 @@ package com.unciv.logic import com.badlogic.gdx.Files import com.badlogic.gdx.Gdx import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.utils.JsonReader import com.unciv.UncivGame import com.unciv.json.fromJsonFile import com.unciv.json.json import com.unciv.models.metadata.GameSettings +import com.unciv.models.metadata.doMigrations +import com.unciv.models.metadata.isMigrationNecessary import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.saves.Gzip @@ -164,22 +167,23 @@ class GameSaver( fun getGeneralSettings(): GameSettings { val settingsFile = getGeneralSettingsFile() - val settings: GameSettings = - if (!settingsFile.exists()) - GameSettings().apply { isFreshlyCreated = true } - else try { - json().fromJson(GameSettings::class.java, settingsFile) - } catch (ex: Exception) { - // I'm not sure of the circumstances, - // but some people were getting null settings, even though the file existed??? Very odd. - // ...Json broken or otherwise unreadable is the only possible reason. - println("Error reading settings file: ${ex.localizedMessage}") - println(" cause: ${ex.cause}") - GameSettings().apply { isFreshlyCreated = true } + var settings: GameSettings? = null + if (settingsFile.exists()) { + try { + settings = json().fromJson(GameSettings::class.java, settingsFile) + if (settings.isMigrationNecessary()) { + settings.doMigrations(JsonReader().parse(settingsFile)) } + } catch (ex: Exception) { + // I'm not sure of the circumstances, + // but some people were getting null settings, even though the file existed??? Very odd. + // ...Json broken or otherwise unreadable is the only possible reason. + println("Error reading settings file: ${ex.localizedMessage}") + println(" cause: ${ex.cause}") + } + } - - return settings + return settings ?: GameSettings().apply { isFreshlyCreated = true } } fun setGeneralSettings(gameSettings: GameSettings) { diff --git a/core/src/com/unciv/logic/event/EventBus.kt b/core/src/com/unciv/logic/event/EventBus.kt index 2353683f66..05b1fa2277 100644 --- a/core/src/com/unciv/logic/event/EventBus.kt +++ b/core/src/com/unciv/logic/event/EventBus.kt @@ -11,10 +11,10 @@ import kotlin.reflect.KClass * **Do not use this for every communication between modules**. Only use it for events that might be relevant for a wide variety of modules or * significantly affect the game state, i.e. buildings being created, units dying, new multiplayer data available, etc. */ -@Suppress("UNCHECKED_CAST") // Through using the "map by KClass", we ensure all methods get called with correct argument type +@Suppress("UNCHECKED_CAST") // Through using the "map by KClass" pattern, we ensure all methods get called with correct argument type object EventBus { - // This is one of the simplest implementations possible. If it is ever useful, this could be changed to - private val receivers = mutableMapOf, MutableList>>() + + private val listeners = mutableMapOf, MutableList>>() /** * Only use this from the render thread. For example, in coroutines launched by [com.unciv.ui.crashhandling.launchCrashHandling] @@ -24,29 +24,65 @@ object EventBus { * but doing it like this makes debugging slightly easier. */ fun send(event: T) { - val listeners = receivers[event::class] - if (listeners == null) return - val iterator = listeners.listIterator() - while (iterator.hasNext()) { - val listener = iterator.next() as EventListener - val eventHandler = listener.eventHandler.get() - if (eventHandler == null) { - // eventHandler got garbage collected, prevent WeakListener memory leak - iterator.remove() - continue - } - val filter = listener.filter.get() + val eventListeners = getListeners(event::class) as Set> + for (listener in eventListeners) { + val filter = listener.filter if (filter == null || filter(event)) { - eventHandler(event) + listener.eventHandler(event) } } } - private fun receive(eventClass: KClass, filter: ((T) -> Boolean)? = null, eventHandler: (T) -> Unit) { - if (receivers[eventClass] == null) { - receivers[eventClass] = mutableListOf() + private fun getListeners(eventClass: KClass): Set> { + val classesToListenTo = getClassesToListenTo(eventClass) // This is always a KClass + // Set because we don't want to notify the same listener multiple times + return buildSet { + for (classToListenTo in classesToListenTo) { + addAll(updateActiveListeners(classToListenTo)) + } + } + } + + /** To be able to listen to an event class and get notified even when child classes are sent as an event */ + private fun getClassesToListenTo(eventClass: KClass): List> { + val superClasses = eventClass.supertypes.map { it.classifier as KClass<*> }.filter { it != Any::class } + return superClasses + eventClass + } + + /** Removes all listeners whose WeakReference got collected and returns the ones that are still active */ + private fun updateActiveListeners(eventClass: KClass<*>): List> { + return buildList { + val listenersWeak = listeners[eventClass] ?: return listOf() + val iterator = listenersWeak.listIterator() + while (iterator.hasNext()) { + val listener = iterator.next() + val eventHandler = listener.eventHandler.get() + if (eventHandler == null) { + // eventHandler got garbage collected, prevent WeakListener memory leak + iterator.remove() + } else { + add(EventListener(eventHandler, listener.filter.get())) + } + } + } + } + + + private fun receive(eventClass: KClass, filter: ((T) -> Boolean)? = null, eventHandler: (T) -> Unit) { + if (listeners[eventClass] == null) { + listeners[eventClass] = mutableListOf() + } + listeners[eventClass]!!.add(EventListenerWeakReference(eventHandler, filter)) + } + + private fun cleanUp(eventHandlers: Map, MutableList>) { + for ((kClass, toRemove) in eventHandlers) { + val registeredListeners = listeners.get(kClass) + registeredListeners?.removeIf { + val eventHandler = it.eventHandler.get() + eventHandler == null || (eventHandler as Any) in toRemove + } } - receivers[eventClass]!!.add(EventListener(eventHandler, filter)) } /** @@ -63,8 +99,16 @@ object EventBus { * // do something when the event is received. * } * } + * + * // Optional + * cleanup() { + * events.stopReceiving() + * } * } * ``` + * + * The [stopReceiving] call is optional. Event listeners will be automatically garbage collected. However, garbage collection is non-deterministic, so it's + * possible that the events keep being received for quite a while even after a class is unused. [stopReceiving] immediately cleans up all listeners. * * To have event listeners automatically garbage collected, we need to use [WeakReference]s in the event bus. For that to work, though, the class * that wants to receive events needs to hold references to its own event listeners. [EventReceiver] allows to do that while also providing the @@ -72,10 +116,12 @@ object EventBus { */ class EventReceiver { - val eventHandlers: MutableList = mutableListOf() + val eventHandlers = mutableMapOf, MutableList>() val filters: MutableList = mutableListOf() /** + * Listen to the event with the given [eventClass] and all events that subclass it. Use [stopReceiving] to stop listening to all events. + * * The listeners will always be called on the main GDX render thread. * * @param T The event class holding the data of the event, or simply [Event]. @@ -84,14 +130,34 @@ object EventBus { if (filter != null) { filters.add(filter) } - eventHandlers.add(eventHandler) + if (eventHandlers[eventClass] == null) { + eventHandlers[eventClass] = mutableListOf() + } + eventHandlers[eventClass]!!.add(eventHandler) + EventBus.receive(eventClass, filter, eventHandler) } + + /** + * Stops receiving all events, cleaning up all event listeners. + */ + fun stopReceiving() { + cleanUp(eventHandlers) + eventHandlers.clear() + filters.clear() + } } } +/** Exists so that eventHandlers and filters do not get garbage-collected *while* we are passing them around in here, + * otherwise we would only need [EventListenerWeakReference] */ private class EventListener( + val eventHandler: (T) -> Unit, + val filter: ((T) -> Boolean)? = null +) + +private class EventListenerWeakReference( eventHandler: (T) -> Unit, filter: ((T) -> Boolean)? = null ) { diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 7c30ec3d18..0830eac6b7 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -1,11 +1,9 @@ package com.unciv.logic.multiplayer import com.badlogic.gdx.files.FileHandle -import com.unciv.Constants import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.GameInfoPreview -import com.unciv.logic.GameSaver import com.unciv.logic.civilization.PlayerType import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached @@ -14,48 +12,65 @@ import com.unciv.ui.crashhandling.CRASH_HANDLING_DAEMON_SCOPE import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.utils.isLargerThan -import java.util.* -import java.util.concurrent.atomic.AtomicReference -import kotlinx.coroutines.* +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch import java.io.FileNotFoundException import java.time.Duration import java.time.Instant +import java.util.* +import java.util.concurrent.atomic.AtomicReference -/** @see getRefreshInterval */ -private const val CUSTOM_SERVER_REFRESH_INTERVAL = 30L - /** * How often files can be checked for new multiplayer games (could be that the user modified their file system directly). More checks within this time period * will do nothing. */ -private val FILE_UPDATE_THROTTLE_INTERVAL = Duration.ofSeconds(60) +private val FILE_UPDATE_THROTTLE_PERIOD = Duration.ofSeconds(60) /** * Provides multiplayer functionality to the rest of the game. * * See the file of [com.unciv.logic.multiplayer.MultiplayerGameAdded] for all available [EventBus] events. */ -class OnlineMultiplayer() { +class OnlineMultiplayer { private val gameSaver = UncivGame.Current.gameSaver + private val onlineGameSaver = OnlineMultiplayerGameSaver() + private val savedGames: MutableMap = Collections.synchronizedMap(mutableMapOf()) - private var lastFileUpdate: AtomicReference = AtomicReference() + + private val lastFileUpdate: AtomicReference = AtomicReference() + private val lastAllGamesRefresh: AtomicReference = AtomicReference() + private val lastCurGameRefresh: AtomicReference = AtomicReference() val games: Set get() = savedGames.values.toSet() init { flow { while (true) { - delay(getRefreshInterval().toMillis()) + delay(500) - // TODO will be used later - // requestUpdate() + val currentGame = getCurrentGame() + val multiplayerSettings = UncivGame.Current.settings.multiplayer + if (currentGame != null) { + throttle(lastCurGameRefresh, multiplayerSettings.currentGameRefreshDelay, {}) { currentGame.requestUpdate() } + } + + val doNotUpdate = if (currentGame == null) listOf() else listOf(currentGame) + throttle(lastAllGamesRefresh, multiplayerSettings.allGameRefreshDelay, {}) { requestUpdate(doNotUpdate = doNotUpdate) } } }.launchIn(CRASH_HANDLING_DAEMON_SCOPE) } + private fun getCurrentGame(): OnlineMultiplayerGame? { + if (UncivGame.isCurrentInitialized() && UncivGame.Current.isGameInfoInitialized()) { + return getGameByGameId(UncivGame.Current.gameInfo.gameId) + } else { + return null + } + } + /** * Requests an update of all multiplayer game state. Does automatic throttling to try to prevent hitting rate limits. * @@ -63,26 +78,24 @@ class OnlineMultiplayer() { * * Fires: [MultiplayerGameUpdateStarted], [MultiplayerGameUpdated], [MultiplayerGameUpdateUnchanged], [MultiplayerGameUpdateFailed] */ - fun requestUpdate(forceUpdate: Boolean = false) = launchCrashHandling("Update all multiplayer games") { - fun alwaysUpdate(instant: Instant?): Boolean = true + fun requestUpdate(forceUpdate: Boolean = false, doNotUpdate: List = listOf()) { + launchCrashHandling("Update all multiplayer games") { + val fileThrottleInterval = if (forceUpdate) Duration.ZERO else FILE_UPDATE_THROTTLE_PERIOD + // An exception only happens here if the files can't be listed, should basically never happen + throttle(lastFileUpdate, fileThrottleInterval, {}, action = ::updateSavesFromFiles) - safeUpdateIf(lastFileUpdate, if (forceUpdate) ::alwaysUpdate else ::fileUpdateNeeded, ::updateSavesFromFiles, {}) { - // only happens if the files can't be listed, should basically never happen - throw it - } - - for (game in savedGames.values) { - launch { - game.requestUpdate(forceUpdate) + for (game in savedGames.values) { + if (game in doNotUpdate) continue + launch { + game.requestUpdate(forceUpdate) + } } } } - private fun fileUpdateNeeded(it: Instant?) = it == null || Duration.between(it, Instant.now()).isLargerThan(FILE_UPDATE_THROTTLE_INTERVAL) - private fun updateSavesFromFiles() { val saves = gameSaver.getMultiplayerSaves() - val removedSaves = savedGames.keys - saves + val removedSaves = savedGames.keys - saves.toSet() removedSaves.forEach(savedGames::remove) val newSaves = saves - savedGames.keys for (saveFile in newSaves) { @@ -98,12 +111,8 @@ class OnlineMultiplayer() { * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time */ suspend fun createGame(newGame: GameInfo) { - OnlineMultiplayerGameSaver().tryUploadGame(newGame, withPreview = true) - val newGamePreview = newGame.asPreview() - val file = gameSaver.saveGame(newGamePreview, newGamePreview.gameId) - val onlineMultiplayerGame = OnlineMultiplayerGame(file, newGamePreview, Instant.now()) - savedGames[file] = onlineMultiplayerGame - postCrashHandlingRunnable { EventBus.send(MultiplayerGameAdded(onlineMultiplayerGame.name)) } + onlineGameSaver.tryUploadGame(newGame, withPreview = true) + addGame(newGame) } /** @@ -117,16 +126,23 @@ class OnlineMultiplayer() { suspend fun addGame(gameId: String, gameName: String? = null): String { val saveFileName = if (gameName.isNullOrBlank()) gameId else gameName var gamePreview: GameInfoPreview - var fileHandle: FileHandle try { - gamePreview = OnlineMultiplayerGameSaver().tryDownloadGamePreview(gameId) - fileHandle = gameSaver.saveGame(gamePreview, saveFileName) + gamePreview = onlineGameSaver.tryDownloadGamePreview(gameId) } catch (ex: FileNotFoundException) { // Game is so old that a preview could not be found on dropbox lets try the real gameInfo instead - gamePreview = OnlineMultiplayerGameSaver().tryDownloadGame(gameId).asPreview() - fileHandle = gameSaver.saveGame(gamePreview, saveFileName) + gamePreview = onlineGameSaver.tryDownloadGame(gameId).asPreview() } - val game = OnlineMultiplayerGame(fileHandle, gamePreview, Instant.now()) + return addGame(gamePreview, saveFileName) + } + + private fun addGame(newGame: GameInfo) { + val newGamePreview = newGame.asPreview() + addGame(newGamePreview, newGamePreview.gameId) + } + + private fun addGame(preview: GameInfoPreview, saveFileName: String): String { + val fileHandle = gameSaver.saveGame(preview, saveFileName) + val game = OnlineMultiplayerGame(fileHandle, preview, Instant.now()) savedGames[fileHandle] = game postCrashHandlingRunnable { EventBus.send(MultiplayerGameAdded(game.name)) } return saveFileName @@ -136,8 +152,12 @@ class OnlineMultiplayer() { return savedGames.values.firstOrNull { it.name == name } } + fun getGameByGameId(gameId: String): OnlineMultiplayerGame? { + return savedGames.values.firstOrNull { it.preview?.gameId == gameId } + } + /** - * Resigns from the given multiplayer [gameId]. Can only resign if it's currently the user's turn, + * Resigns from the given multiplayer [game]. Can only resign if it's currently the user's turn, * to ensure that no one else can upload the game in the meantime. * * Fires [MultiplayerGameUpdated] @@ -147,12 +167,9 @@ class OnlineMultiplayer() { * @return false if it's not the user's turn and thus resigning did not happen */ suspend fun resign(game: OnlineMultiplayerGame): Boolean { - val preview = game.preview - if (preview == null) { - throw game.error!! - } + val preview = game.preview ?: throw game.error!! // download to work with the latest game state - val gameInfo = OnlineMultiplayerGameSaver().tryDownloadGame(preview.gameId) + val gameInfo = onlineGameSaver.tryDownloadGame(preview.gameId) val playerCiv = gameInfo.currentPlayerCiv if (!gameInfo.isUsersTurn()) { @@ -174,9 +191,8 @@ class OnlineMultiplayer() { val newPreview = gameInfo.asPreview() gameSaver.saveGame(newPreview, game.fileHandle) - OnlineMultiplayerGameSaver().tryUploadGame(gameInfo, withPreview = true) + onlineGameSaver.tryUploadGame(gameInfo, withPreview = true) game.doManualUpdate(newPreview) - postCrashHandlingRunnable { EventBus.send(MultiplayerGameUpdated(game.name, newPreview)) } return true } @@ -185,10 +201,7 @@ class OnlineMultiplayer() { * @throws FileNotFoundException if the file can't be found */ suspend fun loadGame(game: OnlineMultiplayerGame) { - val preview = game.preview - if (preview == null) { - throw game.error!! - } + val preview = game.preview ?: throw game.error!! loadGame(preview.gameId) } @@ -197,11 +210,42 @@ class OnlineMultiplayer() { * @throws FileNotFoundException if the file can't be found */ suspend fun loadGame(gameId: String) { - val gameInfo = OnlineMultiplayerGameSaver().tryDownloadGame(gameId) - gameInfo.isUpToDate = true + val gameInfo = downloadGame(gameId) + val preview = gameInfo.asPreview() + val onlineGame = getGameByGameId(gameId) + val onlinePreview = onlineGame?.preview + if (onlineGame == null) { + createGame(gameInfo) + } else if (onlinePreview != null && hasNewerGameState(preview, onlinePreview)){ + onlineGame.doManualUpdate(preview) + } postCrashHandlingRunnable { UncivGame.Current.loadGame(gameInfo) } } + /** + * Checks if the given game is current and loads it, otherwise loads the game from the server + */ + suspend fun loadGame(gameInfo: GameInfo) { + val gameId = gameInfo.gameId + val preview = onlineGameSaver.tryDownloadGamePreview(gameId) + if (hasLatestGameState(gameInfo, preview)) { + gameInfo.isUpToDate = true + postCrashHandlingRunnable { UncivGame.Current.loadGame(gameInfo) } + } else { + loadGame(gameId) + } + } + + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws FileNotFoundException if the file can't be found + */ + suspend fun downloadGame(gameId: String): GameInfo { + val latestGame = onlineGameSaver.tryDownloadGame(gameId) + latestGame.isUpToDate = true + return latestGame + } + /** * Deletes the game from disk, does not delete it remotely. * @@ -217,10 +261,7 @@ class OnlineMultiplayer() { * Fires [MultiplayerGameNameChanged] */ fun changeGameName(game: OnlineMultiplayerGame, newName: String) { - val oldPreview = game.preview - if (oldPreview == null) { - throw game.error!! - } + val oldPreview = game.preview ?: throw game.error!! val oldLastUpdate = game.lastUpdate val oldName = game.name @@ -230,53 +271,92 @@ class OnlineMultiplayer() { val newGame = OnlineMultiplayerGame(newFileHandle, oldPreview, oldLastUpdate) savedGames[newFileHandle] = newGame - EventBus.send(MultiplayerGameNameChanged(newName, oldName)) + EventBus.send(MultiplayerGameNameChanged(oldName, newName)) } + + /** + * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time + * @throws FileNotFoundException if the file can't be found + */ + suspend fun updateGame(gameInfo: GameInfo) { + onlineGameSaver.tryUploadGame(gameInfo, withPreview = true) + val game = getGameByGameId(gameInfo.gameId) + if (game == null) { + addGame(gameInfo) + } else { + game.doManualUpdate(gameInfo.asPreview()) + } + } + + /** + * Checks if [gameInfo] and [preview] are up-to-date with each other. + */ + fun hasLatestGameState(gameInfo: GameInfo, preview: GameInfoPreview): Boolean { + // TODO look into how to maybe extract interfaces to not make this take two different methods + return gameInfo.currentPlayer == preview.currentPlayer + && gameInfo.turns == preview.turns + } + + /** + * Checks if [preview1] has a more recent game state than [preview2] + */ + private fun hasNewerGameState(preview1: GameInfoPreview, preview2: GameInfoPreview): Boolean { + return preview1.turns > preview2.turns + } + } /** - * Calls the given [updateFun] only when [shouldUpdate] called with the current value of [lastUpdate] returns true. + * Calls the given [action] when [lastSuccessfulExecution] lies further in the past than [throttleInterval]. * - * Also updates [lastUpdate] to [Instant.now], but only when [updateFun] did not result in an exception. + * Also updates [lastSuccessfulExecution] to [Instant.now], but only when [action] did not result in an exception. * - * Any exception thrown by [updateFun] is propagated. + * Any exception thrown by [action] is propagated. * * @return true if the update happened */ -suspend fun safeUpdateIf( - lastUpdate: AtomicReference, - shouldUpdate: (Instant?) -> Boolean, - updateFun: suspend () -> T, - onUnchanged: () -> T, - onFailed: (Exception) -> T +suspend fun throttle( + lastSuccessfulExecution: AtomicReference, + throttleInterval: Duration, + onNoExecution: () -> T, + onFailed: (Exception) -> T = { throw it }, + action: suspend () -> T ): T { - val lastUpdateTime = lastUpdate.get() + val lastExecution = lastSuccessfulExecution.get() val now = Instant.now() - if (shouldUpdate(lastUpdateTime) && lastUpdate.compareAndSet(lastUpdateTime, now)) { - try { - return updateFun() - } catch (e: Exception) { - lastUpdate.compareAndSet(now, lastUpdateTime) - return onFailed(e) - } + val shouldRunAction = lastExecution == null || Duration.between(lastExecution, now).isLargerThan(throttleInterval) + return if (shouldRunAction) { + attemptAction(lastSuccessfulExecution, onNoExecution, onFailed, action) } else { - return onUnchanged() + onNoExecution() } } - -fun GameInfoPreview.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.userId -fun GameInfo.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.userId - /** - * How often all multiplayer games are refreshed in the background + * Attempts to run the [action], changing [lastSuccessfulExecution], but only if no other thread changed [lastSuccessfulExecution] in the meantime + * and [action] did not throw an exception. */ -private fun getRefreshInterval(): Duration { - val settings = UncivGame.Current.settings - val isDropbox = settings.multiplayerServer == Constants.dropboxMultiplayerServer - return if (isDropbox) { - Duration.ofMinutes(settings.multiplayerTurnCheckerDelayInMinutes.toLong()) +suspend fun attemptAction( + lastSuccessfulExecution: AtomicReference, + onNoExecution: () -> T, + onFailed: (Exception) -> T = { throw it }, + action: suspend () -> T +): T { + val lastExecution = lastSuccessfulExecution.get() + val now = Instant.now() + return if (lastSuccessfulExecution.compareAndSet(lastExecution, now)) { + try { + action() + } catch (e: Exception) { + lastSuccessfulExecution.compareAndSet(now, lastExecution) + onFailed(e) + } } else { - Duration.ofSeconds(CUSTOM_SERVER_REFRESH_INTERVAL) + onNoExecution() } } + + +fun GameInfoPreview.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.multiplayer.userId +fun GameInfo.isUsersTurn() = getCivilization(currentPlayer).playerId == UncivGame.Current.settings.multiplayer.userId + diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt index 1f62f00864..cbfae1afe3 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerEvents.kt @@ -3,52 +3,62 @@ package com.unciv.logic.multiplayer import com.unciv.logic.GameInfoPreview import com.unciv.logic.event.Event +interface HasMultiplayerGameName { + val name: String +} + +interface MultiplayerGameUpdateEnded : Event, HasMultiplayerGameName +interface MultiplayerGameUpdateSucceeded : Event, HasMultiplayerGameName { + val preview: GameInfoPreview +} + /** * Gets sent when a game was added. */ class MultiplayerGameAdded( - val name: String -) : Event + override val name: String +) : Event, HasMultiplayerGameName /** * Gets sent when a game successfully updated */ class MultiplayerGameUpdated( - val name: String, - val preview: GameInfoPreview, -) : Event + override val name: String, + override val preview: GameInfoPreview, +) : MultiplayerGameUpdateEnded, MultiplayerGameUpdateSucceeded /** * Gets sent when a game errored while updating */ class MultiplayerGameUpdateFailed( - val name: String, + override val name: String, val error: Exception -) : Event +) : MultiplayerGameUpdateEnded /** * Gets sent when a game updated successfully, but nothing changed */ class MultiplayerGameUpdateUnchanged( - val name: String -) : Event + override val name: String, + override val preview: GameInfoPreview +) : MultiplayerGameUpdateEnded, MultiplayerGameUpdateSucceeded /** * Gets sent when a game starts updating */ class MultiplayerGameUpdateStarted( - val name: String -) : Event + override val name: String +) : Event, HasMultiplayerGameName /** * Gets sent when a game's name got changed */ class MultiplayerGameNameChanged( - val name: String, - val oldName: String -) : Event + override val name: String, + val newName: String +) : Event, HasMultiplayerGameName /** * Gets sent when a game is deleted */ class MultiplayerGameDeleted( - val name: String -) : Event + override val name: String +) : Event, HasMultiplayerGameName diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt index dbc1b14831..48fd191f7f 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt @@ -16,9 +16,9 @@ import java.util.concurrent.atomic.AtomicReference /** @see getUpdateThrottleInterval */ -private const val DROPBOX_THROTTLE_INTERVAL = 8L +private const val DROPBOX_THROTTLE_PERIOD = 8L /** @see getUpdateThrottleInterval */ -private const val CUSTOM_SERVER_THROTTLE_INTERVAL = 1L +private const val CUSTOM_SERVER_THROTTLE_PERIOD = 1L class OnlineMultiplayerGame( val fileHandle: FileHandle, @@ -55,8 +55,7 @@ class OnlineMultiplayerGame( return previewFromFile } - private fun shouldUpdate(lastUpdateTime: Instant?): Boolean = - preview == null || error != null || lastUpdateTime == null || Duration.between(lastUpdateTime, Instant.now()).isLargerThan(getUpdateThrottleInterval()) + private fun needsUpdate(): Boolean = preview == null || error != null /** * Fires: [MultiplayerGameUpdateStarted], [MultiplayerGameUpdated], [MultiplayerGameUpdateUnchanged], [MultiplayerGameUpdateFailed] @@ -65,15 +64,18 @@ class OnlineMultiplayerGame( * @throws FileNotFoundException if the file can't be found */ suspend fun requestUpdate(forceUpdate: Boolean = false) { - fun alwaysUpdate(instant: Instant?): Boolean = true - val shouldUpdateFun = if (forceUpdate) ::alwaysUpdate else ::shouldUpdate val onUnchanged = { GameUpdateResult.UNCHANGED } val onError = { e: Exception -> error = e GameUpdateResult.FAILURE } postCrashHandlingRunnable { EventBus.send(MultiplayerGameUpdateStarted(name)) } - val updateResult = safeUpdateIf(lastOnlineUpdate, shouldUpdateFun, ::update, onUnchanged, onError) + val throttleInterval = if (forceUpdate) Duration.ZERO else getUpdateThrottleInterval() + val updateResult = if (forceUpdate || needsUpdate()) { + attemptAction(lastOnlineUpdate, onUnchanged, onError, ::update) + } else { + throttle(lastOnlineUpdate, throttleInterval, onUnchanged, onError, ::update) + } when (updateResult) { GameUpdateResult.UNCHANGED, GameUpdateResult.CHANGED -> error = null else -> {} @@ -81,7 +83,7 @@ class OnlineMultiplayerGame( val updateEvent = when (updateResult) { GameUpdateResult.CHANGED -> MultiplayerGameUpdated(name, preview!!) GameUpdateResult.FAILURE -> MultiplayerGameUpdateFailed(name, error!!) - GameUpdateResult.UNCHANGED -> MultiplayerGameUpdateUnchanged(name) + GameUpdateResult.UNCHANGED -> MultiplayerGameUpdateUnchanged(name, preview!!) } postCrashHandlingRunnable { EventBus.send(updateEvent) } } @@ -99,6 +101,7 @@ class OnlineMultiplayerGame( lastOnlineUpdate.set(Instant.now()) error = null preview = gameInfo + postCrashHandlingRunnable { EventBus.send(MultiplayerGameUpdated(name, gameInfo)) } } override fun equals(other: Any?): Boolean = other is OnlineMultiplayerGame && fileHandle == other.fileHandle @@ -113,6 +116,6 @@ private enum class GameUpdateResult { * How often games can be checked for remote updates. More attempted checks within this time period will do nothing. */ private fun getUpdateThrottleInterval(): Duration { - val isDropbox = UncivGame.Current.settings.multiplayerServer == Constants.dropboxMultiplayerServer - return Duration.ofSeconds(if (isDropbox) DROPBOX_THROTTLE_INTERVAL else CUSTOM_SERVER_THROTTLE_INTERVAL) + val isDropbox = UncivGame.Current.settings.multiplayer.server == Constants.dropboxMultiplayerServer + return Duration.ofSeconds(if (isDropbox) DROPBOX_THROTTLE_PERIOD else CUSTOM_SERVER_THROTTLE_PERIOD) } diff --git a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerGameSaver.kt b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerGameSaver.kt index 5f8d726008..a184b7e002 100644 --- a/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerGameSaver.kt +++ b/core/src/com/unciv/logic/multiplayer/storage/OnlineMultiplayerGameSaver.kt @@ -21,21 +21,25 @@ class OnlineMultiplayerGameSaver( private var fileStorageIdentifier: String? = null ) { fun fileStorage(): FileStorage { - val identifier = if (fileStorageIdentifier == null) UncivGame.Current.settings.multiplayerServer else fileStorageIdentifier + val identifier = if (fileStorageIdentifier == null) UncivGame.Current.settings.multiplayer.server else fileStorageIdentifier return if (identifier == Constants.dropboxMultiplayerServer) DropBox else UncivServerFileStorage(identifier!!) } /** @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time */ suspend fun tryUploadGame(gameInfo: GameInfo, withPreview: Boolean) { - // We upload the gamePreview before we upload the game as this - // seems to be necessary for the kick functionality + val zippedGameInfo = GameSaver.gameInfoToString(gameInfo, forceZip = true) + fileStorage().saveFileData(gameInfo.gameId, zippedGameInfo, true) + + // We upload the preview after the game because otherwise the following race condition will happen: + // Current player ends turn -> Uploads Game Preview + // Other player checks for updates -> Downloads Game Preview + // Current player starts game upload + // Other player sees update in preview -> Downloads game, gets old state + // Current player finishes uploading game if (withPreview) { tryUploadGamePreview(gameInfo.asPreview()) } - - val zippedGameInfo = GameSaver.gameInfoToString(gameInfo, forceZip = true) - fileStorage().saveFileData(gameInfo.gameId, zippedGameInfo, true) } @Suppress("MemberVisibilityCanBePrivate") @@ -70,4 +74,4 @@ class OnlineMultiplayerGameSaver( val zippedGameInfo = fileStorage().loadFileData("${gameId}_Preview") return GameSaver.gameInfoPreviewFromString(zippedGameInfo) } -} \ No newline at end of file +} diff --git a/core/src/com/unciv/models/metadata/GameSettings.kt b/core/src/com/unciv/models/metadata/GameSettings.kt index 132153f319..72299d28da 100644 --- a/core/src/com/unciv/models/metadata/GameSettings.kt +++ b/core/src/com/unciv/models/metadata/GameSettings.kt @@ -6,6 +6,7 @@ import com.unciv.Constants import com.unciv.UncivGame import com.unciv.ui.utils.Fonts import java.text.Collator +import java.time.Duration import java.util.* import kotlin.collections.HashSet @@ -43,10 +44,6 @@ class GameSettings { var showPixelUnits: Boolean = true var showPixelImprovements: Boolean = true var continuousRendering = false - var userId = "" - var multiplayerTurnCheckerEnabled = true - var multiplayerTurnCheckerPersistentNotificationEnabled = true - var multiplayerTurnCheckerDelayInMinutes = 5 var orderTradeOffersByAmount = true var confirmNextTurn = false var windowState = WindowState() @@ -54,9 +51,7 @@ class GameSettings { var visualMods = HashSet() var useDemographics: Boolean = false - - var multiplayerServer = Constants.dropboxMultiplayerServer - + var multiplayer = GameSettingsMultiplayer() var showExperimentalWorldWrap = false // We're keeping this as a config due to ANR problems on Android phones for people who don't know what they're doing :/ @@ -72,10 +67,13 @@ class GameSettings { /** Maximum zoom-out of the map - performance heavy */ var maxWorldZoomOut = 2f + /** used to migrate from older versions of the settings */ + var version: Int? = null + init { // 26 = Android Oreo. Versions below may display permanent icon in notification bar. if (Gdx.app?.type == Application.ApplicationType.Android && Gdx.app.version < 26) { - multiplayerTurnCheckerPersistentNotificationEnabled = false + multiplayer.turnCheckerPersistentNotificationEnabled = false } } @@ -156,3 +154,14 @@ enum class LocaleCode(var language: String, var country: String) { Ukrainian("uk", "UA"), Vietnamese("vi", "VN"), } + +class GameSettingsMultiplayer { + var userId = "" + var server = Constants.dropboxMultiplayerServer + var turnCheckerEnabled = true + var turnCheckerPersistentNotificationEnabled = true + var turnCheckerDelay = Duration.ofMinutes(5) + var statusButtonInSinglePlayer = false + var currentGameRefreshDelay = Duration.ofSeconds(10) + var allGameRefreshDelay = Duration.ofMinutes(5) +} diff --git a/core/src/com/unciv/models/metadata/GameSettingsMigrations.kt b/core/src/com/unciv/models/metadata/GameSettingsMigrations.kt new file mode 100644 index 0000000000..197da002ed --- /dev/null +++ b/core/src/com/unciv/models/metadata/GameSettingsMigrations.kt @@ -0,0 +1,40 @@ +package com.unciv.models.metadata + +import com.badlogic.gdx.utils.JsonValue +import java.time.Duration + +private const val CURRENT_VERSION = 1 + +fun GameSettings.doMigrations(json: JsonValue) { + if (version == null) { + migrateMultiplayerSettings(json) + version = 1 + } +} + +fun GameSettings.isMigrationNecessary(): Boolean { + return version != CURRENT_VERSION +} + +private fun GameSettings.migrateMultiplayerSettings(json: JsonValue) { + val userId = json.get("userId") + if (userId != null && userId.isString) { + multiplayer.userId = userId.asString() + } + val server = json.get("multiplayerServer") + if (server != null && server.isString) { + multiplayer.server = server.asString() + } + val enabled = json.get("multiplayerTurnCheckerEnabled") + if (enabled != null && enabled.isBoolean) { + multiplayer.turnCheckerEnabled = enabled.asBoolean() + } + val notification = json.get("multiplayerTurnCheckerPersistentNotificationEnabled") + if (notification != null && notification.isBoolean) { + multiplayer.turnCheckerPersistentNotificationEnabled = notification.asBoolean() + } + val delayInMinutes = json.get("multiplayerTurnCheckerDelayInMinutes") + if (delayInMinutes != null && delayInMinutes.isNumber) { + multiplayer.turnCheckerDelay = Duration.ofMinutes(delayInMinutes.asLong()) + } +} diff --git a/core/src/com/unciv/ui/multiplayer/AddMultiplayerGameScreen.kt b/core/src/com/unciv/ui/multiplayer/AddMultiplayerGameScreen.kt index 3e7bdc7d2c..35cc40c0bf 100644 --- a/core/src/com/unciv/ui/multiplayer/AddMultiplayerGameScreen.kt +++ b/core/src/com/unciv/ui/multiplayer/AddMultiplayerGameScreen.kt @@ -7,6 +7,7 @@ import com.unciv.logic.IdChecker import com.unciv.models.translations.tr import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable +import com.unciv.ui.multiplayer.MultiplayerHelpers import com.unciv.ui.pickerscreens.PickerScreen import com.unciv.ui.popup.Popup import com.unciv.ui.popup.ToastPopup @@ -60,7 +61,7 @@ class AddMultiplayerGameScreen(backScreen: MultiplayerScreen) : PickerScreen() { game.setScreen(backScreen) } } catch (ex: Exception) { - val message = backScreen.getLoadExceptionMessage(ex) + val message = MultiplayerHelpers.getLoadExceptionMessage(ex) postCrashHandlingRunnable { popup.reuseWith(message, true) } diff --git a/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt b/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt index 01699d0c05..c4fdbb61a9 100644 --- a/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt +++ b/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt @@ -8,6 +8,7 @@ import com.unciv.ui.pickerscreens.PickerScreen import com.unciv.ui.utils.* import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable +import com.unciv.ui.multiplayer.MultiplayerHelpers import com.unciv.ui.popup.Popup import com.unciv.ui.popup.ToastPopup import com.unciv.ui.popup.YesNoPopup @@ -96,7 +97,7 @@ class EditMultiplayerGameInfoScreen(val multiplayerGame: OnlineMultiplayerGame, } } } catch (ex: Exception) { - val message = backScreen.getLoadExceptionMessage(ex) + val message = MultiplayerHelpers.getLoadExceptionMessage(ex) postCrashHandlingRunnable { popup.reuseWith(message, true) } diff --git a/core/src/com/unciv/ui/multiplayer/GameList.kt b/core/src/com/unciv/ui/multiplayer/GameList.kt new file mode 100644 index 0000000000..2854b98d3a --- /dev/null +++ b/core/src/com/unciv/ui/multiplayer/GameList.kt @@ -0,0 +1,146 @@ +package com.unciv.ui.multiplayer + +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.ui.Container +import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.badlogic.gdx.scenes.scene2d.ui.VerticalGroup +import com.unciv.UncivGame +import com.unciv.logic.GameInfoPreview +import com.unciv.logic.event.EventBus +import com.unciv.logic.multiplayer.HasMultiplayerGameName +import com.unciv.logic.multiplayer.MultiplayerGameAdded +import com.unciv.logic.multiplayer.MultiplayerGameDeleted +import com.unciv.logic.multiplayer.MultiplayerGameNameChanged +import com.unciv.logic.multiplayer.MultiplayerGameUpdateEnded +import com.unciv.logic.multiplayer.MultiplayerGameUpdateFailed +import com.unciv.logic.multiplayer.MultiplayerGameUpdateStarted +import com.unciv.logic.multiplayer.MultiplayerGameUpdateSucceeded +import com.unciv.logic.multiplayer.MultiplayerGameUpdateUnchanged +import com.unciv.logic.multiplayer.MultiplayerGameUpdated +import com.unciv.logic.multiplayer.isUsersTurn +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.utils.BaseScreen +import com.unciv.ui.utils.onClick +import com.unciv.ui.utils.setSize + +class GameList( + onSelected: (String) -> Unit +) : VerticalGroup() { + + private val gameDisplays = mutableMapOf() + + private val events = EventBus.EventReceiver() + + init { + padTop(10f) + padBottom(10f) + + events.receive(MultiplayerGameAdded::class) { + val multiplayerGame = UncivGame.Current.onlineMultiplayer.getGameByName(it.name) + if (multiplayerGame == null) return@receive + addGame(it.name, multiplayerGame.preview, multiplayerGame.error, onSelected) + } + events.receive(MultiplayerGameNameChanged::class) { + val gameDisplay = gameDisplays.remove(it.name) + if (gameDisplay == null) return@receive + gameDisplay.changeName(it.newName) + gameDisplays[it.newName] = gameDisplay + children.sort() + } + events.receive(MultiplayerGameDeleted::class) { + val gameDisplay = gameDisplays.remove(it.name) + if (gameDisplay == null) return@receive + gameDisplay.remove() + } + + for (game in UncivGame.Current.onlineMultiplayer.games) { + addGame(game.name, game.preview, game.error, onSelected) + } + } + + private fun addGame(name: String, preview: GameInfoPreview?, error: Exception?, onSelected: (String) -> Unit) { + val gameDisplay = GameDisplay(name, preview, error, onSelected) + gameDisplays[name] = gameDisplay + addActor(gameDisplay) + children.sort() + } +} + +private class GameDisplay( + multiplayerGameName: String, + preview: GameInfoPreview?, + error: Exception?, + private val onSelected: (String) -> Unit +) : Table(), Comparable { + var gameName: String = multiplayerGameName + private set + val gameButton = TextButton(gameName, BaseScreen.skin) + val turnIndicator = createIndicator("OtherIcons/ExclamationMark") + val errorIndicator = createIndicator("StatIcons/Malcontent") + val refreshIndicator = createIndicator("EmojiIcons/Turn") + val statusIndicators = HorizontalGroup() + + val events = EventBus.EventReceiver() + + init { + padBottom(5f) + + updateTurnIndicator(preview) + updateErrorIndicator(error != null) + add(statusIndicators) + add(gameButton) + onClick { onSelected(gameName) } + + val isOurGame: (HasMultiplayerGameName) -> Boolean = { it.name == gameName } + events.receive(MultiplayerGameUpdateStarted::class, isOurGame, { + statusIndicators.addActor(refreshIndicator) + }) + events.receive(MultiplayerGameUpdateEnded::class, isOurGame) { + refreshIndicator.remove() + } + events.receive(MultiplayerGameUpdated::class, isOurGame) { + updateTurnIndicator(it.preview) + } + events.receive(MultiplayerGameUpdateSucceeded::class, isOurGame) { + updateErrorIndicator(false) + } + events.receive(MultiplayerGameUpdateFailed::class, isOurGame) { + updateErrorIndicator(true) + } + } + + fun changeName(newName: String) { + gameName = newName + gameButton.setText(newName) + } + + private fun updateTurnIndicator(preview: GameInfoPreview?) { + if (preview?.isUsersTurn() == true) { + statusIndicators.addActor(turnIndicator) + } else { + turnIndicator.remove() + } + } + + private fun updateErrorIndicator(hasError: Boolean) { + if (hasError) { + statusIndicators.addActor(errorIndicator) + } else { + errorIndicator.remove() + } + } + + private fun createIndicator(imagePath: String): Actor { + val image = ImageGetter.getImage(imagePath) + image.setSize(50f) + val container = Container(image) + container.padRight(5f) + return container + } + + override fun compareTo(other: GameDisplay): Int = gameName.compareTo(other.gameName) + override fun equals(other: Any?): Boolean = (other is GameDisplay) && (gameName == other.gameName) + override fun hashCode(): Int = gameName.hashCode() +} diff --git a/core/src/com/unciv/ui/multiplayer/MultiplayerHelpers.kt b/core/src/com/unciv/ui/multiplayer/MultiplayerHelpers.kt new file mode 100644 index 0000000000..80dec08f9a --- /dev/null +++ b/core/src/com/unciv/ui/multiplayer/MultiplayerHelpers.kt @@ -0,0 +1,70 @@ +package com.unciv.ui.multiplayer + +import com.unciv.UncivGame +import com.unciv.logic.UncivShowableException +import com.unciv.logic.multiplayer.OnlineMultiplayerGame +import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached +import com.unciv.models.translations.tr +import com.unciv.ui.crashhandling.launchCrashHandling +import com.unciv.ui.crashhandling.postCrashHandlingRunnable +import com.unciv.ui.popup.Popup +import com.unciv.ui.utils.BaseScreen +import java.io.FileNotFoundException +import java.time.Duration +import java.time.Instant + +object MultiplayerHelpers { + fun getLoadExceptionMessage(ex: Throwable) = when (ex) { + is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds" + is FileNotFoundException -> "File could not be found on the multiplayer server" + is UncivShowableException -> ex.message!! // some of these seem to be translated already, but not all + else -> "Unhandled problem, [${ex::class.simpleName}] ${ex.message}" + } + + fun loadMultiplayerGame(screen: BaseScreen, selectedGame: OnlineMultiplayerGame) { + val loadingGamePopup = Popup(screen) + loadingGamePopup.addGoodSizedLabel("Loading latest game state...") + loadingGamePopup.open() + + launchCrashHandling("JoinMultiplayerGame") { + try { + UncivGame.Current.onlineMultiplayer.loadGame(selectedGame) + } catch (ex: Exception) { + val message = getLoadExceptionMessage(ex) + postCrashHandlingRunnable { + loadingGamePopup.reuseWith(message, true) + } + } + } + } + + fun buildDescriptionText(multiplayerGame: OnlineMultiplayerGame): StringBuilder { + val descriptionText = StringBuilder() + val ex = multiplayerGame.error + if (ex != null) { + descriptionText.append("Error while refreshing:".tr()).append(' ') + val message = getLoadExceptionMessage(ex) + descriptionText.appendLine(message.tr()) + } + val lastUpdate = multiplayerGame.lastUpdate + descriptionText.appendLine("Last refresh: ${formattedElapsedTime(lastUpdate)} ago".tr()) + val preview = multiplayerGame.preview + if (preview?.currentPlayer != null) { + val currentTurnStartTime = Instant.ofEpochMilli(preview.currentTurnStartTime) + descriptionText.appendLine("Current Turn: [${preview.currentPlayer}] since ${formattedElapsedTime(currentTurnStartTime)} ago".tr()) + } + return descriptionText + } + + private fun formattedElapsedTime(lastUpdate: Instant): String { + val durationToNow = Duration.between(lastUpdate, Instant.now()) + val elapsedMinutes = durationToNow.toMinutes() + if (elapsedMinutes < 120) return "[$elapsedMinutes] [Minutes]" + val elapsedHours = durationToNow.toHours() + if (elapsedHours < 48) { + return "[${elapsedHours}] [Hours]" + } else { + return "[${durationToNow.toDays()}] [Days]" + } + } +} diff --git a/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt b/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt index 315f50c658..ea134fc65c 100644 --- a/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt +++ b/core/src/com/unciv/ui/multiplayer/MultiplayerScreen.kt @@ -1,24 +1,22 @@ package com.unciv.ui.multiplayer import com.badlogic.gdx.Gdx -import com.badlogic.gdx.scenes.scene2d.Actor -import com.badlogic.gdx.scenes.scene2d.ui.* -import com.unciv.UncivGame -import com.unciv.logic.* +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.ui.TextButton import com.unciv.logic.event.EventBus -import com.unciv.logic.multiplayer.* -import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached +import com.unciv.logic.multiplayer.MultiplayerGameDeleted +import com.unciv.logic.multiplayer.OnlineMultiplayerGame import com.unciv.models.translations.tr +import com.unciv.ui.multiplayer.GameList +import com.unciv.ui.multiplayer.MultiplayerHelpers import com.unciv.ui.pickerscreens.PickerScreen -import com.unciv.ui.utils.* -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable -import com.unciv.ui.images.ImageGetter import com.unciv.ui.popup.Popup import com.unciv.ui.popup.ToastPopup -import java.io.FileNotFoundException -import java.time.Duration -import java.time.Instant +import com.unciv.ui.utils.BaseScreen +import com.unciv.ui.utils.disable +import com.unciv.ui.utils.enable +import com.unciv.ui.utils.onClick +import com.unciv.ui.utils.toTextButton import com.unciv.ui.utils.AutoScrollPane as ScrollPane class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { @@ -55,7 +53,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { setupRightSideButton() - events.receive(MultiplayerGameDeleted::class, {it.name == selectedGame?.name}) { + events.receive(MultiplayerGameDeleted::class, { it.name == selectedGame?.name }) { unselectGame() } @@ -64,7 +62,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { private fun setupRightSideButton() { rightSideButton.setText("Join game".tr()) - rightSideButton.onClick { joinMultiplayerGame(selectedGame!!) } + rightSideButton.onClick { MultiplayerHelpers.loadMultiplayerGame(this, selectedGame!!) } } private fun createRightSideTable(): Table { @@ -117,7 +115,7 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { private fun createCopyUserIdButton(): TextButton { val btn = copyUserIdText.toTextButton() btn.onClick { - Gdx.app.clipboard.contents = game.settings.userId + Gdx.app.clipboard.contents = game.settings.multiplayer.userId ToastPopup("UserID copied to clipboard", this) } return btn @@ -159,23 +157,6 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { stage.addActor(tab) } - fun joinMultiplayerGame(selectedGame: OnlineMultiplayerGame) { - val loadingGamePopup = Popup(this) - loadingGamePopup.addGoodSizedLabel("Loading latest game state...") - loadingGamePopup.open() - - launchCrashHandling("JoinMultiplayerGame") { - try { - game.onlineMultiplayer.loadGame(selectedGame) - } catch (ex: Exception) { - val message = getLoadExceptionMessage(ex) - postCrashHandlingRunnable { - loadingGamePopup.reuseWith(message, true) - } - } - } - } - private fun unselectGame() { selectedGame = null @@ -203,161 +184,6 @@ class MultiplayerScreen(previousScreen: BaseScreen) : PickerScreen() { editButton.enable() rightSideButton.enable() - descriptionLabel.setText(buildDescriptionText(multiplayerGame)) - } - - private fun buildDescriptionText(multiplayerGame: OnlineMultiplayerGame): StringBuilder { - val descriptionText = StringBuilder() - val ex = multiplayerGame.error - if (ex != null) { - descriptionText.append("Error while refreshing:".tr()).append(' ') - val message = getLoadExceptionMessage(ex) - descriptionText.appendLine(message.tr()) - } - val lastUpdate = multiplayerGame.lastUpdate - descriptionText.appendLine("Last refresh: ${formattedElapsedTime(lastUpdate)} ago".tr()) - val preview = multiplayerGame.preview - if (preview?.currentPlayer != null) { - val currentTurnStartTime = Instant.ofEpochMilli(preview.currentTurnStartTime) - descriptionText.appendLine("Current Turn: [${preview.currentPlayer}] since ${formattedElapsedTime(currentTurnStartTime)} ago".tr()) - } - return descriptionText - } - - private fun formattedElapsedTime(lastUpdate: Instant): String { - val durationToNow = Duration.between(lastUpdate, Instant.now()) - val elapsedMinutes = durationToNow.toMinutes() - if (elapsedMinutes < 120) return "[$elapsedMinutes] [Minutes]" - val elapsedHours = durationToNow.toHours() - if (elapsedHours < 48) { - return "[${elapsedHours}] [Hours]" - } else { - return "[${durationToNow.toDays()}] [Days]" - } - } - - fun getLoadExceptionMessage(ex: Exception) = when (ex) { - is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds" - is FileNotFoundException -> "File could not be found on the multiplayer server" - is UncivShowableException -> ex.message!! // some of these seem to be translated already, but not all - else -> "Unhandled problem, [${ex::class.simpleName}] ${ex.message}" + descriptionLabel.setText(MultiplayerHelpers.buildDescriptionText(multiplayerGame)) } } - -private class GameList( - onSelected: (String) -> Unit -) : VerticalGroup() { - - private val gameDisplays = mutableMapOf() - - private val events = EventBus.EventReceiver() - - init { - padTop(10f) - padBottom(10f) - - events.receive(MultiplayerGameAdded::class) { - val multiplayerGame = UncivGame.Current.onlineMultiplayer.getGameByName(it.name) - if (multiplayerGame == null) return@receive - addGame(it.name, multiplayerGame.preview, multiplayerGame.error, onSelected) - } - events.receive(MultiplayerGameNameChanged::class) { - val gameDisplay = gameDisplays.remove(it.oldName) - if (gameDisplay == null) return@receive - gameDisplay.changeName(it.name) - gameDisplays[it.name] = gameDisplay - children.sort() - } - events.receive(MultiplayerGameDeleted::class) { - val gameDisplay = gameDisplays.remove(it.name) - if (gameDisplay == null) return@receive - gameDisplay.remove() - } - for (game in UncivGame.Current.onlineMultiplayer.games) { - addGame(game.name, game.preview, game.error, onSelected) - } - } - - private fun addGame(name: String, preview: GameInfoPreview?, error: Exception?, onSelected: (String) -> Unit) { - val gameDisplay = GameDisplay(name, preview, error, onSelected) - gameDisplays[name] = gameDisplay - addActor(gameDisplay) - children.sort() - } -} - -private class GameDisplay( - multiplayerGameName: String, - preview: GameInfoPreview?, - error: Exception?, - private val onSelected: (String) -> Unit -) : Table(), Comparable { - var gameName: String = multiplayerGameName - private set - val gameButton = TextButton(gameName, BaseScreen.skin) - val turnIndicator = createIndicator("OtherIcons/ExclamationMark") - val errorIndicator = createIndicator("StatIcons/Malcontent") - val refreshIndicator = createIndicator("EmojiIcons/Turn") - val statusIndicators = HorizontalGroup() - - val events = EventBus.EventReceiver() - - init { - padBottom(5f) - - updateTurnIndicator(preview) - updateErrorIndicator(error != null) - add(statusIndicators) - add(gameButton) - onClick { onSelected(gameName) } - - events.receive(MultiplayerGameUpdateStarted::class, { it.name == gameName }, { - statusIndicators.addActor(refreshIndicator) - }) - events.receive(MultiplayerGameUpdateUnchanged::class, { it.name == gameName }, { - refreshIndicator.remove() - }) - events.receive(MultiplayerGameUpdated::class, { it.name == gameName }) { - updateTurnIndicator(it.preview) - updateErrorIndicator(false) - refreshIndicator.remove() - } - events.receive(MultiplayerGameUpdateFailed::class, { it.name == gameName }) { - updateErrorIndicator(true) - refreshIndicator.remove() - } - } - - fun changeName(newName: String) { - gameName = newName - gameButton.setText(newName) - } - - private fun updateTurnIndicator(preview: GameInfoPreview?) { - if (preview?.isUsersTurn() == true) { - statusIndicators.addActor(turnIndicator) - } else { - turnIndicator.remove() - } - } - - private fun updateErrorIndicator(hasError: Boolean) { - if (hasError) { - statusIndicators.addActor(errorIndicator) - } else { - errorIndicator.remove() - } - } - - private fun createIndicator(imagePath: String): Actor { - val image = ImageGetter.getImage(imagePath) - image.setSize(50f) - val container = Container(image) - container.padRight(5f) - return container - } - - override fun compareTo(other: GameDisplay): Int = gameName.compareTo(other.gameName) - override fun equals(other: Any?): Boolean = (other is GameDisplay) && (gameName == other.gameName) - override fun hashCode(): Int = gameName.hashCode() -} diff --git a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt index 5d4b0adc10..b9de20b7b1 100644 --- a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt @@ -73,7 +73,7 @@ class NewGameScreen( rightSideButton.setText("Start game!".tr()) rightSideButton.onClick { if (gameSetupInfo.gameParameters.isOnlineMultiplayer) { - val isDropbox = UncivGame.Current.settings.multiplayerServer == Constants.dropboxMultiplayerServer + val isDropbox = UncivGame.Current.settings.multiplayer.server == Constants.dropboxMultiplayerServer if (!checkConnectionToMultiplayerServer()) { val noInternetConnectionPopup = Popup(this) val label = if (isDropbox) "Couldn't connect to Dropbox!" else "Couldn't connect to Multiplayer Server!" @@ -100,7 +100,7 @@ class NewGameScreen( it.playerType == PlayerType.Human && // do not allow multiplayer with only remote spectator(s) and AI(s) - non-MP that works !(it.chosenCiv == Constants.spectator && gameSetupInfo.gameParameters.isOnlineMultiplayer && - it.playerId != UncivGame.Current.settings.userId) + it.playerId != UncivGame.Current.settings.multiplayer.userId) }) { val noHumanPlayersPopup = Popup(this) noHumanPlayersPopup.addGoodSizedLabel("No human players selected!".tr()).row() @@ -188,7 +188,7 @@ class NewGameScreen( topTable.add(playerPickerTable) // No ScrollPane, PlayerPickerTable has its own .width(stage.width / 3).top() } - + private fun initPortrait() { scrollPane.setScrollingDisabled(false,false) @@ -199,7 +199,7 @@ class NewGameScreen( topTable.add(newGameOptionsTable.modCheckboxes).expandX().fillX().row() topTable.addSeparator(Color.DARK_GRAY, height = 1f) - + topTable.add(ExpanderTab("Map Options") { it.add(mapOptionsTable).row() }).expandX().fillX().row() @@ -212,9 +212,9 @@ class NewGameScreen( } private fun checkConnectionToMultiplayerServer(): Boolean { - val isDropbox = UncivGame.Current.settings.multiplayerServer == Constants.dropboxMultiplayerServer + val isDropbox = UncivGame.Current.settings.multiplayer.server == Constants.dropboxMultiplayerServer return try { - val multiplayerServer = UncivGame.Current.settings.multiplayerServer + val multiplayerServer = UncivGame.Current.settings.multiplayer.server val u = URL(if (isDropbox) "https://content.dropboxapi.com" else multiplayerServer) val con = u.openConnection() con.connectTimeout = 3000 @@ -334,7 +334,7 @@ class TranslatedSelectBox(values : Collection, default:String, skin: Ski items = array selected = array.firstOrNull { it.value == default } ?: array.first() } - + fun setSelected(newValue: String) { selected = items.firstOrNull { it == TranslatedString(newValue) } ?: return } diff --git a/core/src/com/unciv/ui/newgamescreen/PlayerPickerTable.kt b/core/src/com/unciv/ui/newgamescreen/PlayerPickerTable.kt index 0994c54984..97690fb57d 100644 --- a/core/src/com/unciv/ui/newgamescreen/PlayerPickerTable.kt +++ b/core/src/com/unciv/ui/newgamescreen/PlayerPickerTable.kt @@ -176,7 +176,7 @@ class PlayerPickerTable( } playerIdTextField.addListener { onPlayerIdTextUpdated(); true } - val currentUserId = UncivGame.Current.settings.userId + val currentUserId = UncivGame.Current.settings.multiplayer.userId val setCurrentUserButton = "Set current user".toTextButton() setCurrentUserButton.onClick { playerIdTextField.text = currentUserId @@ -203,8 +203,8 @@ class PlayerPickerTable( */ private fun getNationTable(player: Player): Table { val nationTable = Table() - val nationImage = - if (player.chosenCiv == Constants.random) + val nationImage = + if (player.chosenCiv == Constants.random) ImageGetter.getRandomNationIndicator(40f) else ImageGetter.getNationIndicator(previousScreen.ruleset.nations[player.chosenCiv]!!, 40f) nationTable.add(nationImage).pad(5f) @@ -230,10 +230,10 @@ class PlayerPickerTable( /** * Returns a list of available civilization for all players, according * to current ruleset, with exception of city states nations, spectator and barbarians. - * + * * Skips nations already chosen by a player, unless parameter [dontSkipNation] says to keep a * specific one. That is used so the picker can be used to inspect and confirm the current selection. - * + * * @return [Sequence] of available [Nation]s */ internal fun getAvailablePlayerCivs(dontSkipNation: String? = null) = diff --git a/core/src/com/unciv/ui/options/AdvancedTab.kt b/core/src/com/unciv/ui/options/AdvancedTab.kt index dbf1912c92..4c391c025d 100644 --- a/core/src/com/unciv/ui/options/AdvancedTab.kt +++ b/core/src/com/unciv/ui/options/AdvancedTab.kt @@ -162,7 +162,7 @@ private fun addSetUserId(table: Table, settings: GameSettings, screen: BaseScree YesNoPopup( "Doing this will reset your current user ID to the clipboard contents - are you sure?", { - settings.userId = clipboardContents + settings.multiplayer.userId = clipboardContents settings.save() idSetLabel.setFontColor(Color.WHITE).setText("ID successfully set!".tr()) }, diff --git a/core/src/com/unciv/ui/options/MultiplayerTab.kt b/core/src/com/unciv/ui/options/MultiplayerTab.kt index fc246ff060..0f96ab6cdd 100644 --- a/core/src/com/unciv/ui/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/options/MultiplayerTab.kt @@ -2,6 +2,7 @@ package com.unciv.ui.options import com.badlogic.gdx.Application import com.badlogic.gdx.Gdx +import com.badlogic.gdx.scenes.scene2d.ui.Label import com.badlogic.gdx.scenes.scene2d.ui.SelectBox import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.TextField @@ -9,10 +10,13 @@ import com.badlogic.gdx.utils.Array import com.unciv.Constants import com.unciv.logic.multiplayer.storage.SimpleHttp import com.unciv.models.metadata.GameSettings +import com.unciv.models.translations.tr import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.popup.Popup import com.unciv.ui.utils.* +import java.time.Duration +import kotlin.reflect.KMutableProperty0 fun multiplayerTab( optionsPopup: OptionsPopup @@ -22,31 +26,45 @@ fun multiplayerTab( val settings = optionsPopup.settings + optionsPopup.addCheckbox(this, "Enable multiplayer status button in singleplayer games", + settings.multiplayer.statusButtonInSinglePlayer, updateWorld = true + ) { + settings.multiplayer.statusButtonInSinglePlayer = it + settings.save() + } + + val curRefreshSelect = addRefreshSelect(this, settings, settings.multiplayer::currentGameRefreshDelay, + "Update status of currently played game every:".toLabel(), curRefreshDropboxOptions, curRefreshCustomServerOptions) + val allRefreshSelect = addRefreshSelect(this, settings, settings.multiplayer::allGameRefreshDelay, + "In-game, update status of all games every:".toLabel(), allRefreshDropboxOptions, allRefreshCustomServerOptions) + + var turnCheckerSelect: SelectBox? = null // at the moment the notification service only exists on Android if (Gdx.app.type == Application.ApplicationType.Android) { optionsPopup.addCheckbox( this, "Enable out-of-game turn notifications", - settings.multiplayerTurnCheckerEnabled + settings.multiplayer.turnCheckerEnabled ) { - settings.multiplayerTurnCheckerEnabled = it + settings.multiplayer.turnCheckerEnabled = it settings.save() } - if (settings.multiplayerTurnCheckerEnabled) { - addMultiplayerTurnCheckerDelayBox(this, settings) + if (settings.multiplayer.turnCheckerEnabled) { + turnCheckerSelect = addRefreshSelect(this, settings, settings.multiplayer::turnCheckerDelay, + "Out-of-game, update status of all games every:".toLabel(), turnCheckerDropboxOptions, turnCheckerCustomServerOptions) optionsPopup.addCheckbox( this, "Show persistent notification for turn notifier service", - settings.multiplayerTurnCheckerPersistentNotificationEnabled + settings.multiplayer.turnCheckerPersistentNotificationEnabled ) - { settings.multiplayerTurnCheckerPersistentNotificationEnabled = it } + { settings.multiplayer.turnCheckerPersistentNotificationEnabled = it } } } val connectionToServerButton = "Check connection to server".toTextButton() val textToShowForMultiplayerAddress = - if (settings.multiplayerServer != Constants.dropboxMultiplayerServer) settings.multiplayerServer + if (!usesDropbox(settings)) settings.multiplayer.server else "https://..." val multiplayerServerTextField = TextField(textToShowForMultiplayerAddress, BaseScreen.skin) multiplayerServerTextField.setTextFieldFilter { _, c -> c !in " \r\n\t\\" } @@ -57,13 +75,21 @@ fun multiplayerTab( multiplayerServerTextField.text = Gdx.app.clipboard.contents }).row() multiplayerServerTextField.onChange { - connectionToServerButton.isEnabled = multiplayerServerTextField.text != Constants.dropboxMultiplayerServer - if (connectionToServerButton.isEnabled) { + val isCustomServer = multiplayerServerTextField.text != Constants.dropboxMultiplayerServer + connectionToServerButton.isEnabled = isCustomServer + + updateRefreshSelectOptions(curRefreshSelect, isCustomServer, curRefreshDropboxOptions, curRefreshCustomServerOptions) + updateRefreshSelectOptions(allRefreshSelect, isCustomServer, allRefreshDropboxOptions, allRefreshCustomServerOptions) + if (turnCheckerSelect != null) { + updateRefreshSelectOptions(turnCheckerSelect, isCustomServer, allRefreshDropboxOptions, allRefreshCustomServerOptions) + } + + if (isCustomServer) { fixTextFieldUrlOnType(multiplayerServerTextField) // we can't trim on 'fixTextFieldUrlOnType' for reasons - settings.multiplayerServer = multiplayerServerTextField.text.trimEnd('/') + settings.multiplayer.server = multiplayerServerTextField.text.trimEnd('/') } else { - settings.multiplayerServer = multiplayerServerTextField.text + settings.multiplayer.server = multiplayerServerTextField.text } settings.save() } @@ -74,6 +100,16 @@ fun multiplayerTab( add("Reset to Dropbox".toTextButton().onClick { multiplayerServerTextField.text = Constants.dropboxMultiplayerServer + if (allRefreshDropboxOptions.size != allRefreshSelect.items.size) { + allRefreshSelect.items = allRefreshDropboxOptions + } + if (curRefreshDropboxOptions.size != curRefreshSelect.items.size) { + curRefreshSelect.items = curRefreshDropboxOptions + } + if (turnCheckerSelect != null && turnCheckerDropboxOptions.size != turnCheckerSelect.items.size) { + turnCheckerSelect.items = turnCheckerDropboxOptions + } + settings.save() }).row() add(connectionToServerButton.onClick { @@ -91,7 +127,7 @@ fun multiplayerTab( private fun successfullyConnectedToServer(settings: GameSettings, action: (Boolean, String, Int?) -> Unit) { launchCrashHandling("TestIsAlive") { - SimpleHttp.sendGetRequest("${settings.multiplayerServer}/isalive") { + SimpleHttp.sendGetRequest("${settings.multiplayer.server}/isalive") { success, result, code -> postCrashHandlingRunnable { action(success, result, code) @@ -134,19 +170,86 @@ private fun fixTextFieldUrlOnType(TextField: TextField) { } } -private fun addMultiplayerTurnCheckerDelayBox(table: Table, settings: GameSettings) { - table.add("Time between turn checks out-of-game (in minutes)".toLabel()).left().fillX() +private class RefreshOptions(val delay: Duration, val label: String) { + override fun toString(): String = label + override fun equals(other: Any?): Boolean = other is RefreshOptions && delay == other.delay + override fun hashCode(): Int = delay.hashCode() +} - val checkDelaySelectBox = SelectBox(table.skin) - val possibleDelaysArray = Array() - possibleDelaysArray.addAll(1, 2, 5, 15) - checkDelaySelectBox.items = possibleDelaysArray - checkDelaySelectBox.selected = settings.multiplayerTurnCheckerDelayInMinutes - table.add(checkDelaySelectBox).pad(10f).row() +private val curRefreshDropboxOptions = + (listOf(10, 20, 30, 60).map { RefreshOptions(Duration.ofSeconds(it), "$it " + "Seconds".tr()) }).toGdxArray() - checkDelaySelectBox.onChange { - settings.multiplayerTurnCheckerDelayInMinutes = checkDelaySelectBox.selected +private val curRefreshCustomServerOptions = + (listOf(3, 5).map { RefreshOptions(Duration.ofSeconds(it), "$it " + "Seconds".tr()) } + curRefreshDropboxOptions).toGdxArray() + +private val allRefreshDropboxOptions = + (listOf(1, 2, 5, 15).map { RefreshOptions(Duration.ofMinutes(it), "$it " + "Minutes".tr()) }).toGdxArray() + +private val allRefreshCustomServerOptions = + (listOf(15, 30).map { RefreshOptions(Duration.ofSeconds(it), "$it " + "Seconds".tr()) } + allRefreshDropboxOptions).toGdxArray() + +private val turnCheckerDropboxOptions = + (listOf(1, 2, 5, 15).map { RefreshOptions(Duration.ofMinutes(it), "$it " + "Minutes".tr()) }).toGdxArray() + +private val turnCheckerCustomServerOptions = + (listOf(30).map { RefreshOptions(Duration.ofSeconds(it), "$it " + "Seconds".tr()) } + allRefreshDropboxOptions).toGdxArray() + +private fun List.toGdxArray(): Array { + val arr = Array(size) + for (it in this) { + arr.add(it) + } + return arr +} + +private fun usesDropbox(settings: GameSettings) = settings.multiplayer.server == Constants.dropboxMultiplayerServer + +private fun addRefreshSelect( + table: Table, + settings: GameSettings, + settingsProperty: KMutableProperty0, + label: Label, + dropboxOptions: Array, + customServerOptions: Array +): SelectBox { + table.add(label).left() + + val refreshSelectBox = SelectBox(table.skin) + val options = if (usesDropbox(settings)) { + dropboxOptions + } else { + customServerOptions + } + refreshSelectBox.items = options + + refreshSelectBox.selected = options.firstOrNull() { it.delay == settingsProperty.get() } ?: options.first() + + table.add(refreshSelectBox).pad(10f).row() + + refreshSelectBox.onChange { + settingsProperty.set(refreshSelectBox.selected.delay) settings.save() } + + return refreshSelectBox +} + +private fun updateRefreshSelectOptions( + selectBox: SelectBox, + isCustomServer: Boolean, + dropboxOptions: Array, + customServerOptions: Array +) { + fun replaceItems(selectBox: SelectBox, options: Array) { + val prev = selectBox.selected + selectBox.items = options + selectBox.selected = prev + } + + if (isCustomServer && selectBox.items.size != customServerOptions.size) { + replaceItems(selectBox, customServerOptions) + } else if (!isCustomServer && selectBox.items.size != dropboxOptions.size) { + replaceItems(selectBox, dropboxOptions) + } } diff --git a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt index 84c72adac1..9fa17c53ba 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt @@ -14,14 +14,16 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.TextButton import com.badlogic.gdx.utils.Align import com.unciv.Constants +import com.unciv.MainMenuScreen import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.civilization.CivilizationInfo import com.unciv.logic.civilization.ReligionState import com.unciv.logic.civilization.diplomacy.DiplomaticStatus +import com.unciv.logic.event.EventBus import com.unciv.logic.map.MapVisualization +import com.unciv.logic.multiplayer.MultiplayerGameUpdated import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached -import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver import com.unciv.logic.trade.TradeEvaluation import com.unciv.models.Tutorial import com.unciv.models.UncivSound @@ -30,12 +32,19 @@ import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.translations.tr import com.unciv.ui.cityscreen.CityScreen import com.unciv.ui.civilopedia.CivilopediaScreen -import com.unciv.ui.crashhandling.CRASH_HANDLING_DAEMON_SCOPE import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter +import com.unciv.ui.multiplayer.MultiplayerHelpers import com.unciv.ui.overviewscreen.EmpireOverviewScreen -import com.unciv.ui.pickerscreens.* +import com.unciv.ui.pickerscreens.DiplomaticVotePickerScreen +import com.unciv.ui.pickerscreens.DiplomaticVoteResultScreen +import com.unciv.ui.pickerscreens.GreatPersonPickerScreen +import com.unciv.ui.pickerscreens.PantheonPickerScreen +import com.unciv.ui.pickerscreens.PolicyPickerScreen +import com.unciv.ui.pickerscreens.ReligiousBeliefsPickerScreen +import com.unciv.ui.pickerscreens.TechButton +import com.unciv.ui.pickerscreens.TechPickerScreen import com.unciv.ui.popup.ExitGamePopup import com.unciv.ui.popup.Popup import com.unciv.ui.popup.ToastPopup @@ -44,23 +53,32 @@ import com.unciv.ui.popup.hasOpenPopups import com.unciv.ui.saves.LoadGameScreen import com.unciv.ui.saves.SaveGameScreen import com.unciv.ui.trade.DiplomacyScreen -import com.unciv.ui.utils.* +import com.unciv.ui.utils.BaseScreen +import com.unciv.ui.utils.Fonts +import com.unciv.ui.utils.KeyCharAndCode import com.unciv.ui.utils.UncivDateFormat.formatDate +import com.unciv.ui.utils.centerX +import com.unciv.ui.utils.colorFromRGB +import com.unciv.ui.utils.darken +import com.unciv.ui.utils.disable +import com.unciv.ui.utils.enable +import com.unciv.ui.utils.isEnabled +import com.unciv.ui.utils.onClick +import com.unciv.ui.utils.setFontSize +import com.unciv.ui.utils.toLabel +import com.unciv.ui.utils.toTextButton import com.unciv.ui.victoryscreen.VictoryScreen import com.unciv.ui.worldscreen.bottombar.BattleTable import com.unciv.ui.worldscreen.bottombar.TileInfoTable import com.unciv.ui.worldscreen.minimap.MinimapHolder +import com.unciv.ui.worldscreen.status.MultiplayerStatusButton import com.unciv.ui.worldscreen.status.NextTurnAction import com.unciv.ui.worldscreen.status.NextTurnButton +import com.unciv.ui.worldscreen.status.StatusButtons import com.unciv.ui.worldscreen.unit.UnitActionsTable import com.unciv.ui.worldscreen.unit.UnitTable import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.launchIn import java.util.* -import kotlin.concurrent.timerTask /** * Unciv's world screen @@ -99,19 +117,20 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas private val diplomacyButtonHolder = Table() private val fogOfWarButton = createFogOfWarButton() private val nextTurnButton = NextTurnButton(keyPressDispatcher) + private val statusButtons = StatusButtons(nextTurnButton) private val tutorialTaskTable = Table().apply { background = ImageGetter.getBackground( ImageGetter.getBlue().darken(0.5f)) } private val notificationsScroll: NotificationsScroll var shouldUpdate = false + private var nextTurnUpdateJob: Job? = null + + private val events = EventBus.EventReceiver() + companion object { /** Switch for console logging of next turn duration */ private const val consoleLog = false - - private lateinit var multiPlayerRefresher: Flow - // this object must not be created multiple times - private var multiPlayerRefresherJob: Job? = null } init { @@ -158,7 +177,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas stage.addActor(notificationsScroll) // very low in z-order, so we're free to let it extend _below_ tile info and minimap if we want stage.addActor(minimapWrapper) stage.addActor(topBar) - stage.addActor(nextTurnButton) + stage.addActor(statusButtons) stage.addActor(techPolicyAndVictoryHolder) stage.addActor(tutorialTaskTable) @@ -198,17 +217,16 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas if (gameInfo.gameParameters.isOnlineMultiplayer && !gameInfo.isUpToDate) isPlayersTurn = false // until we're up to date, don't let the player do anything - if (gameInfo.gameParameters.isOnlineMultiplayer && !isPlayersTurn) { - // restart the timer - stopMultiPlayerRefresher() - - multiPlayerRefresher = flow { - while (true) { + if (gameInfo.gameParameters.isOnlineMultiplayer) { + val gameId = gameInfo.gameId + events.receive(MultiplayerGameUpdated::class, { it.preview.gameId == gameId }) { + if (isNextTurnUpdateRunning() || game.onlineMultiplayer.hasLatestGameState(gameInfo, it.preview)) { + return@receive + } + launchCrashHandling("Load latest multiplayer state") { loadLatestMultiplayerState() - delay(10000) } } - multiPlayerRefresherJob = multiPlayerRefresher.launchIn(CRASH_HANDLING_DAEMON_SCOPE) } // don't run update() directly, because the UncivGame.worldScreen should be set so that the city buttons and tile groups @@ -216,10 +234,9 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas shouldUpdate = true } - private fun stopMultiPlayerRefresher() { - if (multiPlayerRefresherJob != null) { - multiPlayerRefresherJob?.cancel() - } + override fun dispose() { + super.dispose() + events.stopReceiving() } private fun addKeyboardPresses() { @@ -287,7 +304,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas keyPressDispatcher[KeyCharAndCode.ctrl('O')] = { // Game Options this.openOptionsPopup(onClose = { mapHolder.reloadMaxZoom() - nextTurnButton.update(hasOpenPopups(), isPlayersTurn, waitingForAutosave) + nextTurnButton.update(hasOpenPopups(), isPlayersTurn, waitingForAutosave, isNextTurnUpdateRunning()) }) } keyPressDispatcher[KeyCharAndCode.ctrl('S')] = { game.setScreen(SaveGameScreen(gameInfo)) } // Save @@ -353,53 +370,37 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } private suspend fun loadLatestMultiplayerState() { - // Since we're on a background thread, all the UI calls in this func need to run from the - // main thread which has a GL context val loadingGamePopup = Popup(this) postCrashHandlingRunnable { - loadingGamePopup.add("Loading latest game state...".tr()) + loadingGamePopup.addGoodSizedLabel("Loading latest game state...") loadingGamePopup.open() } try { - val latestGame = OnlineMultiplayerGameSaver().tryDownloadGame(gameInfo.gameId) - - // if we find the current player didn't change, don't update - // Additionally, check if we are the current player, and in that case always stop - // This fixes a bug where for some reason players were waiting for themselves. - if (gameInfo.currentPlayer == latestGame.currentPlayer - && gameInfo.turns == latestGame.turns - && latestGame.currentPlayer != gameInfo.getPlayerToViewAs().civName - ) { - postCrashHandlingRunnable { loadingGamePopup.close() } - shouldUpdate = true - return - } else { // if the game updated, even if it's not our turn, reload the world - - // stuff has changed and the "waiting for X" will now show the correct civ - stopMultiPlayerRefresher() - latestGame.isUpToDate = true - if (viewingCiv.civName == latestGame.currentPlayer || viewingCiv.civName == Constants.spectator) { - game.platformSpecificHelper?.notifyTurnStarted() - } - postCrashHandlingRunnable { createNewWorldScreen(latestGame) } + val latestGame = game.onlineMultiplayer.downloadGame(gameInfo.gameId) + if (viewingCiv.civName == latestGame.currentPlayer || viewingCiv.civName == Constants.spectator) { + game.platformSpecificHelper?.notifyTurnStarted() } - - } catch (ex: FileStorageRateLimitReached) { postCrashHandlingRunnable { - loadingGamePopup.reuseWith("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds", true) + loadingGamePopup.close() + if (game.gameInfo.gameId == gameInfo.gameId) { // game could've been changed during download + createNewWorldScreen(latestGame) + } } - // stop refresher to not spam user with "Server limit reached!" - // popups and restart after limit timer is over - stopMultiPlayerRefresher() - val restartAfter : Long = ex.limitRemainingSeconds.toLong() * 1000 - - Timer("RestartTimerTimer", true).schedule(timerTask { - multiPlayerRefresherJob = multiPlayerRefresher.launchIn(CRASH_HANDLING_DAEMON_SCOPE) - }, restartAfter) } catch (ex: Throwable) { postCrashHandlingRunnable { - loadingGamePopup.reuseWith("Couldn't download the latest game state!", true) - loadingGamePopup.addAction(Actions.delay(5f, Actions.run { loadingGamePopup.close() })) + val message = MultiplayerHelpers.getLoadExceptionMessage(ex) + loadingGamePopup.innerTable.clear() + loadingGamePopup.addGoodSizedLabel("Couldn't download the latest game state!").colspan(2).row() + loadingGamePopup.addGoodSizedLabel(message).colspan(2).row() + loadingGamePopup.addButtonInRow("Retry") { + launchCrashHandling("Load latest multiplayer state after error") { + loadLatestMultiplayerState() + } + }.right() + loadingGamePopup.addButtonInRow("Main menu") { + game.setScreen(MainMenuScreen()) + }.left() } } } @@ -494,9 +495,9 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } } } - updateNextTurnButton(hasOpenPopups()) // This must be before the notifications update, since its position is based on it + updateGameplayButtons() notificationsScroll.update(viewingCiv.notifications, bottomTileInfoTable.height) - notificationsScroll.setTopRight(stage.width - 10f, nextTurnButton.y - 5f) + notificationsScroll.setTopRight(stage.width - 10f, statusButtons.y - 5f) } private fun getCurrentTutorialTask(): String { @@ -658,7 +659,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas shouldUpdate = true // on a separate thread so the user can explore their world while we're passing the turn - launchCrashHandling("NextTurn", runAsDaemon = false) { + nextTurnUpdateJob = launchCrashHandling("NextTurn", runAsDaemon = false) { if (consoleLog) println("\nNext turn starting " + Date().formatDate()) val startTime = System.currentTimeMillis() @@ -670,7 +671,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas if (originalGameInfo.gameParameters.isOnlineMultiplayer) { try { - OnlineMultiplayerGameSaver().tryUploadGame(gameInfoClone, withPreview = true) + game.onlineMultiplayer.updateGame(gameInfoClone) } catch (ex: Exception) { val message = when (ex) { is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds" @@ -738,14 +739,34 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } } - private fun updateNextTurnButton(isSomethingOpen: Boolean) { - nextTurnButton.update(isSomethingOpen, isPlayersTurn, waitingForAutosave, getNextTurnAction()) - nextTurnButton.setPosition(stage.width - nextTurnButton.width - 10f, topBar.y - nextTurnButton.height - 10f) + private fun isNextTurnUpdateRunning(): Boolean { + val job = nextTurnUpdateJob + return job != null && job.isActive } + private fun updateGameplayButtons() { + nextTurnButton.update(hasOpenPopups(), isPlayersTurn, waitingForAutosave, isNextTurnUpdateRunning(), getNextTurnAction()) + + updateMultiplayerStatusButton() + + statusButtons.pack() + statusButtons.setPosition(stage.width - statusButtons.width - 10f, topBar.y - statusButtons.height - 10f) + } + + private fun updateMultiplayerStatusButton() { + if (gameInfo.gameParameters.isOnlineMultiplayer || game.settings.multiplayer.statusButtonInSinglePlayer) { + if (statusButtons.multiplayerStatusButton != null) return + statusButtons.multiplayerStatusButton = MultiplayerStatusButton(this, game.onlineMultiplayer.getGameByGameId(gameInfo.gameId)) + } else { + if (statusButtons.multiplayerStatusButton == null) return + statusButtons.multiplayerStatusButton = null + } + } private fun getNextTurnAction(): NextTurnAction { return when { + isNextTurnUpdateRunning() -> + NextTurnAction("Working...", Color.GRAY) {} !isPlayersTurn && gameInfo.gameParameters.isOnlineMultiplayer -> NextTurnAction("Waiting for [${gameInfo.currentPlayerCiv}]...", Color.GRAY) {} !isPlayersTurn && !gameInfo.gameParameters.isOnlineMultiplayer -> diff --git a/core/src/com/unciv/ui/worldscreen/status/MultiplayerStatusButton.kt b/core/src/com/unciv/ui/worldscreen/status/MultiplayerStatusButton.kt new file mode 100644 index 0000000000..2d32997382 --- /dev/null +++ b/core/src/com/unciv/ui/worldscreen/status/MultiplayerStatusButton.kt @@ -0,0 +1,185 @@ +package com.unciv.ui.worldscreen.status + +import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.scenes.scene2d.Actor +import com.badlogic.gdx.scenes.scene2d.actions.Actions +import com.badlogic.gdx.scenes.scene2d.actions.RepeatAction +import com.badlogic.gdx.scenes.scene2d.ui.Button +import com.badlogic.gdx.scenes.scene2d.ui.Cell +import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup +import com.badlogic.gdx.scenes.scene2d.ui.Image +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.Stack +import com.badlogic.gdx.utils.Align +import com.unciv.UncivGame +import com.unciv.logic.event.EventBus +import com.unciv.logic.multiplayer.HasMultiplayerGameName +import com.unciv.logic.multiplayer.MultiplayerGameNameChanged +import com.unciv.logic.multiplayer.MultiplayerGameUpdateEnded +import com.unciv.logic.multiplayer.MultiplayerGameUpdateStarted +import com.unciv.logic.multiplayer.MultiplayerGameUpdated +import com.unciv.logic.multiplayer.OnlineMultiplayerGame +import com.unciv.logic.multiplayer.isUsersTurn +import com.unciv.ui.crashhandling.launchCrashHandling +import com.unciv.ui.crashhandling.postCrashHandlingRunnable +import com.unciv.ui.images.ImageGetter +import com.unciv.ui.utils.BaseScreen +import com.unciv.ui.utils.onClick +import com.unciv.ui.utils.setSize +import kotlinx.coroutines.delay +import java.time.Duration +import java.time.Instant + +class MultiplayerStatusButton( + /*val*/ screen: BaseScreen, + curGame: OnlineMultiplayerGame? +) : Button(BaseScreen.skin) { + private var curGameName = curGame?.name + private val multiplayerImage = createMultiplayerImage() + private val loadingImage = createLoadingImage() + private val turnIndicator = TurnIndicator() + private val turnIndicatorCell: Cell + private val gameNamesWithCurrentTurn = getInitialGamesWithCurrentTurn() + private var loadingStarted: Instant? = null + + private val events = EventBus.EventReceiver() + + init { + turnIndicatorCell = add().padTop(10f).padBottom(10f) + add(Stack(multiplayerImage, loadingImage)).pad(5f) + + updateTurnIndicator(flash = false) // no flash since this is just the initial construction + events.receive(MultiplayerGameUpdated::class) { + val shouldUpdate = if (it.preview.isUsersTurn()) { + gameNamesWithCurrentTurn.add(it.name) + } else { + gameNamesWithCurrentTurn.remove(it.name) + } + if (shouldUpdate) postCrashHandlingRunnable { + updateTurnIndicator() + } + } + + val curGameFilter: (HasMultiplayerGameName) -> Boolean = { it.name == curGameName } + + events.receive(MultiplayerGameNameChanged::class, curGameFilter) { + curGameName = it.newName + } + + events.receive(MultiplayerGameUpdateStarted::class, curGameFilter) { startLoading() } + events.receive(MultiplayerGameUpdateEnded::class, curGameFilter) { stopLoading() } + + onClick { + MultiplayerStatusPopup(screen).open() + } + } + + private fun startLoading() { + loadingStarted = Instant.now() + + if (UncivGame.Current.settings.continuousRendering) { + loadingImage.clearActions() + loadingImage.addAction(Actions.repeat(RepeatAction.FOREVER,Actions.rotateBy(-90f, 1f))) + } + + loadingImage.isVisible = true + + multiplayerImage.color.a = 0.4f + } + + private fun stopLoading() { + val loadingTime = Duration.between(loadingStarted ?: Instant.now(), Instant.now()) + val waitFor = if (loadingTime.toMillis() < 500) { + // Some servers might reply almost instantly. That's nice and all, but the user will just see a blinking icon in that case + // and won't be able to make out what it was. So we just show the loading indicator a little longer even though it's already done. + Duration.ofMillis(500 - loadingTime.toMillis()) + } else { + Duration.ZERO + } + launchCrashHandling("Hide loading indicator") { + delay(waitFor.toMillis()) + postCrashHandlingRunnable { + loadingImage.clearActions() + loadingImage.isVisible = false + multiplayerImage.color.a = 1f + } + } + } + + private fun getInitialGamesWithCurrentTurn(): MutableSet { + return findGamesToBeNotifiedAbout(UncivGame.Current.onlineMultiplayer.games) + } + + /** @return set of gameIds */ + private fun findGamesToBeNotifiedAbout(games: Iterable): MutableSet { + return games + .filter { it.name != curGameName } + .filter { it.preview?.isUsersTurn() == true } + .map { it.name } + .toMutableSet() + } + + + private fun createMultiplayerImage(): Image { + val img = ImageGetter.getImage("OtherIcons/Multiplayer") + img.setSize(40f) + return img + } + + private fun createLoadingImage(): Image { + val img = ImageGetter.getImage("OtherIcons/Loading") + img.setSize(40f) + img.isVisible = false + img.setOrigin(Align.center) + return img + } + + private fun updateTurnIndicator(flash: Boolean = true) { + if (gameNamesWithCurrentTurn.size == 0) { + turnIndicatorCell.clearActor() + } else { + turnIndicatorCell.setActor(turnIndicator) + turnIndicator.update(gameNamesWithCurrentTurn.size) + } + + // flash so the user sees an better update + if (flash) { + turnIndicator.flash() + } + } +} + +private class TurnIndicator : HorizontalGroup() { + val gameAmount = Label("2", BaseScreen.skin) + val image: Image + init { + image = ImageGetter.getImage("OtherIcons/ExclamationMark") + image.setSize(30f) + addActor(image) + } + + fun update(gamesWithUpdates: Int) { + if (gamesWithUpdates < 2) { + gameAmount.remove() + } else { + gameAmount.setText(gamesWithUpdates) + addActorAt(0, gameAmount) + } + } + + fun flash() { + // using a gdx Action would be nicer, but we don't necessarily have continuousRendering on and we still want to flash + flash(6, Color.WHITE, Color.ORANGE) + } + private fun flash(alternations: Int, curColor: Color, nextColor: Color) { + if (alternations == 0) return + gameAmount.color = nextColor + image.color = nextColor + launchCrashHandling("StatusButton color flash") { + delay(500) + postCrashHandlingRunnable { + flash(alternations - 1, nextColor, curColor) + } + } + } +} diff --git a/core/src/com/unciv/ui/worldscreen/status/MultiplayerStatusPopup.kt b/core/src/com/unciv/ui/worldscreen/status/MultiplayerStatusPopup.kt new file mode 100644 index 0000000000..45aab7bdd9 --- /dev/null +++ b/core/src/com/unciv/ui/worldscreen/status/MultiplayerStatusPopup.kt @@ -0,0 +1,48 @@ +package com.unciv.ui.worldscreen.status + +import com.unciv.UncivGame +import com.unciv.logic.multiplayer.OnlineMultiplayerGame +import com.unciv.models.translations.tr +import com.unciv.ui.multiplayer.GameList +import com.unciv.ui.multiplayer.MultiplayerHelpers +import com.unciv.ui.pickerscreens.PickerPane +import com.unciv.ui.popup.Popup +import com.unciv.ui.utils.BaseScreen +import com.unciv.ui.utils.onClick + +class MultiplayerStatusPopup( + screen: BaseScreen, +) : Popup(screen) { + + val pickerPane = PickerPane() + var selectedGame: OnlineMultiplayerGame? = null + + init { + val pickerCell = add() + .width(700f).fillX().expandX() + .minHeight(screen.stage.height * 0.5f) + .maxHeight(screen.stage.height * 0.8f) + + val gameList = GameList(::gameSelected) + pickerPane.topTable.add(gameList) + pickerPane.rightSideButton.setText("Load game".tr()) + pickerPane.closeButton.onClick(::close) + pickerCell.setActor(pickerPane) + pickerPane.rightSideButton.onClick { + close() + val game = selectedGame + if (game != null) { + MultiplayerHelpers.loadMultiplayerGame(screen, game) + } + } + } + + private fun gameSelected(gameName: String) { + val multiplayerGame = UncivGame.Current.onlineMultiplayer.getGameByName(gameName)!! + selectedGame = multiplayerGame + pickerPane.setRightSideButtonEnabled(true) + pickerPane.rightSideButton.setText("Load [$gameName]".tr()) + pickerPane.descriptionLabel.setText(MultiplayerHelpers.buildDescriptionText(multiplayerGame)) + } + +} diff --git a/core/src/com/unciv/ui/worldscreen/status/NextTurnButton.kt b/core/src/com/unciv/ui/worldscreen/status/NextTurnButton.kt index 3eae812824..8b15433dc5 100644 --- a/core/src/com/unciv/ui/worldscreen/status/NextTurnButton.kt +++ b/core/src/com/unciv/ui/worldscreen/status/NextTurnButton.kt @@ -19,7 +19,11 @@ class NextTurnButton( keyPressDispatcher['n'] = action } - fun update(isSomethingOpen: Boolean, isPlayersTurn: Boolean, waitingForAutosave: Boolean, nextTurnAction: NextTurnAction? = null) { + fun update(isSomethingOpen: Boolean, + isPlayersTurn: Boolean, + waitingForAutosave: Boolean, + isNextTurnUpdateRunning: Boolean, + nextTurnAction: NextTurnAction? = null) { if (nextTurnAction != null) { this.nextTurnAction = nextTurnAction setText(nextTurnAction.text.tr()) @@ -27,8 +31,8 @@ class NextTurnButton( pack() } - isEnabled = !isSomethingOpen && isPlayersTurn && !waitingForAutosave + isEnabled = !isSomethingOpen && isPlayersTurn && !waitingForAutosave && !isNextTurnUpdateRunning } } -class NextTurnAction(val text: String, val color: Color, val action: () -> Unit) \ No newline at end of file +class NextTurnAction(val text: String, val color: Color, val action: () -> Unit) diff --git a/core/src/com/unciv/ui/worldscreen/status/StatusButtons.kt b/core/src/com/unciv/ui/worldscreen/status/StatusButtons.kt new file mode 100644 index 0000000000..da1e0c6ecd --- /dev/null +++ b/core/src/com/unciv/ui/worldscreen/status/StatusButtons.kt @@ -0,0 +1,26 @@ +package com.unciv.ui.worldscreen.status + +import com.badlogic.gdx.scenes.scene2d.ui.HorizontalGroup + +class StatusButtons( + nextTurnButton: NextTurnButton, + multiplayerStatusButton: MultiplayerStatusButton? = null +) : HorizontalGroup() { + var multiplayerStatusButton: MultiplayerStatusButton? = multiplayerStatusButton + set(button) { + multiplayerStatusButton?.remove() + field = button + if (button != null) { + addActorAt(0, button) + } + } + + init { + space(10f) + right() + if (multiplayerStatusButton != null) { + addActor(multiplayerStatusButton) + } + addActor(nextTurnButton) + } +} diff --git a/tests/src/com/unciv/logic/event/EventBusTest.kt b/tests/src/com/unciv/logic/event/EventBusTest.kt new file mode 100644 index 0000000000..ad31f90a37 --- /dev/null +++ b/tests/src/com/unciv/logic/event/EventBusTest.kt @@ -0,0 +1,46 @@ +package com.unciv.logic.event + +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import java.lang.ref.WeakReference + +class EventBusTest { + open class Parent : Event + class Child : Parent() + + @Test + fun `should receive parent event once when receiving child event`() { + val events = EventBus.EventReceiver() + var callCount = 0 + events.receive(Parent::class) { ++callCount } + + EventBus.send(Child()) + + assertThat(callCount, `is`(1)) + } + + @Test + fun `should not receive parent event when listening to child event`() { + val events = EventBus.EventReceiver() + var callCount = 0 + events.receive(Child::class) { callCount++ } + + EventBus.send(Parent()) + + assertThat(callCount, `is`(0)) + } + + @Test + fun `should stop listening to events when requested`() { + val events = EventBus.EventReceiver() + var callCount = 0 + events.receive(Child::class) { callCount++ } + + EventBus.send(Child()) + events.stopReceiving() + EventBus.send(Child()) + + assertThat(callCount, `is`(1)) + } +}