diff --git a/PolyMod.csproj b/PolyMod.csproj index 7cf560e..e5bc0f7 100644 --- a/PolyMod.csproj +++ b/PolyMod.csproj @@ -10,8 +10,8 @@ https://polymod.dev/nuget/v3/index.json; IL2CPP - 1.1.9-pre - 2.13.2.14360 + 1.2.0-pre + 2.13.0.14218 PolyModdingTeam The Battle of Polytopia's mod loader. IDE0130 diff --git a/installer/main.py b/installer/main.py index 9454650..a4f1fef 100644 --- a/installer/main.py +++ b/installer/main.py @@ -15,8 +15,8 @@ "win32": "win", "darwin": "macos", }[sys.platform] -BEPINEX = f"733/BepInEx-Unity.IL2CPP-{OS}-x64-6.0.0-be.733%2B995f049" -POLYMOD = "https://github.com/PolyModdingTeam/PolyMod/releases/latest/download/PolyMod.dll" +BEPINEX = "https://polymod.dev/data/bepinex.txt" +POLYMOD = "https://api.github.com/repos/PolyModdingTeam/PolyMod/releases" def resource_path(path): @@ -54,6 +54,7 @@ def prepare(target): return path_entry.configure(state=customtkinter.DISABLED) browse_button.configure(state=customtkinter.DISABLED) + prerelease_checkbox.destroy() install_button.destroy() uninstall_button.destroy() progress_bar = customtkinter.CTkProgressBar(app, determinate_speed=50 / 2) @@ -65,13 +66,16 @@ def prepare(target): def install(path): to_zip( requests.get( - f"https://builds.bepinex.dev/projects/bepinex_be/{BEPINEX}.zip" + requests.get(BEPINEX).text.strip().replace("{os}", OS) ) ).extractall(path) progress_bar.step() + for release in requests.get(POLYMOD).json(): + if release["prerelease"] and not prerelease_checkbox.get(): continue + latest = release open(path + "/BepInEx/plugins/PolyMod.dll", "wb").write( - requests.get(POLYMOD).content + requests.get(latest["assets"][0]["browser_download_url"]).content ) progress_bar.step() @@ -133,6 +137,8 @@ def quit(): app, placeholder_text="Game path", width=228) browse_button = customtkinter.CTkButton( app, text="Browse", command=browse, width=1) +prerelease_checkbox = customtkinter.CTkCheckBox( + app, text="Prerelease", width=1) install_button = customtkinter.CTkButton( app, text="Install", command=lambda: prepare(install)) uninstall_button = customtkinter.CTkButton( @@ -140,7 +146,8 @@ def quit(): path_entry.grid(column=0, row=0, padx=5, pady=5) browse_button.grid(column=1, row=0, padx=(0, 5), pady=5) -install_button.grid(column=0, row=1, columnspan=2, padx=5, pady=5) -uninstall_button.grid(column=0, row=2, columnspan=2, padx=5, pady=5) +prerelease_checkbox.grid(column=0, row=1, columnspan=2, padx=5, pady=5) +install_button.grid(column=0, row=2, columnspan=2, padx=5, pady=5) +uninstall_button.grid(column=0, row=3, columnspan=2, padx=5, pady=5) app.mainloop() diff --git a/resources/localization.json b/resources/localization.json index 8a39199..efc9a56 100644 --- a/resources/localization.json +++ b/resources/localization.json @@ -44,8 +44,8 @@ "German (Germany)": "{0} Willkommen! {1}\nDiese Mods sind gerade eben geladen:" }, "polymod_hub_mod": { - "English": "Name: {0}\nStatus: {1}\nAuthors: {2}\nVersion: {3}", - "Russian": "Имя: {0}\nСтатус: {1}\nАвторы: {2}\nВерсия: {3}", + "English": "Name: {0}\nStatus: {1}\nAuthors: {2}\nVersion: {3}\nDescription: {4}", + "Russian": "Имя: {0}\nСтатус: {1}\nАвторы: {2}\nВерсия: {3}\nОписание: {4}", "Turkish": "İsim: {0}\nDurum: {1}\nYaratıcılar: {2}\nSürüm: {3}", "Spanish (Mexico)": "Titulo: {0}\nEstado: {1}\nPublicador: {2}\nVersion: {3}", "French (France)": "Titre: {0}\nEtat: {1}\nAuteurs: {2}\nVersion: {3}", @@ -160,5 +160,53 @@ "Portuguese (Brazil)": "Essa versão do PolyMod não foi desenvolvida para a versão atual do aplicativo e pode não funcionar corretamente!", "Elyrion": "πȱ∫ỹmȱδ ƒƒƒƒƒƒƒ ŋȱŧ ȱrrȱ #₺rr∑ŋŧ ƒƒƒƒƒƒƒ ỹ maỹ ŋȱŧ ~ȱr§ #ȱrr∑#ŧ∫ỹ!", "German (Germany)": "Diese Version von PolyMod ist nicht für die aktuelle Version der Anwendung ausgelegt und könnte nicht funktionieren!" + }, + "polymod_debug": { + "English": "Debug", + "Russian": "Дебаг" + }, + "polymod_autoupdate": { + "English": "Auto-update", + "Russian": "Автообновление" + }, + "polymod_autoupdate_alpha": { + "English": "Include alphas", + "Russian": "Include alphas" + }, + "polymod_autoupdate_description": { + "English": "New update available!", + "Russian": "Доступно новое обновление!" + }, + "polymod_autoupdate_update": { + "English": "Update", + "Russian": "Обновить" + }, + "polymod_hub_config": { + "English": "CONFIG", + "Russian": "КОНФИГ" + }, + "polymod_hub_config_enable": { + "English": "ENABLE {0}", + "Russian": "ВКЛЮЧИТЬ {0}" + }, + "polymod_hub_config_disable": { + "English": "DISABLE {0}", + "Russian": "ВЫЛЮЧИТЬ {0}" + }, + "polymod_config_setto": { + "English": "{0} is set to {1}!", + "Russian": "{0} задан на {1}!" + }, + "polymod_hub_spriteinfo_update": { + "English": "UPDATE SPRITES", + "Russian": "ОБНОВИТЬ СПРАЙТЫ" + }, + "polymod_spriteinfo_updated": { + "English": "Sprite info for mod {0} updated!", + "Russian": "Данные спрайтов обновлены для мода {0}!" + }, + "polymod_spriteinfo_notupdated": { + "English": "No sprite infos were found!", + "Russian": "Данные спрайтов не найдены!" } } diff --git a/src/Json/EnumCacheJson.cs b/src/Json/EnumCacheJson.cs index 8ee87be..9b98432 100644 --- a/src/Json/EnumCacheJson.cs +++ b/src/Json/EnumCacheJson.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; namespace PolyMod.Json; + internal class EnumCacheJson : JsonConverter where T : struct, Enum { public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/src/Json/Vector2Json.cs b/src/Json/Vector2Json.cs index 628c94f..847cc62 100644 --- a/src/Json/Vector2Json.cs +++ b/src/Json/Vector2Json.cs @@ -3,6 +3,7 @@ using UnityEngine; namespace PolyMod.Json; + internal class Vector2Json : JsonConverter { public override Vector2 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/src/Json/VersionJson.cs b/src/Json/VersionJson.cs index 38c08ec..5ccf7ec 100644 --- a/src/Json/VersionJson.cs +++ b/src/Json/VersionJson.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; namespace PolyMod.Json; + internal class VersionJson : JsonConverter { public override Version? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/src/Loader.cs b/src/Loader.cs index 8b3662f..bdf79a2 100644 --- a/src/Loader.cs +++ b/src/Loader.cs @@ -6,6 +6,7 @@ using PolyMod.Json; using PolyMod.Managers; using Polytopia.Data; +using PolytopiaBackendBase.Game; using System.Data; using System.Diagnostics; using System.Globalization; @@ -16,6 +17,7 @@ using UnityEngine; namespace PolyMod; + public static class Loader { internal static Dictionary typeMappings = new() @@ -30,9 +32,115 @@ public static class Loader { "tribeAbility", typeof(TribeAbility.Type) }, { "unitAbility", typeof(UnitAbility.Type) }, { "improvementAbility", typeof(ImprovementAbility.Type) }, - { "playerAbility", typeof(PlayerAbility.Type) } + { "playerAbility", typeof(PlayerAbility.Type) }, + { "weaponData", typeof(UnitData.WeaponEnum) } + }; + internal static List gamemodes = new(); + private static readonly Dictionary> typeHandlers = new() + { + [typeof(TribeData.Type)] = new((token, duringEnumCacheCreation) => + { + if (duringEnumCacheCreation) + { + Registry.customTribes.Add((TribeData.Type)Registry.autoidx); + token["style"] = Registry.climateAutoidx; + token["climate"] = Registry.climateAutoidx; + Registry.climateAutoidx++; + } + else + { + if (token["preview"] != null) + { + Visual.PreviewTile[] preview = JsonSerializer.Deserialize(token["preview"].ToString())!; + Registry.tribePreviews[Util.GetJTokenName(token)] = preview; + } + } + }), + + [typeof(UnitData.Type)] = new((token, duringEnumCacheCreation) => + { + if (duringEnumCacheCreation) + { + if (token["prefab"] != null) + { + Registry.prefabNames.Add((int)(UnitData.Type)Registry.autoidx, CultureInfo.CurrentCulture.TextInfo.ToTitleCase(token["prefab"]!.ToString())); + } + } + else + { + if (token["embarksTo"] != null) + { + string unitId = Util.GetJTokenName(token); + string embarkUnitId = token["embarksTo"].ToString(); + Main.embarkNames[unitId] = embarkUnitId; + } + if (token["weapon"] != null) + { + string weaponString = token["weapon"].ToString(); + if (EnumCache.TryGetType(weaponString, out UnitData.WeaponEnum type)) + { + token["weapon"] = (int)type; + } + } + } + }), + + [typeof(ImprovementData.Type)] = new((token, duringEnumCacheCreation) => + { + if (duringEnumCacheCreation) + { + ImprovementData.Type improvementPrefabType = ImprovementData.Type.CustomsHouse; + if (token["prefab"] != null) + { + string prefabId = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(token["prefab"]!.ToString()); + if (Enum.TryParse(prefabId, out ImprovementData.Type parsedType)) + improvementPrefabType = parsedType; + } + PrefabManager.improvements.TryAdd((ImprovementData.Type)Registry.autoidx, PrefabManager.improvements[improvementPrefabType]); + } + else + { + if (token["attractsResource"] != null) + { + string improvementId = Util.GetJTokenName(token); + string attractsId = token["attractsResource"].ToString(); + Main.attractsResourceNames[improvementId] = attractsId; + } + if (token["attractsToTerrain"] != null) + { + string improvementId = Util.GetJTokenName(token); + string attractsId = token["attractsToTerrain"].ToString(); + Main.attractsTerrainNames[improvementId] = attractsId; + } + } + }), + + [typeof(ResourceData.Type)] = new((token, duringEnumCacheCreation) => + { + if (duringEnumCacheCreation) + { + ResourceData.Type resourcePrefabType = ResourceData.Type.Game; + if (token["prefab"] != null) + { + string prefabId = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(token["prefab"]!.ToString()); + if (Enum.TryParse(prefabId, out ResourceData.Type parsedType)) + resourcePrefabType = parsedType; + } + PrefabManager.resources.TryAdd((ResourceData.Type)Registry.autoidx, PrefabManager.resources[resourcePrefabType]); + } + }) }; + public record GameModeButtonsInformation(int gameModeIndex, UIButtonBase.ButtonAction action, int? buttonIndex, Sprite? sprite); + + public static void AddGameModeButton(string id, UIButtonBase.ButtonAction action, Sprite? sprite) + { + EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx); + EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx); + gamemodes.Add(new GameModeButtonsInformation(Registry.gameModesAutoidx, action, null, sprite)); + Registry.gameModesAutoidx++; + } + public static void AddPatchDataType(string typeId, Type type) { if (!typeMappings.ContainsKey(typeId)) @@ -88,30 +196,42 @@ internal static void LoadMods(Dictionary mods) } } - if (manifest != null - && manifest.id != null - && Regex.IsMatch(manifest.id, @"^(?!polytopia$)[a-z_]+$") - && manifest.version != null - && manifest.authors != null - && manifest.authors.Length != 0 - ) + if (manifest == null) { - if (mods.ContainsKey(manifest.id)) - { - Plugin.logger.LogError($"Mod {manifest.id} already exists"); - continue; - } - mods.Add(manifest.id, new( - manifest, - Mod.Status.Success, - files - )); - Plugin.logger.LogInfo($"Registered mod {manifest.id}"); + Plugin.logger.LogError($"Mod manifest not found in {modContainer}"); + continue; } - else + if (manifest.id == null) + { + Plugin.logger.LogError($"Mod id not found in {modContainer}"); + continue; + } + if (!Regex.IsMatch(manifest.id, @"^(?!polytopia$)[a-z_]+$")) + { + Plugin.logger.LogError($"Mod id {manifest.id} is invalid in {modContainer}"); + continue; + } + if (manifest.version == null) + { + Plugin.logger.LogError($"Mod version not found in {modContainer}"); + continue; + } + if (manifest.authors == null || manifest.authors.Length == 0) + { + Plugin.logger.LogError($"Mod authors not found in {modContainer}"); + continue; + } + if (mods.ContainsKey(manifest.id)) { - Plugin.logger.LogError("An invalid mod manifest was found or not found at all"); + Plugin.logger.LogError($"Mod {manifest.id} already exists"); + continue; } + mods.Add(manifest.id, new( + manifest, + Mod.Status.Success, + files + )); + Plugin.logger.LogInfo($"Registered mod {manifest.id}"); } foreach (var (id, mod) in mods) @@ -250,7 +370,7 @@ public static void LoadLocalizationFile(Mod mod, Mod.File file) { Loc.BuildAndLoadLocalization(JsonSerializer .Deserialize>>(file.bytes)!); - Plugin.logger.LogInfo($"Registried localization from {mod.id} mod"); + Plugin.logger.LogInfo($"Registered localization from {mod.id} mod"); } catch (Exception e) { @@ -280,24 +400,48 @@ public static void LoadSpriteFile(Mod mod, Mod.File file) Registry.sprites.Add(name, sprite); } - public static void LoadSpriteInfoFile(Mod mod, Mod.File file) + public static void UpdateSprite(string name) + { + if (Registry.spriteInfos.ContainsKey(name) && Registry.sprites.ContainsKey(name)) + { + Visual.SpriteInfo spriteData = Registry.spriteInfos[name]; + Sprite sprite = Visual.BuildSpriteWithTexture( + Registry.sprites[name].texture, + spriteData.pivot, + spriteData.pixelsPerUnit + ); + GameManager.GetSpriteAtlasManager().cachedSprites["Heads"][name] = sprite; + Registry.sprites[name] = sprite; + } + } + + public static Dictionary? LoadSpriteInfoFile(Mod mod, Mod.File file) { try { - Registry.spriteInfos = Registry.spriteInfos - .Concat(JsonSerializer.Deserialize>( - file.bytes, - new JsonSerializerOptions() - { - Converters = { new Vector2Json() }, - } - )!) - .ToDictionary(e => e.Key, e => e.Value); - Plugin.logger.LogInfo($"Registried sprite data from {mod.id} mod"); + var deserialized = JsonSerializer.Deserialize>( + file.bytes, + new JsonSerializerOptions() + { + Converters = { new Vector2Json() }, + } + ); + + if (deserialized != null) + { + foreach (var kvp in deserialized) + { + Registry.spriteInfos[kvp.Key] = kvp.Value; + } + } + + Plugin.logger.LogInfo($"Registered sprite data from {mod.id} mod"); + return deserialized; } catch (Exception e) { Plugin.logger.LogError($"Error on loading sprite data from {mod.id} mod: {e.Message}"); + return null; } } @@ -310,6 +454,119 @@ public static void LoadAudioFile(Mod mod, Mod.File file) // TODO: issue #71 } + public static void LoadPrefabInfoFile(Mod mod, Mod.File file) + { + try + { + var prefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions + { + Converters = { new Vector2Json() }, + PropertyNameCaseInsensitive = true, + }); + if (prefab == null || prefab.type != Visual.PrefabType.Unit) + return; + + var baseUnit = PrefabManager.GetPrefab(UnitData.Type.Warrior, TribeData.Type.Imperius, SkinType.Default); + if (baseUnit == null) + return; + + var unitInstance = GameObject.Instantiate(baseUnit); + if (unitInstance == null) + return; + + var spriteContainer = unitInstance.transform.GetChild(0); + var material = ClearExistingPartsAndExtractMaterial(spriteContainer); + + var visualParts = ApplyVisualParts(prefab.visualParts, spriteContainer, material); + + var svr = unitInstance.GetComponent(); + svr.visualParts = visualParts.ToArray(); + + GameObject.DontDestroyOnLoad(unitInstance.gameObject); + Registry.unitPrefabs.Add(prefab, unitInstance.GetComponent()); + + Plugin.logger.LogInfo($"Registered prefab info from {mod.id} mod"); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading prefab info from {mod.id} mod: {e.Message}"); + } + } + + private static Material? ClearExistingPartsAndExtractMaterial(Transform spriteContainer) + { + Material? material = null; + for (int i = 0; i < spriteContainer.childCount; i++) + { + var child = spriteContainer.GetChild(i); + if (child.gameObject.name == "Head") + { + var renderer = child.GetComponent(); + if (renderer != null) + material = renderer.material; + } + GameObject.Destroy(child.gameObject); + } + return material; + } + + private static List ApplyVisualParts( + List partInfos, + Transform spriteContainer, + Material? material) + { + List parts = new(); + + foreach (var info in partInfos) + { + parts.Add(CreateVisualPart(info, spriteContainer, material)); + } + + return parts; + } + + private static SkinVisualsReference.VisualPart CreateVisualPart( + Visual.VisualPartInfo info, + Transform parent, + Material? material) + { + var visualPartObj = new GameObject(info.gameObjectName); + visualPartObj.transform.SetParent(parent); + visualPartObj.transform.position = info.coordinates; + visualPartObj.transform.localScale = info.scale; + visualPartObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation); + + var outlineObj = new GameObject("Outline"); + outlineObj.transform.SetParent(visualPartObj.transform); + outlineObj.transform.position = info.coordinates; + outlineObj.transform.localScale = info.scale; + outlineObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation); + + var visualPart = new SkinVisualsReference.VisualPart + { + DefaultSpriteName = info.baseName, + visualPart = visualPartObj, + outline = outlineObj, + tintable = info.tintable + }; + + var renderer = visualPartObj.AddComponent(); + renderer.material = material; + renderer.sortingLayerName = "Units"; + renderer.sortingOrder = info.tintable ? 0 : 1; + + visualPart.renderer = new SkinVisualsReference.RendererUnion { spriteRenderer = renderer }; + + var outlineRenderer = outlineObj.AddComponent(); + outlineRenderer.material = material; + outlineRenderer.sortingLayerName = "Units"; + outlineRenderer.sortingOrder = -1; + + visualPart.outlineRenderer = new SkinVisualsReference.RendererUnion { spriteRenderer = outlineRenderer }; + + return visualPart; + } + public static void LoadGameLogicDataPatch(Mod mod, JObject gld, JObject patch) { try @@ -320,66 +577,22 @@ public static void LoadGameLogicDataPatch(Mod mod, JObject gld, JObject patch) JObject? token = jtoken.TryCast(); if (token != null) { - if (token["idx"] != null && (int)token["idx"] == -1) + string dataType = Util.GetJTokenName(token, 2); + if (typeMappings.TryGetValue(dataType, out Type? targetType)) { - string id = Util.GetJTokenName(token); - string dataType = Util.GetJTokenName(token, 2); - token["idx"] = Registry.autoidx; - if (typeMappings.TryGetValue(dataType, out Type? targetType)) + if (token["idx"] != null && (int)token["idx"] == -1) { + string id = Util.GetJTokenName(token); + token["idx"] = Registry.autoidx; MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(targetType).GetMethod("AddMapping"); if (methodInfo != null) { methodInfo.Invoke(null, new object[] { id, Registry.autoidx }); methodInfo.Invoke(null, new object[] { id, Registry.autoidx }); - if (targetType == typeof(TribeData.Type)) - { - Registry.customTribes.Add((TribeData.Type)Registry.autoidx); - token["style"] = Registry.climateAutoidx; - token["climate"] = Registry.climateAutoidx; - Registry.climateAutoidx++; - } - else if (targetType == typeof(UnitData.Type)) - { - UnitData.Type unitPrefabType = UnitData.Type.Scout; - if (token["prefab"] != null) - { - TextInfo textInfo = CultureInfo.CurrentCulture.TextInfo; - string prefabId = textInfo.ToTitleCase(token["prefab"].ToString()); - if (Enum.TryParse(prefabId, out UnitData.Type parsedType)) - { - unitPrefabType = parsedType; - } - } - PrefabManager.units.TryAdd((int)(UnitData.Type)Registry.autoidx, PrefabManager.units[(int)unitPrefabType]); - } - else if (targetType == typeof(ImprovementData.Type)) - { - ImprovementData.Type improvementPrefabType = ImprovementData.Type.CustomsHouse; - if (token["prefab"] != null) - { - TextInfo textInfo = CultureInfo.CurrentCulture.TextInfo; - string prefabId = textInfo.ToTitleCase(token["prefab"].ToString()); - if (Enum.TryParse(prefabId, out ImprovementData.Type parsedType)) - { - improvementPrefabType = parsedType; - } - } - PrefabManager.improvements.TryAdd((ImprovementData.Type)Registry.autoidx, PrefabManager.improvements[improvementPrefabType]); - } - else if (targetType == typeof(ResourceData.Type)) + + if (typeHandlers.TryGetValue(targetType, out var handler)) { - ResourceData.Type resourcePrefabType = ResourceData.Type.Game; - if (token["prefab"] != null) - { - TextInfo textInfo = CultureInfo.CurrentCulture.TextInfo; - string prefabId = textInfo.ToTitleCase(token["prefab"].ToString()); - if (Enum.TryParse(prefabId, out ResourceData.Type parsedType)) - { - resourcePrefabType = parsedType; - } - } - PrefabManager.resources.TryAdd((ResourceData.Type)Registry.autoidx, PrefabManager.resources[resourcePrefabType]); + handler(token, true); } Plugin.logger.LogInfo("Created mapping for " + targetType.ToString() + " with id " + id + " and index " + Registry.autoidx); Registry.autoidx++; @@ -388,18 +601,23 @@ public static void LoadGameLogicDataPatch(Mod mod, JObject gld, JObject patch) } } } - foreach (JToken jtoken in patch.SelectTokens("$.tribeData.*").ToArray()) + foreach (JToken jtoken in patch.SelectTokens("$.*.*").ToArray()) { - JObject token = jtoken.Cast(); - - if (token["preview"] != null) + JObject? token = jtoken.TryCast(); + if (token != null) { - Visual.PreviewTile[] preview = JsonSerializer.Deserialize(token["preview"].ToString())!; - Registry.tribePreviews[Util.GetJTokenName(token)] = preview; + string dataType = Util.GetJTokenName(token, 2); + if (typeMappings.TryGetValue(dataType, out Type? targetType)) + { + if (typeHandlers.TryGetValue(targetType, out var handler)) + { + handler(token, false); + } + } } } gld.Merge(patch, new() { MergeArrayHandling = MergeArrayHandling.Replace, MergeNullValueHandling = MergeNullValueHandling.Merge }); - Plugin.logger.LogInfo($"Registried patch from {mod.id} mod"); + Plugin.logger.LogInfo($"Registered patch from {mod.id} mod"); } catch (Exception e) { @@ -408,6 +626,14 @@ public static void LoadGameLogicDataPatch(Mod mod, JObject gld, JObject patch) } } + public static void LoadAssetBundle(Mod mod, Mod.File file) + { + Registry.assetBundles.Add( + Path.GetFileNameWithoutExtension(file.name), + AssetBundle.LoadFromMemory(file.bytes) + ); + } + public static void HandleSkins(JObject gld, JObject patch) { foreach (JToken jtoken in patch.SelectTokens("$.tribeData.*").ToArray()) diff --git a/src/Managers/Audio.cs b/src/Managers/Audio.cs index 7eecf93..f93ee78 100644 --- a/src/Managers/Audio.cs +++ b/src/Managers/Audio.cs @@ -4,6 +4,7 @@ using UnityEngine.Networking; namespace PolyMod.Managers; + public static class Audio { [HarmonyPostfix] diff --git a/src/Managers/AutoUpdate.cs b/src/Managers/AutoUpdate.cs new file mode 100644 index 0000000..ef7a043 --- /dev/null +++ b/src/Managers/AutoUpdate.cs @@ -0,0 +1,162 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Text.Json; +using HarmonyLib; +using UnityEngine; + +namespace PolyMod.Managers; + +internal static class AutoUpdate +{ + [HarmonyPostfix] + [HarmonyPatch(typeof(StartScreen), nameof(StartScreen.Start))] + private static void StartScreen_Start() + { + if (!Plugin.config.autoUpdate) return; + if (Environment.GetEnvironmentVariable("WINEPREFIX") != null) + { + Plugin.logger.LogError("Autoupdate is not supported on Wine!"); + return; + } + HttpClient client = new(); + client.DefaultRequestHeaders.Add("User-Agent", "PolyMod"); + try + { + var json = JsonDocument.Parse( + client.GetAsync("https://api.github.com/repos/PolyModdingTeam/PolyMod/releases").UnwrapAsync() + .Content.ReadAsStringAsync().UnwrapAsync() + ); + JsonElement? latest = null; + for (int i = 0; i < json.RootElement.GetArrayLength(); i++) + { + var release = json.RootElement[i]; + if (release.GetProperty("prerelease").GetBoolean() && !Plugin.config.updatePrerelease) continue; + latest = release; + break; + } + string newVersion = latest?.GetProperty("tag_name").GetString()!.TrimStart('v')!; + if (newVersion.IsVersionOlderOrEqual(Plugin.VERSION)) return; + string os = Application.platform switch + { + RuntimePlatform.WindowsPlayer => "win", + RuntimePlatform.LinuxPlayer => "linux", + RuntimePlatform.OSXPlayer => "macos", + _ => "unknown", + }; + if (os == "unknown") + { + Plugin.logger.LogError("Unsupported platform for autoupdate!"); + return; + } + string bepinex_url = client + .GetAsync("https://polymod.dev/data/bepinex.txt").UnwrapAsync() + .Content.ReadAsStringAsync().UnwrapAsync() + .Replace("{os}", os); + void Update() + { + Time.timeScale = 0; + File.WriteAllBytes( + Path.Combine(Plugin.BASE_PATH, "PolyMod.new.dll"), + client.GetAsync(latest?.GetProperty("assets")[0].GetProperty("browser_download_url").GetString()!).UnwrapAsync() + .Content.ReadAsByteArrayAsync().UnwrapAsync() + ); + using ZipArchive bepinex = new(client.GetAsync(bepinex_url).UnwrapAsync().Content.ReadAsStream()); + bepinex.ExtractToDirectory(Path.Combine(Plugin.BASE_PATH, "New"), overwriteFiles: true); + ProcessStartInfo info = new() + { + WorkingDirectory = Path.Combine(Plugin.BASE_PATH), + CreateNoWindow = true, + }; + if (Application.platform == RuntimePlatform.WindowsPlayer) + { + string batchPath = Path.Combine(Plugin.BASE_PATH, "update.bat"); + File.WriteAllText(batchPath, $@" + @echo off + echo Waiting for Polytopia.exe to exit... + :waitloop + tasklist | findstr /I ""Polytopia.exe"" >nul + if not errorlevel 1 ( + timeout /T 1 >nul + goto waitloop + ) + + echo Updating... + robocopy ""New"" . /E /MOVE /NFL /NDL /NJH /NJS /NP >nul + rmdir /S /Q ""New"" + del /F /Q ""BepInEx\plugins\PolyMod.dll"" + move /Y ""PolyMod.new.dll"" ""BepInEx\plugins\PolyMod.dll"" + + echo Launching game... + start steam://rungameid/874390 + timeout /T 3 /NOBREAK >nul + exit + "); + info.FileName = "cmd.exe"; + info.Arguments = $"/C start \"\" \"{batchPath}\""; + info.WorkingDirectory = Plugin.BASE_PATH; + info.CreateNoWindow = true; + info.UseShellExecute = false; + } + if (Application.platform == RuntimePlatform.LinuxPlayer || Application.platform == RuntimePlatform.OSXPlayer) + { + string bashPath = Path.Combine(Plugin.BASE_PATH, "update.sh"); + File.WriteAllText(bashPath, $@" + #!/bin/bash + + echo ""Waiting for Polytopia to exit..."" + while pgrep -x ""Polytopia"" > /dev/null; do + sleep 1 + done + + echo ""Updating..."" + mv New/* . && rm -rf New + rm -f BepInEx/plugins/PolyMod.dll + mv -f PolyMod.new.dll BepInEx/plugins/PolyMod.dll + + echo ""Launching game..."" + xdg-open steam://rungameid/874390 & + + sleep 3 + exit 0 + "); + + System.Diagnostics.Process chmod = new System.Diagnostics.Process(); + chmod.StartInfo.FileName = "chmod"; + chmod.StartInfo.Arguments = $"+x \"{bashPath}\""; + chmod.StartInfo.UseShellExecute = false; + chmod.StartInfo.CreateNoWindow = true; + chmod.Start(); + chmod.WaitForExit(); + + info.FileName = "/bin/bash"; + info.Arguments = $"\"{bashPath}\""; + info.WorkingDirectory = Plugin.BASE_PATH; + info.CreateNoWindow = true; + info.UseShellExecute = false; + } + Process.Start(info); + Application.Quit(); + } + PopupManager.GetBasicPopup(new( + Localization.Get("polymod.autoupdate"), + Localization.Get("polymod.autoupdate.description"), + new(new PopupBase.PopupButtonData[] { + new( + "polymod.autoupdate.update", + PopupBase.PopupButtonData.States.None, + (Il2CppSystem.Action)Update + ) + })) + ).Show(); + } + catch (Exception e) + { + Plugin.logger.LogError($"Failed to check updates: {e.Message}"); + } + } + + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(AutoUpdate)); + } +} \ No newline at end of file diff --git a/src/Managers/Compatibility.cs b/src/Managers/Compatibility.cs index d270cb4..12f41fb 100644 --- a/src/Managers/Compatibility.cs +++ b/src/Managers/Compatibility.cs @@ -4,16 +4,16 @@ using UnityEngine.EventSystems; namespace PolyMod.Managers; + internal static class Compatibility { - internal static string signature = string.Empty; - internal static string looseSignature = string.Empty; + internal static string checksum = string.Empty; + internal static bool shouldResetSettings = false; private static bool sawSignatureWarning; - public static void HashSignatures(StringBuilder looseSignatureString, StringBuilder signatureString) + public static void HashSignatures(StringBuilder checksumString) { - looseSignature = Util.Hash(looseSignatureString); - signature = Util.Hash(signatureString); + checksum = Util.Hash(checksumString); } private static bool CheckSignatures(Action action, int id, BaseEventData eventData, Il2CppSystem.Guid gameId) @@ -24,15 +24,15 @@ private static bool CheckSignatures(Action action, int id, B return true; } - string[] signatures = { string.Empty, string.Empty }; + string signature = string.Empty; try { - signatures = File.ReadAllLines(Path.Combine(Application.persistentDataPath, $"{gameId}.signatures")); + signature = File.ReadAllText(Path.Combine(Application.persistentDataPath, $"{gameId}.signatures")); } catch { } - if (signatures[0] == string.Empty && signatures[1] == string.Empty) return true; + if (signature == string.Empty) return true; if (Plugin.config.debug) return true; - if (looseSignature != signatures[0]) + if (checksum != signature) { PopupManager.GetBasicPopup(new( Localization.Get("polymod.signature.mismatch"), @@ -43,23 +43,6 @@ private static bool CheckSignatures(Action action, int id, B )).Show(); return false; } - if (signature != signatures[1]) - { - PopupManager.GetBasicPopup(new( - Localization.Get("polymod.signature.mismatch"), - Localization.Get("polymod.signature.maybe.incompatible"), - new(new PopupBase.PopupButtonData[] { - new( - "OK", - callback: (UIButtonBase.ButtonAction)((int _, BaseEventData _) => { - sawSignatureWarning = true; - action(id, eventData); - }) - ) - }) - )).Show(); - return false; - } return true; } @@ -67,18 +50,33 @@ private static bool CheckSignatures(Action action, int id, B [HarmonyPatch(typeof(StartScreen), nameof(StartScreen.Start))] private static void StartScreen_Start() { - Version incompatibilityWarningLastVersion = Plugin.POLYTOPIA_VERSION.CutRevision(); + string lastChecksum = checksum; try { - incompatibilityWarningLastVersion = new(File.ReadAllText(Plugin.INCOMPATIBILITY_WARNING_LAST_VERSION_PATH)); + lastChecksum = new(File.ReadAllText(Plugin.CHECKSUM_PATH)); } catch (FileNotFoundException) { } + + File.WriteAllText( + Plugin.CHECKSUM_PATH, + checksum + ); + if (lastChecksum != checksum) + { + shouldResetSettings = true; + } + + Version incompatibilityWarningLastVersion = new(PlayerPrefs.GetString( + Plugin.INCOMPATIBILITY_WARNING_LAST_VERSION_KEY, + Plugin.POLYTOPIA_VERSION.CutRevision().ToString() + )); if (VersionManager.SemanticVersion.Cast().CutRevision() > incompatibilityWarningLastVersion) { - File.WriteAllText( - Plugin.INCOMPATIBILITY_WARNING_LAST_VERSION_PATH, + PlayerPrefs.SetString( + Plugin.INCOMPATIBILITY_WARNING_LAST_VERSION_KEY, VersionManager.SemanticVersion.Cast().CutRevision().ToString() ); + PlayerPrefs.Save(); PopupManager.GetBasicPopup(new( Localization.Get("polymod.version.mismatch"), Localization.Get("polymod.version.mismatch.description"), @@ -139,10 +137,29 @@ private static void ClientBase_CreateSession(GameSettings settings, Il2CppSystem { File.WriteAllLinesAsync( Path.Combine(Application.persistentDataPath, $"{gameId}.signatures"), - new string[] { looseSignature, signature } + new string[] { checksum } ); } + [HarmonyPrefix] + [HarmonyPatch(typeof(TribeSelectorScreen), nameof(TribeSelectorScreen.Show))] + private static bool TribeSelectorScreen_Show(bool instant = false) + { + if (shouldResetSettings) + { + RestorePreliminaryGameSettings(); + shouldResetSettings = false; + } + return true; + } + + internal static void RestorePreliminaryGameSettings() + { + GameManager.PreliminaryGameSettings.disabledTribes.Clear(); + GameManager.PreliminaryGameSettings.selectedSkins.Clear(); + GameManager.PreliminaryGameSettings.SaveToDisk(); + } + internal static void Init() { Harmony.CreateAndPatchAll(typeof(Compatibility)); diff --git a/src/Managers/Hub.cs b/src/Managers/Hub.cs index 9bee0fc..43b186f 100644 --- a/src/Managers/Hub.cs +++ b/src/Managers/Hub.cs @@ -1,16 +1,23 @@ +using System.Text.Json; using Cpp2IL.Core.Extensions; using HarmonyLib; +using I2.Loc; using Il2CppInterop.Runtime; +using Polytopia.Data; using TMPro; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; +using static PopupBase; namespace PolyMod.Managers; + internal static class Hub { private const string HEADER_PREFIX = ""; private const string HEADER_POSTFIX = ""; + private const int POPUP_WIDTH = 1400; + public static bool isConfigPopupActive = false; [HarmonyPrefix] [HarmonyPatch(typeof(SplashController), nameof(SplashController.LoadAndPlayClip))] @@ -121,7 +128,8 @@ static void PolyModHubButtonClicked(int buttonId, BaseEventData eventData) Localization.Get("polymod.hub.mod.status." + Enum.GetName(typeof(Mod.Status), mod.status)!.ToLower()), string.Join(", ", mod.authors), - mod.version.ToString() + mod.version.ToString(), + mod.description ?? "" }); popup.Description += "\n\n"; } @@ -134,30 +142,91 @@ static void PolyModHubButtonClicked(int buttonId, BaseEventData eventData) new("buttons.back"), new( "polymod.hub.discord", - callback: (UIButtonBase.ButtonAction)((int _, BaseEventData _) => + callback: (UIButtonBase.ButtonAction)((_, _) => NativeHelpers.OpenURL(Plugin.DISCORD_LINK, false)) + ), + new( + "polymod.hub.config", + callback: (UIButtonBase.ButtonAction)((_, _) => + { + ShowConfigPopup(); + }) ) }; if (Plugin.config.debug) + { popupButtons.Add(new( "polymod.hub.dump", - callback: (UIButtonBase.ButtonAction)((int _, BaseEventData _) => + callback: (UIButtonBase.ButtonAction)((_, _) => { Directory.CreateDirectory(Plugin.DUMPED_DATA_PATH); File.WriteAllTextAsync( - Path.Combine(Plugin.DUMPED_DATA_PATH, $"gameLogicData.json"), + Path.Combine(Plugin.DUMPED_DATA_PATH, "gameLogicData.json"), PolytopiaDataManager.provider.LoadGameLogicData(VersionManager.GameLogicDataVersion) ); File.WriteAllTextAsync( - Path.Combine(Plugin.DUMPED_DATA_PATH, $"avatarData.json"), + Path.Combine(Plugin.DUMPED_DATA_PATH, "avatarData.json"), PolytopiaDataManager.provider.LoadAvatarData(1337) ); + foreach (var category in LocalizationManager.Sources[0].GetCategories()) + File.WriteAllTextAsync( + Path.Combine(Plugin.DUMPED_DATA_PATH, $"localization_{category}.csv"), + LocalizationManager.Sources[0].Export_CSV(category) + ); + foreach (KeyValuePair entry in Registry.mods) + { + foreach (Mod.File file in entry.Value.files) + { + if (Path.GetFileName(file.name) == "sprites.json") + { + File.WriteAllBytes(Path.Combine(Plugin.DUMPED_DATA_PATH, $"sprites_{entry.Key}.json"), file.bytes); + } + } + } + foreach (TribeData.Type type in Enum.GetValues(typeof(TribeData.Type))) + { + List previewTiles = new(); + SelectTribePopup popup = PopupManager.GetSelectTribePopup(); + for (int x = -3; x <= 3; x++) + { + for (int y = -7; y <= 7; y++) + { + Vector2Int pos = new Vector2Int(x, y); + if (popup.UIWorldPreview.worldPreviewData.TryGetData(pos, type, out UITileData tileData)) + { + Visual.PreviewTile previewTile = new Visual.PreviewTile + { + x = tileData.Position.x, + y = tileData.Position.y, + terrainType = tileData.terrainType, + resourceType = tileData.resourceType, + unitType = tileData.unitType, + improvementType = tileData.improvementType + }; + previewTiles.Add(previewTile); + } + } + } + File.WriteAllTextAsync( + Path.Combine(Plugin.DUMPED_DATA_PATH, $"preview_{type}.json"), + JsonSerializer.Serialize(previewTiles, new JsonSerializerOptions { WriteIndented = true }) + ); + } NotificationManager.Notify(Localization.Get("polymod.hub.dumped")); }), closesPopup: false )); + popupButtons.Add(new( + "polymod.hub.spriteinfo.update", + callback: (UIButtonBase.ButtonAction)((_, _) => + { + UpdateSpriteInfos(); + }), + closesPopup: false + )); + } popup.buttonData = popupButtons.ToArray(); - popup.ShowSetWidth(1000); + popup.ShowSetWidth(POPUP_WIDTH); } if (Main.dependencyCycle) @@ -180,6 +249,144 @@ static void PolyModHubButtonClicked(int buttonId, BaseEventData eventData) } } + [HarmonyPostfix] + [HarmonyPatch(typeof(GameManager), nameof(GameManager.Update))] + private static void GameManager_Update() + { + if (Input.GetKey(KeyCode.LeftControl) && Input.GetKeyDown(KeyCode.Tab) && !isConfigPopupActive) + { + ShowConfigPopup(); + } + } + + internal static void UpdateSpriteInfos() + { + string message = string.Empty; + Directory.CreateDirectory(Plugin.DUMPED_DATA_PATH); + + foreach (var file in Directory.GetFiles(Plugin.DUMPED_DATA_PATH)) + { + string? name = Path.GetFileNameWithoutExtension(file); + List subnames = new(); + if (name.Contains("sprites_")) + { + subnames = name.Split('_').ToList(); + Mod.File spriteInfo = new(Path.GetFileNameWithoutExtension(file), File.ReadAllBytes(file)); + Dictionary? deserialized = Loader.LoadSpriteInfoFile(Registry.mods[subnames[1]], spriteInfo); + if (deserialized != null) + { + foreach (var kvp in deserialized) + { + Loader.UpdateSprite(kvp.Key); + } + message += Localization.Get("polymod.spriteinfo.updated", new Il2CppSystem.Object[] { subnames[1] }); + } + } + } + if (message == string.Empty) + { + message = Localization.Get("polymod.spriteinfo.notupdated"); + } + NotificationManager.Notify(message); + } + + internal static void ShowConfigPopup() + { + BasicPopup polymodPopup = PopupManager.GetBasicPopup(); + + polymodPopup.Header = Localization.Get("polymod.hub.config"); + polymodPopup.Description = ""; + + polymodPopup.buttonData = CreateConfigPopupButtonData(); + polymodPopup.ShowSetWidth(POPUP_WIDTH); + polymodPopup.Show(); + } + + internal static PopupButtonData[] CreateConfigPopupButtonData() + { + List popupButtons = new() + { + new(Localization.Get("buttons.back"), PopupButtonData.States.None, (UIButtonBase.ButtonAction)OnBackButtonClicked, -1, true, null) + }; + + if (GameManager.Instance.isLevelLoaded) + { + popupButtons.Add(new PopupButtonData(Localization.Get("polymod.hub.spriteinfo.update"), PopupButtonData.States.None, (UIButtonBase.ButtonAction)OnUpdateSpritesButtonClicked, -1, true, null)); + } + else + { + string debugButtonName = Localization.Get( + Plugin.config.debug ? "polymod.hub.config.disable" : "polymod.hub.config.enable", + new Il2CppSystem.Object[] { Localization.Get("polymod.debug", + new Il2CppSystem.Object[]{}).ToUpperInvariant() } + ); + string autoUpdateButtonName = Localization.Get( + Plugin.config.autoUpdate ? "polymod.hub.config.disable" : "polymod.hub.config.enable", + new Il2CppSystem.Object[] { Localization.Get("polymod.autoupdate", + new Il2CppSystem.Object[]{}).ToUpperInvariant() } + ); + string includeAlphasButtonName = Localization.Get( + Plugin.config.updatePrerelease ? "polymod.hub.config.disable" : "polymod.hub.config.enable", + new Il2CppSystem.Object[] { Localization.Get("polymod.autoupdate.alpha", + new Il2CppSystem.Object[]{}).ToUpperInvariant() } + ); + popupButtons.Add(new PopupButtonData(debugButtonName, PopupButtonData.States.None, (UIButtonBase.ButtonAction)OnDebugButtonClicked, -1, true, null)); + popupButtons.Add(new PopupButtonData(autoUpdateButtonName, PopupButtonData.States.None, (UIButtonBase.ButtonAction)OnAutoUpdateButtonClicked, -1, true, null)); + popupButtons.Add(new PopupButtonData(includeAlphasButtonName, Plugin.config.autoUpdate ? PopupButtonData.States.None : PopupButtonData.States.Disabled, (UIButtonBase.ButtonAction)OnIncludeAlphasButtonClicked, -1, true, null)); + } + return popupButtons.ToArray(); + + void OnDebugButtonClicked(int buttonId, BaseEventData eventData) + { + Plugin.config = new(debug: !Plugin.config.debug, autoUpdate: Plugin.config.autoUpdate, updatePrerelease: Plugin.config.updatePrerelease); + Plugin.WriteConfig(); + Plugin.UpdateConsole(); + NotificationManager.Notify(Localization.Get( + "polymod.config.setto", + new Il2CppSystem.Object[] { Localization.Get("polymod.debug", + new Il2CppSystem.Object[]{}), Plugin.config.debug } + )); + isConfigPopupActive = false; + } + + void OnAutoUpdateButtonClicked(int buttonId, BaseEventData eventData) + { + Plugin.config = new(debug: Plugin.config.debug, autoUpdate: !Plugin.config.autoUpdate, updatePrerelease: Plugin.config.updatePrerelease); + Plugin.WriteConfig(); + Plugin.UpdateConsole(); + NotificationManager.Notify(Localization.Get( + "polymod.config.setto", + new Il2CppSystem.Object[] { Localization.Get("polymod.autoupdate", + new Il2CppSystem.Object[]{}), Plugin.config.autoUpdate } + )); + isConfigPopupActive = false; + } + + void OnIncludeAlphasButtonClicked(int buttonId, BaseEventData eventData) + { + Plugin.config = new(debug: Plugin.config.debug, autoUpdate: Plugin.config.autoUpdate, updatePrerelease: !Plugin.config.updatePrerelease); + Plugin.WriteConfig(); + Plugin.UpdateConsole(); + NotificationManager.Notify(Localization.Get( + "polymod.config.setto", + new Il2CppSystem.Object[] { Localization.Get("polymod.autoupdate.alpha", + new Il2CppSystem.Object[]{}), Plugin.config.updatePrerelease } + )); + isConfigPopupActive = false; + } + + void OnUpdateSpritesButtonClicked(int buttonId, BaseEventData eventData) + { + UpdateSpriteInfos(); + isConfigPopupActive = false; + } + + void OnBackButtonClicked(int buttonId, BaseEventData eventData) + { + isConfigPopupActive = false; + } + } + internal static void Init() { Harmony.CreateAndPatchAll(typeof(Hub)); diff --git a/src/Managers/Loc.cs b/src/Managers/Loc.cs index 4033f60..5b1c70f 100644 --- a/src/Managers/Loc.cs +++ b/src/Managers/Loc.cs @@ -6,6 +6,7 @@ using Polytopia.Data; namespace PolyMod.Managers; + public static class Loc { [HarmonyPostfix] @@ -41,7 +42,7 @@ private static bool Localization_Get(ref string key, Il2CppReferenceArray= Plugin.AUTOIDX_STARTS_FROM) + if (parsedIdx >= Plugin.AUTOIDX_STARTS_FROM) { idx = parsedIdx; } diff --git a/src/Managers/Main.cs b/src/Managers/Main.cs index d1fabaf..68892ed 100644 --- a/src/Managers/Main.cs +++ b/src/Managers/Main.cs @@ -2,19 +2,28 @@ using HarmonyLib; using Newtonsoft.Json.Linq; using Polytopia.Data; +using PolytopiaBackendBase.Game; using System.Diagnostics; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using UnityEngine; namespace PolyMod.Managers; + public static class Main { internal const int MAX_TECH_TIER = 100; internal static readonly Stopwatch stopwatch = new(); internal static bool fullyInitialized; internal static bool dependencyCycle; - + internal static Dictionary embarkNames = new(); + internal static Dictionary embarkOverrides = new(); + internal static bool currentlyEmbarking = false; + internal static Dictionary attractsResourceNames = new(); + internal static Dictionary attractsTerrainNames = new(); + internal static Dictionary attractsResourceOverrides = new(); + internal static Dictionary attractsTerrainOverrides = new(); [HarmonyPrefix] [HarmonyPatch(typeof(GameLogicData), nameof(GameLogicData.AddGameLogicPlaceholders))] @@ -23,11 +32,75 @@ private static void GameLogicData_Parse(GameLogicData __instance, JObject rootOb if (!fullyInitialized) { Load(rootObject); + foreach (System.Collections.Generic.KeyValuePair item in Registry.prefabNames) + { + UnitData.Type unitPrefabType = UnitData.Type.Scout; + string prefabId = item.Value; + if (Enum.TryParse(prefabId, out UnitData.Type parsedType)) + { + unitPrefabType = parsedType; + PrefabManager.units.TryAdd(item.Key, PrefabManager.units[(int)unitPrefabType]); + } + else + { + KeyValuePair prefabInfo = Registry.unitPrefabs.FirstOrDefault(kv => kv.Key.name == prefabId); + if (!EqualityComparer.Default.Equals(prefabInfo.Key, default)) + { + PrefabManager.units.TryAdd(item.Key, prefabInfo.Value); + } + else + { + PrefabManager.units.TryAdd(item.Key, PrefabManager.units[(int)unitPrefabType]); + } + } + } foreach (Visual.SkinInfo skin in Registry.skinInfo) { if (skin.skinData != null) __instance.skinData[(SkinType)skin.idx] = skin.skinData; } + foreach (KeyValuePair entry in embarkNames) + { + try + { + UnitData.Type unit = EnumCache.GetType(entry.Key); + UnitData.Type newUnit = EnumCache.GetType(entry.Value); + embarkOverrides[unit] = newUnit; + Plugin.logger.LogInfo($"Embark unit type for {entry.Key} is now {entry.Value}"); + } + catch + { + Plugin.logger.LogError($"Embark unit type for {entry.Key} is not valid: {entry.Value}"); + } + } + foreach (KeyValuePair entry in attractsResourceNames) + { + try + { + ImprovementData.Type improvement = EnumCache.GetType(entry.Key); + ResourceData.Type resource = EnumCache.GetType(entry.Value); + attractsResourceOverrides[improvement] = resource; + Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts {entry.Value}"); + } + catch + { + Plugin.logger.LogError($"Improvement {entry.Key} resource type is not valid: {entry.Value}"); + } + } + foreach (KeyValuePair entry in attractsTerrainNames) + { + try + { + ImprovementData.Type improvement = EnumCache.GetType(entry.Key); + Polytopia.Data.TerrainData.Type terrain = EnumCache.GetType(entry.Value); + attractsTerrainOverrides[improvement] = terrain; + Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts on {entry.Value}"); + } + catch + { + Plugin.logger.LogError($"Improvement {entry.Key} terrain type is not valid: {entry.Value}"); + } + } fullyInitialized = true; } } @@ -70,6 +143,180 @@ private static bool IL2CPPUnityLogSource_UnityLogCallback(string logLine, string return true; } + [HarmonyPrefix] + [HarmonyPatch(typeof(GameModeScreen), nameof(GameModeScreen.Init))] + private static void GameModeScreen_Init(GameModeScreen __instance) + { + List list = __instance.buttons.ToList(); + for (int i = 0; i < Loader.gamemodes.Count; i++) + { + var item = Loader.gamemodes[i]; + var button = GameObject.Instantiate(__instance.buttons[2]); + list.Add(button); + Loader.gamemodes[i] = new Loader.GameModeButtonsInformation(item.gameModeIndex, item.action, __instance.buttons.Length, item.sprite); + } + + var newArray = list.ToArray(); + for (int i = 0; i < __instance.buttons.Length; i++) + { + if (newArray[i] != null) newArray[i].OnClicked = __instance.buttons[i].OnClicked; + } + + for (int i = 0; i < Loader.gamemodes.Count; i++) + { + if (Loader.gamemodes[i].buttonIndex != null) + newArray[Loader.gamemodes[i].buttonIndex!.Value].OnClicked = Loader.gamemodes[i].action; + } + + __instance.buttons = newArray; + + for (int i = 0; i < __instance.buttons.Length; i++) + { + GamemodeButton button = __instance.buttons[i]; + var newData = button.gamemodeData.ToList(); + foreach (var info in Loader.gamemodes) + { + string id = EnumCache.GetName((GameMode)info.gameModeIndex).ToLower(); + newData.Add(new GamemodeButton.GamemodeButtonData() + { + gameMode = (GameMode)info.gameModeIndex, + id = System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(id), + descriptionKey = "gamemode." + id + ".description.button", + headerKey = "gamemode." + id + ".caps", + icon = info.sprite + }); + } + button.gamemodeData = newData.ToArray(); + + for (int j = 0; j < Loader.gamemodes.Count; j++) + { + Loader.GameModeButtonsInformation info = Loader.gamemodes[j]; + + if (info.buttonIndex == i) + { + button.SetGamemode(info.buttonIndex.Value); + } + } + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(TechView), nameof(TechView.CreateNode))] + public static bool TechView_CreateNode(TechView __instance, TechData data, TechItem parentItem, float angle) + { + GameLogicData gameLogicData = GameManager.GameState.GameLogicData; + TribeData tribeData = gameLogicData.GetTribeData(GameManager.LocalPlayer.tribe); + float baseAngle = 360 / gameLogicData.GetOverride(gameLogicData.GetTechData(TechData.Type.Basic), tribeData).techUnlocks.Count; + float childAngle = 0f; + if (parentItem != null) + childAngle = angle + baseAngle * (data.techUnlocks.Count - 1) / 2f; + foreach (var techData in data.techUnlocks) + { + if (gameLogicData.TryGetData(techData.type, out TechData techData2)) + { + TechData @override = gameLogicData.GetOverride(techData, tribeData); + TechItem techItem = __instance.CreateTechItem(@override, parentItem, childAngle); + __instance.currTechIdx++; + if (@override.techUnlocks != null && @override.techUnlocks.Count > 0) + __instance.CreateNode(@override, techItem, childAngle); + childAngle -= baseAngle; + } + } + Il2CppSystem.Action onItemsRefreshed = __instance.OnItemsRefreshed; + if (onItemsRefreshed == null) + { + return false; + } + onItemsRefreshed.Invoke(__instance); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(EmbarkAction), nameof(EmbarkAction.Execute))] + private static bool EmbarkAction_Execute_Prefix(EmbarkAction __instance, GameState gameState) + { + currentlyEmbarking = true; + return true; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(ActionUtils), nameof(ActionUtils.TrainUnit))] + private static bool ActionUtils_TrainUnit(ref UnitState __result, GameState gameState, PlayerState playerState, TileData tile, ref UnitData unitData) + { + if (tile == null) + { + return true; + } + if (tile.unit == null) + { + return true; + } + if (currentlyEmbarking) + { + if (embarkOverrides.TryGetValue(tile.unit.type, out UnitData.Type newType)) + { + gameState.GameLogicData.TryGetData(newType, out unitData); + } + currentlyEmbarking = false; + } + return true; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(StartTurnAction), nameof(StartTurnAction.Execute))] + private static void StartTurnAction_Execute(StartTurnAction __instance, GameState state) + { + for (int i = state.ActionStack.Count - 1; i >= 0; i--) + { + if (state.ActionStack[i].GetActionType() == ActionType.CreateResource) + { + state.ActionStack.RemoveAt(i); + } + } + for (int i = 0; i < state.Map.Tiles.Length; i++) + { + TileData tileData = state.Map.Tiles[i]; + if (tileData.owner == __instance.PlayerId && tileData.improvement != null && state.CurrentTurn > 0U) + { + ImprovementData improvementData; + state.GameLogicData.TryGetData(tileData.improvement.type, out improvementData); + if (improvementData != null) + { + if (improvementData.HasAbility(ImprovementAbility.Type.Attract) && tileData.improvement.GetAge(state) % improvementData.growthRate == 0) + { + ResourceData.Type resourceType = ResourceData.Type.Game; + if (attractsResourceOverrides.TryGetValue(tileData.improvement.type, out ResourceData.Type newType)) + { + resourceType = newType; + } + Polytopia.Data.TerrainData.Type targetTerrain = Polytopia.Data.TerrainData.Type.Forest; + if (attractsTerrainOverrides.TryGetValue(tileData.improvement.type, out Polytopia.Data.TerrainData.Type newTerrain)) + { + targetTerrain = newTerrain; + } + foreach (TileData tileData2 in state.Map.GetArea(tileData.coordinates, 1, true, false)) + { + if (tileData2.owner == __instance.PlayerId && tileData2.improvement == null && tileData2.resource == null && tileData2.terrain == targetTerrain) + { + state.ActionStack.Add(new CreateResourceAction(__instance.PlayerId, resourceType, tileData2.coordinates, CreateResourceAction.CreateReason.Attract)); + break; + } + } + } + } + } + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(Unit), nameof(Unit.CreateUnit))] + private static bool Unit_CreateUnit(Unit __instance, UnitData unitData, TribeData.Type tribe, SkinType unitSkin) + { + Unit unit = PrefabManager.GetPrefab(unitData.type, tribe, unitSkin); + if (unit == null) Console.Write("THIS FUCKING SHIT IS NULL WHAT THE FUCK"); + return true; + } + internal static void Init() { stopwatch.Start(); @@ -77,6 +324,7 @@ internal static void Init() Mod.Manifest polytopia = new( "polytopia", "The Battle of Polytopia", + null, new(Application.version.ToString()), new string[] { "Midjiwan AB" }, Array.Empty() @@ -86,13 +334,13 @@ internal static void Init() dependencyCycle = !Loader.SortMods(Registry.mods); if (dependencyCycle) return; - StringBuilder looseSignatureString = new(); - StringBuilder signatureString = new(); + StringBuilder checksumString = new(); foreach (var (id, mod) in Registry.mods) { if (mod.status != Mod.Status.Success) continue; foreach (var file in mod.files) { + checksumString.Append(JsonSerializer.Serialize(file)); if (Path.GetExtension(file.name) == ".dll") { Loader.LoadAssemblyFile(mod, file); @@ -104,14 +352,11 @@ internal static void Init() } if (!mod.client && id != "polytopia") { - looseSignatureString.Append(id); - looseSignatureString.Append(mod.version.Major); - - signatureString.Append(id); - signatureString.Append(mod.version.ToString()); + checksumString.Append(id); + checksumString.Append(mod.version.ToString()); } } - Compatibility.HashSignatures(looseSignatureString, signatureString); + Compatibility.HashSignatures(checksumString); stopwatch.Stop(); } @@ -131,21 +376,40 @@ internal static void Load(JObject gameLogicdata) if (mod.status != Mod.Status.Success) continue; foreach (var file in mod.files) { - if (Path.GetFileName(file.name) == "patch.json") - { - Loader.LoadGameLogicDataPatch(mod, gameLogicdata, JObject.Parse(new StreamReader(new MemoryStream(file.bytes)).ReadToEnd())); - } if (Path.GetFileName(file.name) == "localization.json") { Loader.LoadLocalizationFile(mod, file); + continue; } - if (Path.GetExtension(file.name) == ".png") + if (Regex.IsMatch(Path.GetFileName(file.name), @"^patch(_.*)?\.json$")) { - Loader.LoadSpriteFile(mod, file); + Loader.LoadGameLogicDataPatch( + mod, + gameLogicdata, + JObject.Parse(new StreamReader(new MemoryStream(file.bytes)).ReadToEnd()) + ); + continue; } - if (Path.GetExtension(file.name) == ".wav") + if (Regex.IsMatch(Path.GetFileName(file.name), @"^prefab(_.*)?\.json$")) + { + Loader.LoadPrefabInfoFile( + mod, + file + ); + continue; + } + + switch (Path.GetExtension(file.name)) { - Loader.LoadAudioFile(mod, file); + case ".png": + Loader.LoadSpriteFile(mod, file); + break; + case ".wav": + Loader.LoadAudioFile(mod, file); + break; + case ".bundle": + Loader.LoadAssetBundle(mod, file); + break; } } } diff --git a/src/Managers/Visual.cs b/src/Managers/Visual.cs index 8a0ac9f..e816f0b 100644 --- a/src/Managers/Visual.cs +++ b/src/Managers/Visual.cs @@ -9,6 +9,7 @@ using System.Text.Json.Serialization; namespace PolyMod.Managers; + public static class Visual { public class PreviewTile @@ -35,6 +36,23 @@ public record SkinInfo(int idx, string id, SkinData? skinData); public static Dictionary basicPopupWidths = new(); private static bool firstTimeOpeningPreview = true; private static UnitData.Type currentUnitTypeUI = UnitData.Type.None; + private static TribeData.Type attackerTribe = TribeData.Type.None; + public enum PrefabType + { + Unit, + Improvement, + Resource + } + public record PrefabInfo(PrefabType type, string name, List visualParts); + public record VisualPartInfo( + string gameObjectName, + string baseName, + float rotation = 0f, + Vector2 coordinates = new Vector2(), + Vector2 scale = new Vector2(), + bool tintable = false + ); + private static bool enableOutlines = false; #region General @@ -69,7 +87,7 @@ void GetAtlas(SpriteAtlas spriteAtlas) string style = ""; foreach (string item in names) { - string upperitem = char.ToUpper(item[0]) + item.Substring(1); + string upperitem = char.ToUpper(item[0]) + item[1..]; if (EnumCache.TryGetType(item, out TribeData.Type tribe) || EnumCache.TryGetType(item, out SkinType skin) || EnumCache.TryGetType(upperitem, out TribeData.Type tribeUpper) || EnumCache.TryGetType(upperitem, out SkinType skinUpper)) { @@ -103,6 +121,30 @@ private static void SpriteAtlasManager_DoSpriteLookup(ref SpriteAtlasManager.Spr #endregion #region Units + // lobotomy + + [HarmonyPrefix] + [HarmonyPatch(typeof(InteractionBar), nameof(InteractionBar.Show))] + private static bool InteractionBar_Show(InteractionBar __instance, bool instant, bool force) + { + enableOutlines = true; + return true; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(UISpriteDuplicator), nameof(UISpriteDuplicator.CreateImage), typeof(SpriteRenderer), typeof(Transform), typeof(Transform), typeof(float), typeof(Vector2), typeof(bool))] + private static bool UISpriteDuplicator_CreateImage(SpriteRenderer spriteRenderer, Transform source, Transform destination, float scale, Vector2 offset, bool forceFullAlpha) + { + return !(spriteRenderer.sortingOrder == -1 && !enableOutlines); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(InteractionBar), nameof(InteractionBar.Show))] + private static void InteractionBar_Show_Postfix(InteractionBar __instance, bool instant, bool force) + { + enableOutlines = false; + } + [HarmonyPrefix] [HarmonyPatch(typeof(UIUnitRenderer), nameof(UIUnitRenderer.CreateUnit))] private static bool UIUnitRenderer_CreateUnit_Prefix(UIUnitRenderer __instance) @@ -189,6 +231,27 @@ private static void TerrainRenderer_UpdateGraphics(TerrainRenderer __instance, T string flood = ""; if (tile.data.effects.Contains(TileData.EffectType.Flooded)) { + Il2CppSystem.Collections.Generic.List newStack = new Il2CppSystem.Collections.Generic.List(); + foreach (CommandBase command in GameManager.GameState.CommandStack) + { + newStack.Add(command); + } + newStack.Reverse(); + foreach (CommandBase command in GameManager.GameState.CommandStack) + { + if (command.GetCommandType() == CommandType.Flood) + { + FloodCommand floodCommand = command.Cast(); + if (floodCommand.Coordinates == tile.Coordinates) + { + if (GameManager.GameState.TryGetPlayer(floodCommand.PlayerId, out PlayerState playerState)) + { + skinType = playerState.skinType; + } + break; + } + } + } flood = "_flooded"; } if (tile.data.terrain is Polytopia.Data.TerrainData.Type.Forest or Polytopia.Data.TerrainData.Type.Mountain) @@ -221,6 +284,24 @@ private static void TerrainRenderer_UpdateGraphics(TerrainRenderer __instance, T } } + [HarmonyPostfix] + [HarmonyPatch(typeof(TileData), nameof(TileData.Flood))] + private static void TileData_Flood(TileData __instance, PlayerState playerState) + { + if (GameManager.Instance.isLevelLoaded) + { + GameManager.Client.ActionManager.ExecuteCommand(new FloodCommand(playerState.Id, __instance.coordinates), out string error); + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(FloodCommand), nameof(FloodCommand.IsValid))] + private static bool FloodCommand_IsValid(ref bool __result, FloodCommand __instance, GameState state, ref string validationError) + { + __result = true; + return false; + } + [HarmonyPostfix] [HarmonyPatch(typeof(PolytopiaSpriteRenderer), nameof(PolytopiaSpriteRenderer.ForceUpdateMesh))] private static void PolytopiaSpriteRenderer_ForceUpdateMesh(PolytopiaSpriteRenderer __instance) @@ -447,10 +528,36 @@ private static void PlayerInfoIcon_SetData(PlayerInfoIcon __instance, TribeData. private static void BasicPopup_Update(BasicPopup __instance) { int id = __instance.GetInstanceID(); - if (Visual.basicPopupWidths.ContainsKey(id)) + if (basicPopupWidths.ContainsKey(id)) __instance.rectTransform.SetWidth(basicPopupWidths[id]); } + [HarmonyPrefix] + [HarmonyPatch(typeof(Unit), nameof(Unit.Attack))] + private static bool Unit_Attack(Unit __instance, WorldCoordinates target, bool moveToTarget, Il2CppSystem.Action onComplete) + { + if (__instance.Owner != null) + { + attackerTribe = __instance.Owner.tribe; + } + return true; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(WeaponGFX), nameof(WeaponGFX.SetSkin))] + private static void WeaponGFX_SetSkin(WeaponGFX __instance, SkinType skinType) + { + if (attackerTribe != TribeData.Type.None) + { + Sprite? sprite = Registry.GetSprite(__instance.defaultSprite.name, Util.GetStyle(attackerTribe, skinType)); + if (sprite != null) + { + __instance.spriteRenderer.sprite = sprite; + } + attackerTribe = TribeData.Type.None; + } + } + [HarmonyPostfix] [HarmonyPatch(typeof(PopupBase), nameof(PopupBase.Hide))] private static void PopupBase_Hide(PopupBase __instance) @@ -499,11 +606,16 @@ public static Sprite BuildSprite(byte[] data, Vector2? pivot = null, float pixel texture.SetPixels(pixels); texture.filterMode = FilterMode.Trilinear; texture.Apply(); + return BuildSpriteWithTexture(texture, pivot, pixelsPerUnit); + } + + public static Sprite BuildSpriteWithTexture(Texture2D texture, Vector2? pivot = null, float? pixelsPerUnit = 2112f) + { return Sprite.Create( texture, new(0, 0, texture.width, texture.height), pivot ?? new(0.5f, 0.5f), - pixelsPerUnit + pixelsPerUnit ?? 2112f ); } diff --git a/src/Mod.cs b/src/Mod.cs index c8c179f..36e81c1 100644 --- a/src/Mod.cs +++ b/src/Mod.cs @@ -1,8 +1,9 @@ namespace PolyMod; + public class Mod { public record Dependency(string id, Version min, Version max, bool required = true); - public record Manifest(string id, string? name, Version version, string[] authors, Dependency[]? dependencies, bool client = false); + public record Manifest(string id, string? name, string? description, Version version, string[] authors, Dependency[]? dependencies, bool client = false); public record File(string name, byte[] bytes); public enum Status { @@ -13,6 +14,7 @@ public enum Status public string id; public string? name; + public string? description; public Version version; public string[] authors; public Dependency[]? dependencies; @@ -24,6 +26,7 @@ public Mod(Manifest manifest, Status status, List files) { id = manifest.id; name = manifest.name ?? manifest.id; + description = manifest.description; version = manifest.version; authors = manifest.authors; dependencies = manifest.dependencies; diff --git a/src/Plugin.cs b/src/Plugin.cs index 86a49ea..025258c 100644 --- a/src/Plugin.cs +++ b/src/Plugin.cs @@ -4,22 +4,28 @@ using BepInEx.Configuration; using BepInEx.Logging; using PolyMod.Managers; +using UnityEngine; namespace PolyMod; + [BepInPlugin("com.polymod", "PolyMod", VERSION)] public partial class Plugin : BepInEx.Unity.IL2CPP.BasePlugin { internal record PolyConfig( - bool debug = false + bool debug = false, + bool autoUpdate = true, + bool updatePrerelease = false ); internal const int AUTOIDX_STARTS_FROM = 1000; + internal const string INCOMPATIBILITY_WARNING_LAST_VERSION_KEY + = "INCOMPATIBILITY_WARNING_LAST_VERSION"; public static readonly string BASE_PATH = Path.Combine(BepInEx.Paths.BepInExRootPath, ".."); public static readonly string MODS_PATH = Path.Combine(BASE_PATH, "Mods"); public static readonly string DUMPED_DATA_PATH = Path.Combine(BASE_PATH, "DumpedData"); internal static readonly string CONFIG_PATH = Path.Combine(BASE_PATH, "PolyMod.json"); - internal static readonly string INCOMPATIBILITY_WARNING_LAST_VERSION_PATH - = Path.Combine(BASE_PATH, "IncompatibilityWarningLastVersion"); + internal static readonly string CHECKSUM_PATH + = Path.Combine(BASE_PATH, "CHECKSUM"); internal static readonly string DISCORD_LINK = "https://discord.gg/eWPdhWtfVy"; internal static readonly List LOG_MESSAGES_IGNORE = new() { @@ -45,15 +51,16 @@ public override void Load() catch { config = new(); - File.WriteAllText(CONFIG_PATH, JsonSerializer.Serialize(config)); } - if (!config.debug) ConsoleManager.DetachConsole(); + WriteConfig(); + UpdateConsole(); logger = Log; ConfigFile.CoreConfig[new("Logging.Disk", "WriteUnityLog")].BoxedValue = true; Compatibility.Init(); Audio.Init(); + AutoUpdate.Init(); Loc.Init(); Visual.Init(); Hub.Init(); @@ -67,4 +74,21 @@ internal static Stream GetResource(string id) $"{typeof(Plugin).Namespace}.resources.{id}" )!; } + + internal static void WriteConfig() + { + File.WriteAllText(CONFIG_PATH, JsonSerializer.Serialize(config)); + } + + internal static void UpdateConsole() + { + if (config.debug) + { + ConsoleManager.CreateConsole(); + } + else + { + ConsoleManager.DetachConsole(); + } + } } diff --git a/src/Registry.cs b/src/Registry.cs index 2c7a612..207d225 100644 --- a/src/Registry.cs +++ b/src/Registry.cs @@ -1,9 +1,11 @@ using LibCpp2IL; using PolyMod.Managers; using Polytopia.Data; +using PolytopiaBackendBase.Game; using UnityEngine; namespace PolyMod; + public static class Registry { public static int autoidx = Plugin.AUTOIDX_STARTS_FROM; @@ -12,9 +14,15 @@ public static class Registry internal static Dictionary mods = new(); public static Dictionary tribePreviews = new(); public static Dictionary spriteInfos = new(); + public static Dictionary prefabNames = new(); + public static Dictionary unitPrefabs = new(); + public static Dictionary resourcePrefabs = new(); + public static Dictionary improvementsPrefabs = new(); + public static Dictionary assetBundles = new(); public static List customTribes = new(); public static List skinInfo = new(); public static int climateAutoidx = (int)Enum.GetValues(typeof(TribeData.Type)).Cast().Last(); + public static int gameModesAutoidx = Enum.GetValues(typeof(GameMode)).Length; public static Sprite? GetSprite(string name, string style = "", int level = 0) { diff --git a/src/Util.cs b/src/Util.cs index d62e51b..4d2fce2 100644 --- a/src/Util.cs +++ b/src/Util.cs @@ -30,11 +30,33 @@ internal static Version Cast(this Il2CppSystem.Version self) return new(self.ToString()); } + internal static T UnwrapAsync(this Task self) + { + return self.GetAwaiter().GetResult(); + } + internal static Version CutRevision(this Version self) { return new(self.Major, self.Minor, self.Build); } + internal static bool IsVersionOlderOrEqual(this string version1, string version2) + { + Version version1_ = new(version1.Split('-')[0]); + Version version2_ = new(version2.Split('-')[0]); + + if (version1_ < version2_) return true; + if (version1_ > version2_) return false; + + string pre1 = version1.Contains('-') ? version1.Split('-')[1] : ""; + string pre2 = version2.Contains('-') ? version2.Split('-')[1] : ""; + + if (string.IsNullOrEmpty(pre1) && !string.IsNullOrEmpty(pre2)) return false; + if (!string.IsNullOrEmpty(pre1) && string.IsNullOrEmpty(pre2)) return true; + + return string.Compare(pre1, pre2, StringComparison.Ordinal) <= 0; + } + internal static string GetStyle(TribeData.Type tribe, SkinType skin) { return skin != SkinType.Default ? EnumCache.GetName(skin) : EnumCache.GetName(tribe);