diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml deleted file mode 100644 index f75f773..0000000 --- a/.github/workflows/installer.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Installer -on: - push: - paths: - - 'installer/**' - pull_request: - paths: - - 'installer/**' -jobs: - job: - defaults: - run: - working-directory: ./installer - strategy: - fail-fast: false - matrix: - os: [windows-latest, ubuntu-22.04, macos-13] - name: Build on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install requirements - run: pip install -r requirements.txt - - name: Build - run: python build.py - - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.os }} - path: installer/dist/ - merge: - name: Merge artifacts - runs-on: ubuntu-latest - needs: job - steps: - - uses: actions/upload-artifact/merge@v4 - with: - name: artifacts - delete-merged: true - separate-directories: true - diff --git a/README.md b/README.md index 2772d50..408cd22 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ | | x86 | ARM | |:-------:|:---:|:---:| | Windows | ✅ | ❌ | -| Linux | ⏺️ | ❌ | -| MacOS | ⏺️ | ❌ | +| Linux | ✅ | ❌ | +| MacOS | ❌ | ❌ | | Android | ➖ | 🚧 | | IOS | ➖ | ❌ | diff --git a/installer/build.py b/installer/build.py deleted file mode 100644 index 8b802ae..0000000 --- a/installer/build.py +++ /dev/null @@ -1,19 +0,0 @@ -import os -import PyInstaller.__main__ - -ROOT = os.path.abspath(os.getcwd()) - -PyInstaller.__main__.run( - [ - "--noconfirm", - "--onefile", - "--windowed", - "--icon", - ROOT + "/icon.ico", - "--name", - "PolyMod", - "--add-data", - ROOT + "/icon.ico:.", - ROOT + "/main.py", - ] -) diff --git a/installer/icon.ico b/installer/icon.ico deleted file mode 100644 index f52643a..0000000 Binary files a/installer/icon.ico and /dev/null differ diff --git a/installer/main.py b/installer/main.py deleted file mode 100644 index e1fad1b..0000000 --- a/installer/main.py +++ /dev/null @@ -1,154 +0,0 @@ -import os -import io -import sys -import shutil -import zipfile -import requests -import threading -import subprocess -import customtkinter -import CTkMessagebox as messagebox - -OS = { - "linux": "linux", - "linux2": "linux", - "win32": "win", - "darwin": "macos", -}[sys.platform] -BEPINEX = "https://polymod.dev/data/bepinex.txt" -POLYMOD = "https://api.github.com/repos/PolyModdingTeam/PolyMod/releases" - - -def resource_path(path): - try: - base_path = sys._MEIPASS - except Exception: - base_path = os.path.abspath(".") - return os.path.join(base_path, path) - - -def to_zip(request: requests.Response): - return zipfile.ZipFile(io.BytesIO(request.content)) - - -def browse(): - global path_entry - path_entry.delete(0, customtkinter.END) - path_entry.insert(0, customtkinter.filedialog.askdirectory()) - - -def prepare(target): - global progress_bar - path = path_entry.get() - try: - if "Polytopia_Data" not in os.listdir(path): - raise FileNotFoundError - except FileNotFoundError: - messagebox.CTkMessagebox( - title="Error", - message="The folder does not exist or is not valid!", - icon="cancel", - width=100, - height=50 - ) - 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) - progress_bar.grid(column=0, row=1, columnspan=2, padx=5, pady=5) - progress_bar.set(0) - threading.Thread(target=target, daemon=True, args=(path, )).start() - - -def install(path): - to_zip( - requests.get( - 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 - break - open(path + "/BepInEx/plugins/PolyMod.dll", "wb").write( - requests.get(latest["assets"][0]["browser_download_url"]).content - ) - progress_bar.step() - - customtkinter.CTkButton(app, text="Launch", command=lambda: launch(path)).grid( - column=0, row=2, columnspan=2, padx=5, pady=5 - ) - - -def uninstall(path): - dirs = [ - "BepInEx", - "dotnet", - ] - files = [ - ".doorstop_version", - "changelog.txt", - "doorstop_config.ini", - "winhttp.dll", # windows - "libdoorstop.so", # linux - "libdoorstop.dylib", # mac - "run_bepinex.sh", # linux + mac - ] - for dir in dirs: - shutil.rmtree(path + "/" + dir, True) - progress_bar.step() - for file in files: - try: - os.remove(path + "/" + file) - except FileNotFoundError: - ... - progress_bar.step() - customtkinter.CTkButton(app, text="Quit", command=quit).grid( - column=0, row=2, columnspan=2, padx=5, pady=5 - ) - - -def launch(path): - if sys.platform != "win32": - subprocess.check_call(f"chmod +x {path}/run_bepinex.sh", shell=True) - subprocess.check_call(f"{path}/run_bepinex.sh {path}/Polytopia.*", shell=True) - subprocess.check_call(f"xdg-open https://docs.bepinex.dev/articles/advanced/steam_interop.html", shell=True) - else: - subprocess.check_call(f"start steam://rungameid/874390", shell=True) - quit() - - -def quit(): - app.destroy() - sys.exit() - - -app = customtkinter.CTk() -app.title("PolyMod") -if OS != "linux": - app.iconbitmap(default=resource_path("icon.ico")) -app.resizable(False, False) - -path_entry = customtkinter.CTkEntry( - 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( - app, text="Uninstall", command=lambda: prepare(uninstall)) - -path_entry.grid(column=0, row=0, padx=5, pady=5) -browse_button.grid(column=1, row=0, padx=(0, 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/installer/requirements.txt b/installer/requirements.txt deleted file mode 100644 index c396099..0000000 --- a/installer/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -requests -pyinstaller -customtkinter -CTkMessagebox \ No newline at end of file diff --git a/resources/localization.json b/resources/localization.json index 3b19f1e..455c1ef 100644 --- a/resources/localization.json +++ b/resources/localization.json @@ -60,9 +60,13 @@ "German (Germany)": "DATENDUMP" }, "polymod_hub_dumped": { - "English": "Dump completed successfully!", - "Russian": "Дамп завершен успешно!", - "German (Germany)": "Dump erfolgreich beendet!" + "English": "Dump completed successfully!\n\n{0}", + "Russian": "Дамп завершен успешно!\n\n{0}", + "German (Germany)": "Dump erfolgreich beendet!\n\n{0}" + }, + "polymod_hub_openfolder": { + "English": "OPEN FOLDER", + "Russian": "ОТКРЫТЬ ПАПКУ" }, "polymod_cycle": { "English": "Dependency cycle detected", @@ -218,18 +222,14 @@ "Russian": "{0} задан на {1}!", "Elyrion": "{0} iȓ ȓ∑Ŧ Ŧȱ {1}!" }, - "polymod_hub_spriteinfo_update": { - "English": "UPDATE SPRITES", - "Russian": "ОБНОВИТЬ СПРАЙТЫ", - "Elyrion": "₺πΔaŦ∑ ȓπriŦ∑ȓ" + "polymod_hub_hotreload": { + "English": "HOT RELOAD" }, - "polymod_spriteinfo_updated": { - "English": "Sprite info for mod {0} updated!", - "Russian": "Данные спрайтов обновлены для мода {0}!", - "Elyrion": "ȓπriŦ∑ iŋ₼ȱ ₼ȱr mȱΔ {0} ₺πΔaŦ∑Δ!" + "polymod_hotreload_success": { + "English": "Hot reload completed for the mod {0}!" }, - "polymod_spriteinfo_notupdated": { - "English": "No sprite infos were found!", + "polymod_hotreload_failure": { + "English": "No mods for hot reload!", "Russian": "Данные спрайтов не найдены!", "Elyrion": "ŋȱ ȓπriŦ∑ iŋ₼ȱȓ ~∑r∑ ₼ȱ₺ŋΔ!" }, @@ -242,5 +242,61 @@ "English": "PROCEED", "Russian": "ПРОДОЛЖИТЬ", "Elyrion": "πrȱ#∑∑Δ" + }, + "polymod_title_invalid": { + "English": "Invalid Launch", + "Russian": "Неверный Запуск" + }, + "polymod_description_invalid": { + "English": "PolyMod launch was invalid. Please, open the game via the PolyMod Launcher or proceed to our site to install it :)", + "Russian": "PolyMod запущен неккоректно. Пожалуйста, откройте игру через PolyMod Launcher или перейдите на наш сайт и установите его :)" + }, + "buttons_download_launcher": { + "English": "OUR SITE", + "Russian": "НАШ САЙТ" + }, + "polymod_hub_summary": { + "English": "{0} of {1} mods loaded.", + "Russian": "Загружено модов: {0} из {1}." + }, + "polymod_hub_mods_none": { + "English": "No mods are installed yet. Put mod archives into the Mods folder next to the game and restart it.", + "Russian": "Моды пока не установлены. Положите архивы модов в папку Mods рядом с игрой и перезапустите её." + }, + "polymod_hub_mod_authors": { + "English": "by {0}", + "Russian": "от {0}" + }, + "polymod_hub_config_debug": { + "English": "DEBUG", + "Russian": "ОТЛАДКА" + }, + "polymod_hub_config_coordinates": { + "English": "COORDINATES", + "Russian": "КООРДИНАТЫ" + }, + "polymod_debug_description": { + "English": "Opens the developer console and writes verbose logs.", + "Russian": "Открывает консоль разработчика и включает подробные логи." + }, + "polymod_coordinates": { + "English": "Coordinates", + "Russian": "Координаты" + }, + "polymod_coordinates_description": { + "English": "Shows the coordinates of every tile on the map.", + "Russian": "Показывает координаты каждой клетки на карте." + }, + "polymod_on": { + "English": "ON", + "Russian": "ВКЛ" + }, + "polymod_off": { + "English": "OFF", + "Russian": "ВЫКЛ" + }, + "polymod_hotreload_unavailable": { + "English": "Hot reload is not available yet.", + "Russian": "Горячая перезагрузка пока недоступна." } -} +} \ No newline at end of file diff --git a/src/Dumper.cs b/src/Dumper.cs new file mode 100644 index 0000000..0bd3c20 --- /dev/null +++ b/src/Dumper.cs @@ -0,0 +1,291 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using Il2CppSystem.Linq; +using PolyMod.Json; +using PolytopiaBackendBase.Common; +using UnityEngine; + +namespace PolyMod.Managers; + +/// +/// Writes the game's own data (logic, localization, sprites, tribe previews and prefabs) to disk +/// so that mod authors can use it as a reference. +/// +internal static class Dumper +{ + private static readonly JsonSerializerOptions INDENTED = new() + { + WriteIndented = true, + }; + + private static readonly JsonSerializerOptions INDENTED_WITH_VECTORS = new() + { + WriteIndented = true, + Converters = { new Vector2Json() }, + }; + + private static readonly JsonSerializerOptions LOCALIZATION = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + internal static string RootPath => Path.GetFullPath(Plugin.DUMPED_DATA_PATH); + + internal static void DumpAll() + { + Directory.CreateDirectory(Plugin.DUMPED_DATA_PATH); + DumpLogic(); + DumpLocalization(); + DumpSprites(); + DumpTribePreviews(); + DumpPrefabs(); + } + + /// + /// Dumps every version of the game logic and avatar data. + /// + internal static void DumpLogic() + { + try + { + Directory.CreateDirectory(Plugin.LOGIC_DUMP_PATH); + for (int version = 1; version <= VersionManager.GameLogicDataVersion; version++) + { + File.WriteAllText( + Path.Combine(Plugin.LOGIC_DUMP_PATH, $"GameLogicData{version}.json"), + PolytopiaDataManager.provider.LoadGameLogicData(version) + ); + } + for (int version = 1; version <= VersionManager.AvatarDataVersion; version++) + { + File.WriteAllText( + Path.Combine(Plugin.LOGIC_DUMP_PATH, $"AvatarData{version}.json"), + PolytopiaDataManager.provider.LoadAvatarData(version) + ); + } + } + catch (Exception exception) + { + Plugin.logger.LogError($"Dumping logic failed: {exception}"); + } + } + + internal static void DumpLocalization() + { + try + { + Directory.CreateDirectory(Plugin.LOCALIZATION_DUMP_PATH); + var source = I2.Loc.LocalizationManager.Sources[0]; + + foreach (var language in source.GetLanguages()) + { + int languageIndex = source.GetLanguageIndex(language); + Dictionary translations = new(); + foreach (var term in source.mTerms) + { + string translation = term.GetTranslation(languageIndex); + if (!string.IsNullOrEmpty(translation)) + translations[term.Term] = translation; + } + File.WriteAllText( + Path.Combine( + Plugin.LOCALIZATION_DUMP_PATH, + $"language_{source.mLanguages[languageIndex].Code}.json" + ), + JsonSerializer.Serialize(translations, LOCALIZATION) + ); + } + + foreach (var category in source.GetCategories()) + { + File.WriteAllText( + Path.Combine(Plugin.LOCALIZATION_DUMP_PATH, $"localization_{category}.csv"), + source.Export_CSV(category) + ); + } + } + catch (Exception exception) + { + Plugin.logger.LogError($"Dumping localization failed: {exception}"); + } + } + + internal static void DumpSprites() + { + try + { + Directory.CreateDirectory(Plugin.SPRITES_DUMP_PATH); + SpriteAtlasManager spriteAtlasManager = GameManager.GetSpriteAtlasManager(); + + for (int i = 0; i < AtlasData.raw.Count; i++) + { + if (spriteAtlasManager.loadedAtlases[i] == null) continue; + + string[] row = AtlasData.raw[i]; + string atlasName = row[0]; + string directory = Path.Combine(Plugin.SPRITES_DUMP_PATH, atlasName); + Directory.CreateDirectory(directory); + + Dictionary atlasSpriteInfo = new(); + for (int j = 1; j < row.Length; j++) + { + string spriteName = row[j]; + Sprite sprite = spriteAtlasManager.GetSprite(spriteName, atlasName); + if (sprite == null) continue; + + (byte[] bytes, Visual.SpriteInfo spriteInfo) = Visual.SpriteToPng(sprite); + File.WriteAllBytes(Path.Combine(directory, $"{spriteName}.png"), bytes); + atlasSpriteInfo[spriteName] = spriteInfo; + } + File.WriteAllText( + Path.Combine(directory, $"sprites_{atlasName}.json"), + JsonSerializer.Serialize(atlasSpriteInfo, INDENTED_WITH_VECTORS) + ); + } + } + catch (Exception exception) + { + Plugin.logger.LogError($"Dumping sprites failed: {exception}"); + } + } + + internal static void DumpTribePreviews() + { + try + { + foreach (TribeType type in Enum.GetValues()) + { + SaveStateData saveStateData = TribePreviewRegistry.GetTribePreviewData(type, SkinType.Default); // TODO: skins + if (saveStateData == null) continue; + + if (!DiskSerializationHelpers.FromLZ4CompressedByteArray( + saveStateData.byteArray, out ClientSerializationWrapper wrapper, out int _)) + { + Plugin.logger.LogWarning($"Dump skipped preview of {type}: save state could not be read"); + continue; + } + + List previewTiles = new(); + foreach (var tileData in wrapper.GetCurrentGameState().Map.Tiles) + { + previewTiles.Add(new TribePreviews.PreviewTile + { + x = tileData.coordinates.x, + y = tileData.coordinates.y, + terrainType = tileData.terrain, + resourceType = tileData.resource != null ? tileData.resource.type : null, + unitType = tileData.unit != null ? tileData.unit.type : null, + improvementType = tileData.improvement != null ? tileData.improvement.type : null, + tileEffects = tileData.effects.ToArray().ToList(), + }); + } + + File.WriteAllText( + Path.Combine(Plugin.DUMPED_DATA_PATH, $"preview_{type}.json"), + JsonSerializer.Serialize(previewTiles, INDENTED) + ); + } + } + catch (Exception exception) + { + Plugin.logger.LogError($"Dumping tribe previews failed: {exception}"); + } + } + + internal static void DumpPrefabs() + { + try + { + Directory.CreateDirectory(Plugin.PREFABS_DUMP_PATH); + PrefabsInfo prefabsInfo = new(); + + foreach (var entry in Prefabs.unitsNew) + { + List? visualParts = CollectVisualParts(entry.Value, entry.Key.unitType); + if (visualParts == null) continue; + + prefabsInfo.units.Add(new UnitPrefabInfo + { + headPositionMarker = entry.Value.headPositionMarker.gameObject.name, + skinnedKey = entry.Key, + visualParts = visualParts, + }); + } + + foreach (var entry in PrefabManager.improvements) + { + List? visualParts = CollectVisualParts(entry.Value, entry.Key); + if (visualParts == null) continue; + + prefabsInfo.improvements.Add(new ImprovementPrefabInfo + { + improvementType = entry.Key, + visualParts = visualParts, + }); + } + + foreach (var entry in PrefabManager.resources) + { + List? visualParts = CollectVisualParts(entry.Value, entry.Key); + if (visualParts == null) continue; + + prefabsInfo.resources.Add(new ResourcePrefabInfo + { + resourceType = entry.Key, + visualParts = visualParts, + }); + } + + // TODO + // foreach (var entry in PrefabManager.weapons) + // { + // WeaponGFX weaponGFX = entry.Value; + // prefabsInfo.weapons.Add(new WeaponPrefabInfo + // { + // weaponEnum = entry.Key, + // spriteName = weaponGFX.defaultSprite.name, + // pathType = weaponGFX.pathType, + // pathMode = weaponGFX.pathMode, + // pathResolution = weaponGFX.pathResolution, + // pathEase = weaponGFX.pathEase, + // lookAheadOffset = weaponGFX.lookAheadOffset, + // handleAngleOffset = weaponGFX.handleAngleOffset, + // sfxType = SFXTypes.None, //TODO + // weaponPuffInfo = null, //TODO + // weaponRangedInfo = null //TODO + // }); + // } + + File.WriteAllText( + Path.Combine(Plugin.PREFABS_DUMP_PATH, "prefabs.json"), + JsonSerializer.Serialize(prefabsInfo, INDENTED_WITH_VECTORS) + ); + } + catch (Exception exception) + { + Plugin.logger.LogError($"Dumping prefabs failed: {exception}"); + } + } + + private static List? CollectVisualParts(Component prefab, object key) + { + SkinVisualsReference skinVisualsReference = prefab.GetComponent(); + if (skinVisualsReference == null || skinVisualsReference.visualParts == null) + { + Plugin.logger.LogWarning($"Dump skipped {key}: no SkinVisualsReference"); + return null; + } + + List visualParts = new(); + foreach (var visualPart in skinVisualsReference.visualParts) + { + if (visualPart == null || visualPart.visualPart == null) continue; + + VisualPartInfo visualPartInfo = new(); + visualPartInfo.CopyFromVisualPart(visualPart); + visualParts.Add(visualPartInfo); + } + return visualParts; + } +} diff --git a/src/Json/EnumNameJson.cs b/src/Json/EnumNameJson.cs new file mode 100644 index 0000000..953ec68 --- /dev/null +++ b/src/Json/EnumNameJson.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PolyMod.Json; + +/// +/// Converts fixed (non-extensible) enums to and from their plain string names. +/// Use this for enums that are not registered in EnumCache. +/// +public class EnumNameJson : JsonConverter where EnumType : struct, Enum +{ + public override EnumType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? enumName = reader.GetString(); + + if (enumName != null && Enum.TryParse(enumName, true, out EnumType parsedValue)) + { + return parsedValue; + } + + string validNames = string.Join(", ", Enum.GetNames(typeof(EnumType))); + throw new JsonException($"Unknown {typeof(EnumType).Name} value '{enumName}'. Valid values: {validNames}"); + } + + public override void Write(Utf8JsonWriter writer, EnumType value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} \ No newline at end of file diff --git a/src/Loader.cs b/src/Loader.cs index 87d5a76..218357a 100644 --- a/src/Loader.cs +++ b/src/Loader.cs @@ -8,7 +8,6 @@ using Polytopia.Data; using PolytopiaBackendBase.Game; using System.Diagnostics; -using System.Globalization; using System.IO.Compression; using System.Reflection; using System.Text; @@ -24,1096 +23,1005 @@ namespace PolyMod; /// public static class Loader { - internal record TypeMapping(Type type, bool shouldCreateCache = true); - - /// - /// Mappings from JSON data types to their corresponding C# types. - /// - internal static Dictionary typeMappings = new() - { - { "tribeData", new TypeMapping(typeof(TribeType)) }, - { "techData", new TypeMapping(typeof(TechData.Type)) }, - { "unitData", new TypeMapping(typeof(UnitData.Type)) }, - { "improvementData", new TypeMapping(typeof(ImprovementData.Type)) }, - { "terrainData", new TypeMapping(typeof(Polytopia.Data.TerrainData.Type)) }, - { "resourceData", new TypeMapping(typeof(ResourceData.Type)) }, - { "taskData", new TypeMapping(typeof(TaskData.Type)) }, - { "tribeAbility", new TypeMapping(typeof(TribeAbility.Type)) }, - { "unitAbility", new TypeMapping(typeof(UnitAbility.Type)) }, - { "improvementAbility", new TypeMapping(typeof(ImprovementAbility.Type)) }, - { "playerAbility", new TypeMapping(typeof(PlayerAbility.Type)) }, - { "weaponData", new TypeMapping(typeof(UnitData.WeaponEnum)) }, - { "skinData", new TypeMapping(typeof(SkinData), false) } - }; - - /// - /// List of custom game modes to be added. - /// - internal static List gamemodes = new(); - - /// - /// Handlers for processing specific data types during mod loading. - /// - internal static readonly Dictionary>> typeHandlers = new() - { - [typeof(TribeType)] = new List>() { - new((token, duringEnumCacheCreation) => - { - if (duringEnumCacheCreation) - { - Registry.customTribes.Add((TribeType)(int)token["idx"]); - token["style"] = Registry.climateAutoidx; - token["climate"] = Registry.climateAutoidx; - Registry.climateAutoidx++; - } - else - { - if (token["skins"] != null) - { - JArray skins = token["skins"].Cast(); - List skinValues = skins._values.ToArray().ToList(); - foreach (var skin in skinValues) - { - string skinId = skin.ToString(); - if (!Enum.TryParse(skinId, ignoreCase: true, out _)) - { - SkinType skinValue = (SkinType)Registry.autoidx; - EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue); - EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue); - Registry.skinInfo.Add(new Visual.SkinInfo(Registry.autoidx, skinId, null)); - Plugin.logger.LogInfo("Created mapping for skinType with id " + skinId + " and index " + Registry.autoidx); - Registry.autoidx++; - if(token["tribeAbilities"] != null) - { - JArray tribeAbilities = token["tribeAbilities"].Cast(); - List tribeAbilitiesValues = tribeAbilities._values.ToArray().ToList(); - - foreach(JToken ability in tribeAbilitiesValues) - { - string abilityValue = ability.ToString(); - - if (Enum.TryParse( - abilityValue, ignoreCase: true, - out TribeAbility.Type tribeAbilityType - ) && tribeAbilityType == TribeAbility.Type.Flooded) - { - string tileEffectId = skinId.ToLowerInvariant() + "_flood"; - EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx); - EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx); + internal record TypeMapping(Type type, bool shouldCreateCache = true); + + /// + /// Mappings from JSON data types to their corresponding C# types. + /// + internal static Dictionary typeMappings = new() + { + { "tribeData", new TypeMapping(typeof(TribeType)) }, + { "techData", new TypeMapping(typeof(TechData.Type)) }, + { "unitData", new TypeMapping(typeof(UnitData.Type)) }, + { "improvementData", new TypeMapping(typeof(ImprovementData.Type)) }, + { "terrainData", new TypeMapping(typeof(Polytopia.Data.TerrainData.Type)) }, + { "resourceData", new TypeMapping(typeof(ResourceData.Type)) }, + { "taskData", new TypeMapping(typeof(TaskData.Type)) }, + { "tribeAbility", new TypeMapping(typeof(TribeAbility.Type)) }, + { "unitAbility", new TypeMapping(typeof(UnitAbility.Type)) }, + { "improvementAbility", new TypeMapping(typeof(ImprovementAbility.Type)) }, + { "playerAbility", new TypeMapping(typeof(PlayerAbility.Type)) }, + { "weaponData", new TypeMapping(typeof(UnitData.WeaponEnum)) }, + { "skinData", new TypeMapping(typeof(SkinData), false) } + }; + + /// + /// List of custom game modes to be added. + /// + internal static List gamemodes = new(); + + /// + /// Handlers for processing specific data types during mod loading. + /// + internal static readonly Dictionary>> typeHandlers = new() + { + [typeof(TribeType)] = new List>() { + new((token, duringEnumCacheCreation) => + { + if (duringEnumCacheCreation) + { + Registry.customTribes.Add((TribeType)(int)token["idx"]); + token["style"] = Registry.climateAutoidx; + token["climate"] = Registry.climateAutoidx; + Registry.climateAutoidx++; + } + else + { + if (token["skins"] != null) + { + JArray skins = token["skins"].Cast(); + List skinValues = skins._values.ToArray().ToList(); + foreach (var skin in skinValues) + { + string skinId = skin.ToString(); + if (!Enum.TryParse(skinId, ignoreCase: true, out _)) + { + SkinType skinValue = (SkinType)Registry.autoidx; + EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue); + EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue); + Registry.skinInfo.Add(new Visual.SkinInfo(Registry.autoidx, skinId, null)); + Plugin.logger.LogInfo("Created mapping for skinType with id " + skinId + " and index " + Registry.autoidx); + Registry.autoidx++; + if(token["tribeAbilities"] != null) + { + JArray tribeAbilities = token["tribeAbilities"].Cast(); + List tribeAbilitiesValues = tribeAbilities._values.ToArray().ToList(); + + foreach(JToken ability in tribeAbilitiesValues) + { + string abilityValue = ability.ToString(); + + if (Enum.TryParse( + abilityValue, ignoreCase: true, + out TribeAbility.Type tribeAbilityType + ) && tribeAbilityType == TribeAbility.Type.Flooded) + { + string tileEffectId = skinId.ToLowerInvariant() + "_flood"; + EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx); + EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx); Visual.customFloodingSkins.Add((TileData.EffectType)Registry.autoidx, skinValue); - Plugin.logger.LogInfo("Created mapping for tileEffect with id " + tileEffectId + " and index " + Registry.autoidx); - Registry.autoidx++; - } - } - } - } - } - Il2CppSystem.Collections.Generic.List modifiedSkins = skins._values; - foreach (var skin in Registry.skinInfo) - { - if (modifiedSkins.Contains(skin.id)) - { - modifiedSkins.Remove(skin.id); - modifiedSkins.Add(skin.idx.ToString()); - } - } - JArray newSkins = new JArray(); - foreach (var item in modifiedSkins) - { - newSkins.Add(item); - } - token["skins"] = newSkins; - } - if (token["preview"] != null) - { - Visual.PreviewTile[] preview = JsonSerializer.Deserialize(token["preview"].ToString())!; - Registry.tribePreviews[Util.GetJTokenName(token)] = preview; - } - } - }) - }, - [typeof(UnitData.Type)] = new List>() { - new((token, duringEnumCacheCreation) => - { - if(duringEnumCacheCreation) - { - if (token["prefab"] == null) - { - Registry.prefabNames.Add((int)token["idx"], EnumCache.GetName(UnitData.Type.Scout).ToLowerInvariant()); - } - } - else - { - if (token["prefab"] != null) - { - Registry.prefabNames.Add((int)token["idx"], token["prefab"].ToString().ToLowerInvariant()); - } - 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 List>() { - 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; - } - if(token["idx"] != null) - PrefabManager.improvements.TryAdd((ImprovementData.Type)(int)token["idx"], PrefabManager.improvements[improvementPrefabType]); - } - else - { - string improvementId = Util.GetJTokenName(token); - if (token["attractsResource"] != null) - { - string attractsId = token["attractsResource"].ToString(); - Main.attractsResourceNames[improvementId] = attractsId; - } - if (token["attractsToTerrain"] != null) - { - string attractsId = token["attractsToTerrain"].ToString(); - Main.attractsTerrainNames[improvementId] = attractsId; - } - if(token["infoOverride"] != null) - { - Loc.buildingsInfoOverrides[improvementId] = Loc.ReplaceDashesWithDots(token["infoOverride"].ToString()); - } - } - }) - }, - - [typeof(ResourceData.Type)] = new List>() { - 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; - } - if(token["idx"] != null) - PrefabManager.resources.TryAdd((ResourceData.Type)(int)token["idx"], PrefabManager.resources[resourcePrefabType]); - } - }) - }, - - [typeof(SkinData)] = new List>() { - new((token, duringEnumCacheCreation) => - { - var prop = token.Parent.Cast(); - prop.Replace(new JProperty(prop.Name.ToLower(), prop.Value)); - }) - }, - }; - - /// - /// Represents information for a custom game mode button. - /// - /// The index of the game mode. - /// The action to perform when the button is clicked. - /// The index of the button in the UI. - /// The sprite for the button. - /// The name of the sprite for the button. - public record GameModeButtonsInformation(int gameModeIndex, UIButtonBase.ButtonAction action, int? buttonIndex, Sprite? sprite, string? spriteName); - - /// - /// Adds a new game mode button. - /// - /// The unique identifier for the game mode. - /// The action to perform when the button is clicked. - /// The boolean which decides if GameMode should appear in the menu. - /// The sprite for the button. - /// The name of the sprite for the button. - public static void AddGameMode(string id, UIButtonBase.ButtonAction action, bool shouldShowInMenu = true, Sprite? sprite = null, string? spriteName = null) - { - EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx); - EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx); - if (shouldShowInMenu) - gamemodes.Add(new GameModeButtonsInformation(Registry.gameModesAutoidx, action, null, sprite, spriteName)); - Registry.gameModesAutoidx++; - } - - /// - /// Adds a new data type for patching. - /// - /// The identifier for the data type in JSON. - /// "The C# type corresponding to the identifier. - public static void AddPatchDataType(string typeId, Type type) - { - if (!typeMappings.ContainsKey(typeId)) - typeMappings.Add(typeId, new TypeMapping(type)); - } - - public static void AddPatchDataType(string typeId, Type type, bool shouldCreateCache) - { - if (!typeMappings.ContainsKey(typeId)) - typeMappings.Add(typeId, new TypeMapping(type, shouldCreateCache)); - } - - public static void AddTypeHandler(Type type, Action handler) - { - string typeString = type.ToString(); - if (!typeMappings.ContainsValue(new TypeMapping(type, true)) && !typeMappings.ContainsValue(new TypeMapping(type, false))) - { - Plugin.logger.LogWarning($"Tried adding TypeHandler for type: {typeString} with missing TypeMapping. Please, add TypeMapping first."); - return; - } - if (!typeHandlers.ContainsKey(type)) - typeHandlers[type] = new(); - - typeHandlers[type].Add(handler); - - Plugin.logger.LogInfo($"Added TypeHandler for type: {typeString}."); - } - - /// - /// Loads all mods from the mods directory. - /// - /// A dictionary to populate with the loaded mods. - internal static void RegisterMods(Dictionary mods) - { - Directory.CreateDirectory(Plugin.MODS_PATH); - string[] modContainers = Directory.GetDirectories(Plugin.MODS_PATH) - .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.polymod")) - .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.zip")) - .ToArray(); - foreach (var modContainer in modContainers) - { - Mod.Manifest? manifest = null; - List files = new(); - - // Load mod from directory or zip archive - if (Directory.Exists(modContainer)) - { - foreach (var file in Directory.GetFiles(modContainer, "*", SearchOption.AllDirectories)) - { - ProcessModFile( - Path.GetRelativePath(modContainer, file), - File.ReadAllBytes(file), - files, - ref manifest - ); - } - } - else - { - foreach (var entry in new ZipArchive(File.OpenRead(modContainer)).Entries) - { - ProcessModFile( - entry.FullName, - entry.ReadBytes(), - files, - ref manifest - ); - } - } - #region ValidateManifest() - if (manifest == null) - { - Plugin.logger.LogError($"Mod manifest not found in {modContainer}"); - continue; - } - 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($"Mod {manifest.id} already exists"); - continue; - } - #endregion - mods.Add(manifest.id, new( - manifest, - Mod.Status.Success, - files - )); - Plugin.logger.LogInfo($"Registered mod {manifest.id}"); - } - - CheckDependencies(mods); - } - - private static void ProcessModFile(string fullName, byte[] bytes, List files, ref Mod.Manifest? manifest) - { - if (fullName == "manifest.json") - { - manifest = JsonSerializer.Deserialize( - bytes, - new JsonSerializerOptions() - { - Converters = { new VersionJson() }, - } - ); - return; - } - files.Add(new(fullName, bytes)); - } - - internal static void LoadMods(Dictionary mods, out bool dependencyCycle) - { - dependencyCycle = !SortMods(Registry.mods); - if (dependencyCycle) return; - - 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") - { - LoadAssemblyFile(mod, file); - } - Match spritesMatch = Regex.Match(Path.GetFileName(file.name), @"^sprites(?:_(.*))?\.json$"); - if (spritesMatch.Success) - { - LoadSpriteInfoFile(mod, file); - } - Match languageMatch = Regex.Match(Path.GetFileName(file.name), @"^language(?:_(.*))?\.json$"); - if (languageMatch.Success) - { - string languageName = languageMatch.Groups[1].Value; - LoadLanguageFile( - mod, - file, - languageName - ); - } - } - if (!mod.client && id != "polytopia") - { - checksumString.Append(id); - checksumString.Append(mod.version.ToString()); - } - } - Compatibility.HashSignatures(checksumString); - - } - internal static void CheckDependencies(Dictionary mods) - { - foreach (var (id, mod) in mods) - { - foreach (var dependency in mod.dependencies ?? Array.Empty()) - { - string? message = null; - if (!mods.ContainsKey(dependency.id)) - { - message = $"Dependency {dependency.id} not found"; - } - else - { - Version version = mods[dependency.id].version; - if ( - (dependency.min != null && version < dependency.min) - || - (dependency.max != null && version > dependency.max) - ) - { - message = $"Need dependency {dependency.id} version {dependency.min} - {dependency.max} found {version}"; - } - } - if (message != null) - { - if (dependency.required) - { - Plugin.logger.LogError(message); - mod.status = Mod.Status.DependenciesUnsatisfied; - } - else - { - Plugin.logger.LogWarning(message); - } - } - } - } - } - - /// - /// Sorts mods based on their dependencies using a topological sort. - /// - /// The dictionary of mods to sort. - /// True if the mods could be sorted (no circular dependencies), false otherwise. - internal static bool SortMods(Dictionary mods) - { - Stopwatch s = new(); - Dictionary> graph = new(); - Dictionary inDegree = new(); - Dictionary successfulMods = new(); - Dictionary unsuccessfulMods = new(); - foreach (var (id, mod) in mods) - { - if (mod.status == Mod.Status.Success) successfulMods.Add(id, mod); - else unsuccessfulMods.Add(id, mod); - } - foreach (var (id, _) in successfulMods) - { - graph[id] = new(); - inDegree[id] = 0; - } - foreach (var (id, mod) in successfulMods) - { - foreach (var dependency in mod.dependencies ?? Array.Empty()) - { - if (!graph.ContainsKey(dependency.id)) continue; - graph[dependency.id].Add(id); - inDegree[id]++; - } - } - Queue queue = new(); - foreach (var (id, _) in successfulMods) - { - if (inDegree[id] == 0) - { - queue.Enqueue(id); - } - } - Dictionary sorted = new(); - while (queue.Count > 0) - { - var id = queue.Dequeue(); - var mod = successfulMods[id]; - sorted.Add(id, mod); - foreach (var neighbor in graph[id]) - { - inDegree[neighbor]--; - if (inDegree[neighbor] == 0) - { - queue.Enqueue(neighbor); - } - } - } - if (sorted.Count != successfulMods.Count) - { - return false; - } - mods.Clear(); - mods.AddRange(sorted); - mods.AddRange(unsuccessfulMods); - - return true; - } - - /// - /// Loads an assembly file from a mod. - /// - /// The mod the assembly belongs to. - /// The assembly file to load. - public static void LoadAssemblyFile(Mod mod, Mod.File file) - { - try - { - Assembly assembly = Assembly.Load(file.bytes); - if (assembly - .GetTypes() - .FirstOrDefault(t => t.IsSubclassOf(typeof(Api.PolyScriptBase))) - is { } modType) - { - var modInstance = (Api.PolyScriptBase)Activator.CreateInstance(modType)!; - modInstance.Initialize(mod, BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}")); - modInstance.Load(); - return; - } - foreach (Type type in assembly.GetTypes()) - { - MethodInfo? loadWithLogger = type.GetMethod("Load", new Type[] { typeof(ManualLogSource) }); - if (loadWithLogger != null) - { - loadWithLogger.Invoke(null, new object[] - { - BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}") - }); - Plugin.logger.LogInfo($"Invoked Load method with logger from {type.FullName} from {mod.id} mod"); - } - MethodInfo? load = type.GetMethod("Load", Array.Empty()); - if (load != null) - { - load.Invoke(null, null); - Plugin.logger.LogInfo($"Invoked Load method from {type.FullName} from {mod.id} mod"); - } - } - } - catch (TargetInvocationException exception) - { - if (exception.InnerException != null) - { - Plugin.logger.LogError($"Error on loading assembly from {mod.id} mod: {exception.InnerException.Message}"); - mod.status = Mod.Status.Error; - } - } - } - - /// - /// Loads a localization file from a mod. - /// - /// The mod the localization file belongs to. - /// The localization file to load. - public static void LoadLocalizationFile(Mod mod, Mod.File file) - { - try - { - Loc.BuildAndLoadLocalization(JsonSerializer - .Deserialize>>(file.bytes)!); - Plugin.logger.LogInfo($"Registered localization from {mod.id} mod"); - } - catch (Exception e) - { - Plugin.logger.LogError($"Error on loading locatization from {mod.id} mod: {e.StackTrace}"); - } - } - - public static void LoadLanguageFile(Mod mod, Mod.File file, string languageName) - { - try - { - var dict = JsonSerializer.Deserialize>(file.bytes); - if (dict != null) - { - Loc.languagesToAdd.Add(languageName, dict); - Plugin.logger.LogInfo($"Registered language from {mod.id} mod"); - } - } - catch (Exception e) - { - Plugin.logger.LogError($"Error on loading language file from {mod.id} mod: {e.StackTrace}"); - } - } - - /// - /// Loads a sprite file from a mod. - /// - /// The mod the sprite file belongs to. - /// The sprite file to load. - public static void LoadSpriteFile(Mod mod, Mod.File file) - { - string name = Path.GetFileNameWithoutExtension(file.name); - Vector2 pivot = name.Split("_")[0] switch - { - "field" => new(0.5f, 0.0f), - "mountain" => new(0.5f, -0.375f), - _ => new(0.5f, 0.5f), - }; - float pixelsPerUnit = 2112f; - if (Registry.spriteInfos.ContainsKey(name)) - { - Visual.SpriteInfo spriteData = Registry.spriteInfos[name]; - pivot = spriteData.pivot ?? pivot; - pixelsPerUnit = spriteData.pixelsPerUnit ?? pixelsPerUnit; - } - Sprite sprite = Visual.BuildSprite(file.bytes, pivot, pixelsPerUnit); - GameManager.GetSpriteAtlasManager().cachedSprites.TryAdd("Heads", new()); - GameManager.GetSpriteAtlasManager().cachedSprites["Heads"].Add(name, sprite); - Registry.sprites.Add(name, sprite); - } - - /// - /// Updates a sprite with new information. - /// - /// The name of the sprite to update. - 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; - } - } - - /// - /// Loads a sprite info file from a mod. - /// - /// The mod the sprite info file belongs to. - /// The sprite info file to load. - /// A dictionary of sprite information, or null if an error occurred. - public static Dictionary? LoadSpriteInfoFile(Mod mod, Mod.File file) - { - try - { - 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.StackTrace}"); - return null; - } - } - - /// - /// Loads an audio file from a mod. - /// - /// The mod the audio file belongs to. - /// The audio file to load. - public static void LoadAudioFile(Mod mod, Mod.File file) - { - // AudioSource audioSource = new GameObject().AddComponent(); - // GameObject.DontDestroyOnLoad(audioSource); - // audioSource.clip = Managers.Audio.BuildAudioClip(file.bytes); - // Registry.audioClips.Add(Path.GetFileNameWithoutExtension(file.name), audioSource); - // TODO: issue #71 - } - - /// - /// Loads a prefab info file from a mod and creates a new unit prefab. - /// - /// The mod the prefab info file belongs to. - /// The prefab info file to load. - 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 || prefab.visualParts.Count == 0) - return; - - if (Registry.unitPrefabs.Keys.Any(pref => pref.name == prefab.name)) - { - Plugin.logger.LogInfo($"Prefab {prefab.name} already exists, skipping."); - } - var baseUnit = PrefabManager.GetPrefab(UnitData.Type.Warrior, TribeType.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, spriteContainer, material); - - Transform? headPositionMarker = null; - foreach (var vp in visualParts) - { - if (vp.visualPart.gameObject.name == prefab.headPositionMarker) - { - headPositionMarker = vp.visualPart.gameObject.transform; - break; - } - } - - unitInstance.headPositionMarker = headPositionMarker ?? visualParts[0].visualPart.transform; - - var svr = unitInstance.GetComponent(); - svr.visualParts = visualParts.ToArray(); - - GameObject.DontDestroyOnLoad(unitInstance.gameObject); - Registry.unitPrefabs.Add(prefab, unitInstance.GetComponent()); - - Plugin.logger.LogInfo($"Registered prefab {prefab.name} info from {mod.id} mod."); - } - catch (Exception e) - { - Plugin.logger.LogError($"Error on loading prefab {Path.GetFileNameWithoutExtension(file.name)} info from {mod.id} mod: {e.StackTrace}."); - } - } - - /// - /// Clears existing visual parts from a prefab and extracts the material. - /// - /// The transform containing the sprite parts. - /// The material of the original sprite parts. - 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; - } - - /// - /// Applies new visual parts to a prefab. - /// - /// A list of visual part information. - /// The transform to add the parts to. - /// The material to use for the parts. - /// A list of the created visual parts. - private static List ApplyVisualParts( - Visual.PrefabInfo prefab, - Transform spriteContainer, - Material? material) - { - if (prefab.visualParts.Count == 0) - return new List(); - - List parts = new(); - - foreach (var info in prefab.visualParts) - { - parts.Add(CreateVisualPart(info, spriteContainer, material)); - } - - return parts; - } - - /// - /// Creates a single visual part for a prefab. - /// - /// The information for the visual part. - /// The parent transform for the part. - /// The material to use for the part. - /// The created visual part. - 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; - } - - /// - /// Loads and applies a game logic data patch from a mod. - /// - /// The mod the patch belongs to. - /// The original game logic data. - /// The patch to apply. - public static void LoadGameLogicDataPatch(Mod mod, JObject gld, JObject patch) - { - try - { - // Merge the patch into the game logic data - gld = JsonMerger.Merge(gld, patch); - Plugin.logger.LogInfo($"Registered patch from {mod.id} mod"); - } - catch (Exception e) - { - Plugin.logger.LogError($"Error on loading patch from {mod.id} mod: {e.StackTrace}"); - mod.status = Mod.Status.Error; - } - } - - /// - /// Loads an asset bundle from a mod. - /// - /// The mod the asset bundle belongs to. - /// The asset bundle file to load. - public static void LoadAssetBundle(Mod mod, Mod.File file) - { - Registry.assetBundles.Add( - Path.GetFileNameWithoutExtension(file.name), - AssetBundle.LoadFromMemory(file.bytes) - ); - } - - /// - /// Processes the merged game logic data after all mods have been loaded and patched. - /// - /// The game logic data object to populate. - /// The root JObject of the merged game logic data. - internal static void ProcessGameLogicData(GameLogicData gameLogicData, JObject rootObject) - { - try - { - CreateMappings(rootObject); - ProcessCustomTribes(); - ProcessPrefabs(); - ProcessEmbarkOverrides(); - ProcessAttractOverrides(); - } - catch (Exception e) - { - Plugin.logger.LogError($"Error on processing modified game logic data : {e.StackTrace}"); - } - } - - internal static void ProcessCustomTribes() - { - foreach (var tribe in Registry.customTribes) - { - if (!GameLogicData.legacyTribeTypesOrder.Contains(tribe)) - { - GameLogicData.legacyTribeTypesOrder.Add(tribe); - } - } - } - - /// - /// Creates EnumCache mappings for custom enum values and invokes type handlers. - /// - /// - internal static void CreateMappings(JObject rootObject) - { - foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray()) - { - JObject? token = jtoken.TryCast(); - if (token == null) - continue; - - string dataType = Util.GetJTokenName(token, 2); - if (!typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping)) - continue; - - if (token["idx"] == null || !typeMapping.shouldCreateCache) - continue; - - Type targetType = typeMapping.type; - if (!targetType.IsEnum) - { - Plugin.logger.LogWarning($"Type {targetType.FullName} is not an enum, skipping!"); - continue; - } - - string id = Util.GetJTokenName(token); - - if ((int)token["idx"] == -1) - { - token["idx"] = Registry.autoidx; - Registry.autoidx++; - } - else if (Plugin.config.allowUnsafeIndexes) - { - Array values = Enum.GetValues(targetType); - - var maxValue = values.Cast().Max(); - - if (maxValue >= (int)token["idx"]) - { - continue; - } - } - else - { - continue; - } - - MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(targetType).GetMethod("AddMapping"); - if (methodInfo == null) - { - Plugin.logger.LogWarning($"Missing AddMapping method for {targetType.FullName}"); - continue; - } - - methodInfo.Invoke(null, new object[] { id, (int)token["idx"] }); - methodInfo.Invoke(null, new object[] { id, (int)token["idx"] }); - - if (typeHandlers.TryGetValue(targetType, out var handlers)) - { - foreach (var handler in handlers) - { - handler(token, true); - } - } - Plugin.logger.LogInfo("Created mapping for " + targetType.ToString() + " with id " + id + " and index " + (int)token["idx"]); - } - foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray()) - { - JObject? token = jtoken.TryCast(); - if (token != null) - { - string dataType = Util.GetJTokenName(token, 2); - if (typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping)) - { - if (typeHandlers.TryGetValue(typeMapping.type, out var handlers)) - { - foreach (var handler in handlers) - { - handler(token, false); - } - } - } - } - } - } - - /// - /// Processes the prefab registry and populates the PrefabManager with custom prefabs. - /// - internal static void ProcessPrefabs() - { - Plugin.logger.LogInfo($"Processing prefabs, count: {Registry.prefabNames.Count}."); - foreach (KeyValuePair item in Registry.prefabNames) - { - UnitData.Type unitPrefabType = UnitData.Type.Scout; - string prefabId = item.Value; - int hashKey = PrefabManager.GetSkinnedHashKey((UnitData.Type)item.Key, TribeType.None, SkinType.Default); - if (!Enum.TryParse(CultureInfo.CurrentCulture.TextInfo.ToTitleCase(prefabId), out unitPrefabType)) // Existing unit prefabs - { - KeyValuePair prefabInfo = Registry.unitPrefabs.FirstOrDefault( - kv => kv.Key.name == prefabId - ); - if (!EqualityComparer.Default.Equals(prefabInfo.Key, default)) - { - Visual.UnitPrefabInfo unitPrefabInfo = new( - EnumCache.GetName((UnitData.Type)item.Key), - EnumCache.GetName(TribeType.None), - EnumCache.GetName(SkinType.Default) - ); - Visual.customPrefabs.Add(unitPrefabInfo, prefabInfo.Value); - Plugin.logger.LogInfo($"Using custom prefab {prefabId} for unit {item.Key}."); - continue; - } - } - Plugin.logger.LogInfo($"Using existing prefab {EnumCache.GetName(unitPrefabType)} for unit {item.Key}."); - PrefabManager.units.TryAdd( - hashKey, - PrefabManager.units[PrefabManager.GetSkinnedHashKey(unitPrefabType, TribeType.None, SkinType.Default)] - ); - } - } - - /// - /// Processes embark overrides by mapping original embark unit types to configured overrides. - /// - internal static void ProcessEmbarkOverrides() - { - foreach (KeyValuePair entry in Main.embarkNames) - { - try - { - UnitData.Type unit = EnumCache.GetType(entry.Key); - UnitData.Type newUnit = EnumCache.GetType(entry.Value); - Main.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}"); - } - } - } - - /// - /// Processes attract overrides by mapping improvements to resources and terrain types based on configured overrides. - /// - internal static void ProcessAttractOverrides() - { - foreach (KeyValuePair entry in Main.attractsResourceNames) - { - try - { - ImprovementData.Type improvement = EnumCache.GetType(entry.Key); - ResourceData.Type resource = EnumCache.GetType(entry.Value); - Main.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 Main.attractsTerrainNames) - { - try - { - ImprovementData.Type improvement = EnumCache.GetType(entry.Key); - Polytopia.Data.TerrainData.Type terrain = EnumCache.GetType(entry.Value); - Main.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}"); - } - } - } + Plugin.logger.LogInfo("Created mapping for tileEffect with id " + tileEffectId + " and index " + Registry.autoidx); + Registry.autoidx++; + } + } + } + } + } + Il2CppSystem.Collections.Generic.List modifiedSkins = skins._values; + foreach (var skin in Registry.skinInfo) + { + if (modifiedSkins.Contains(skin.id)) + { + modifiedSkins.Remove(skin.id); + modifiedSkins.Add(skin.idx.ToString()); + } + } + JArray newSkins = new JArray(); + foreach (var item in modifiedSkins) + { + newSkins.Add(item); + } + token["skins"] = newSkins; + } + if (token["preview"] != null) + { + TribePreviews.PreviewTile[] preview = JsonSerializer.Deserialize(token["preview"].ToString())!; + Registry.tribePreviews[Util.GetJTokenName(token)] = preview; + } + } + }) + }, + [typeof(UnitData.Type)] = new List>() { + new((token, duringEnumCacheCreation) => + { + if(!duringEnumCacheCreation) + { + 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 List>() { + new((token, duringEnumCacheCreation) => + { + if (duringEnumCacheCreation) + return; + + if (token["prefab"] != null && (bool)token["prefab"]) + { + bool levels = false; + if(token["maxLevel"] != null && (int)token["maxLevel"] > 0) + { + levels = true; + } + Prefabs.improvementPrefabInfosToCreate[(ImprovementData.Type)(int)token["idx"]] = levels; + } + string improvementId = Util.GetJTokenName(token); + if (token["attractsResource"] != null) + { + string attractsId = token["attractsResource"].ToString(); + Main.attractsResourceNames[improvementId] = attractsId; + } + if (token["attractsToTerrain"] != null) + { + string attractsId = token["attractsToTerrain"].ToString(); + Main.attractsTerrainNames[improvementId] = attractsId; + } + if(token["infoOverride"] != null) + { + Loc.buildingsInfoOverrides[improvementId] = Loc.ReplaceDashesWithDots(token["infoOverride"].ToString()); + } + }) + }, + + [typeof(ResourceData.Type)] = new List>() { + new((token, duringEnumCacheCreation) => + { + if (duringEnumCacheCreation) + return; + + if (token["prefab"] != null && (bool)token["prefab"]) + { + Prefabs.resourcePrefabsInfosToCreate.Add((ResourceData.Type)(int)token["idx"]); + } + }) + }, + + [typeof(SkinData)] = new List>() { + new((token, duringEnumCacheCreation) => + { + var prop = token.Parent.Cast(); + prop.Replace(new JProperty(prop.Name.ToLower(), prop.Value)); + }) + }, + }; + + /// + /// Represents information for a custom game mode button. + /// + /// The index of the game mode. + /// The action to perform when the button is clicked. + /// The index of the button in the UI. + /// The sprite for the button. + /// The name of the sprite for the button. + public record GameModeButtonsInformation(int gameModeIndex, UIButtonBase.ButtonAction action, int? buttonIndex, Sprite? sprite, string? spriteName); + + /// + /// Adds a new game mode button. + /// + /// The unique identifier for the game mode. + /// The action to perform when the button is clicked. + /// The boolean which decides if GameMode should appear in the menu. + /// The sprite for the button. + /// The name of the sprite for the button. + public static void AddGameMode(string id, UIButtonBase.ButtonAction action, bool shouldShowInMenu = true, Sprite? sprite = null, string? spriteName = null) + { + EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx); + EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx); + if(shouldShowInMenu) + gamemodes.Add(new GameModeButtonsInformation(Registry.gameModesAutoidx, action, null, sprite, spriteName)); + Registry.gameModesAutoidx++; + } + + /// + /// Adds a new data type for patching. + /// + /// The identifier for the data type in JSON. + /// "The C# type corresponding to the identifier. + public static void AddPatchDataType(string typeId, Type type) + { + if (!typeMappings.ContainsKey(typeId)) + typeMappings.Add(typeId, new TypeMapping(type)); + } + + public static void AddPatchDataType(string typeId, Type type, bool shouldCreateCache) + { + if (!typeMappings.ContainsKey(typeId)) + typeMappings.Add(typeId, new TypeMapping(type, shouldCreateCache)); + } + + public static void AddTypeHandler(Type type, Action handler) + { + string typeString = type.ToString(); + if(!typeMappings.ContainsValue(new TypeMapping(type, true)) && !typeMappings.ContainsValue(new TypeMapping(type, false))) + { + Plugin.logger.LogWarning($"Tried adding TypeHandler for type: {typeString} with missing TypeMapping. Please, add TypeMapping first."); + return; + } + if(!typeHandlers.ContainsKey(type)) + typeHandlers[type] = new(); + + typeHandlers[type].Add(handler); + + Plugin.logger.LogInfo($"Added TypeHandler for type: {typeString}."); + } + + /// + /// Loads all mods from the mods directory. + /// + /// A dictionary to populate with the loaded mods. + internal static void RegisterMods(Dictionary mods) + { + Directory.CreateDirectory(Plugin.MODS_PATH); + string[] modContainers = Directory.GetDirectories(Plugin.MODS_PATH) + .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.polymod")) + .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.zip")) + .ToArray(); + foreach (var modContainer in modContainers) + { + Mod.Manifest? manifest = null; + List files = new(); + + // Load mod from directory or zip archive + if (Directory.Exists(modContainer)) + { + foreach (var file in Directory.GetFiles(modContainer, "*", SearchOption.AllDirectories)) + { + ProcessModFile( + Path.GetRelativePath(modContainer, file), + File.ReadAllBytes(file), + files, + ref manifest + ); + } + } + else + { + foreach (var entry in new ZipArchive(File.OpenRead(modContainer)).Entries) + { + ProcessModFile( + entry.FullName, + entry.ReadBytes(), + files, + ref manifest + ); + } + } + #region ValidateManifest() + if (manifest == null) + { + Plugin.logger.LogError($"Mod manifest not found in {modContainer}"); + continue; + } + 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 (Plugin.config.disabledMods.Contains(manifest.id)) + { + Plugin.logger.LogError($"Mod {manifest.id} is disabled, skipping"); + 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($"Mod {manifest.id} already exists"); + continue; + } + #endregion + mods.Add(manifest.id, new( + manifest, + Mod.Status.Success, + files + )); + Plugin.logger.LogInfo($"Registered mod {manifest.id}"); + } + + CheckDependencies(mods); + } + + private static void ProcessModFile(string fullName, byte[] bytes, List files, ref Mod.Manifest? manifest) + { + if (fullName == "manifest.json") + { + manifest = JsonSerializer.Deserialize( + bytes, + new JsonSerializerOptions() + { + Converters = { new VersionJson() }, + } + ); + return; + } + files.Add(new(fullName, bytes)); + } + + internal static void LoadMods(Dictionary mods, out bool dependencyCycle) + { + dependencyCycle = !SortMods(Registry.mods); + if (dependencyCycle) return; + + 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") + { + LoadAssemblyFile(mod, file); + } + Match spritesMatch = Regex.Match(Path.GetFileName(file.name), @"^sprites(?:_(.*))?\.json$"); + if (spritesMatch.Success) + { + LoadSpriteInfoFile(mod, file); + } + Match languageMatch = Regex.Match(Path.GetFileName(file.name), @"^language(?:_(.*))?\.json$"); + if (languageMatch.Success) + { + string languageName = languageMatch.Groups[1].Value; + LoadLanguageFile( + mod, + file, + languageName + ); + } + } + if (!mod.client && id != "polytopia") + { + checksumString.Append(id); + checksumString.Append(mod.version.ToString()); + } + } + Compatibility.HashSignatures(checksumString); + } + + internal static void CheckDependencies(Dictionary mods) + { + foreach (var (id, mod) in mods) + { + foreach (var dependency in mod.dependencies ?? Array.Empty()) + { + string? message = null; + if (!mods.ContainsKey(dependency.id)) + { + message = $"Dependency {dependency.id} not found"; + } + else + { + Version version = mods[dependency.id].version; + if ( + (dependency.min != null && version < dependency.min) + || + (dependency.max != null && version > dependency.max) + ) + { + message = $"Need dependency {dependency.id} version {dependency.min} - {dependency.max} found {version}"; + } + } + if (message != null) + { + if (dependency.required) + { + Plugin.logger.LogError(message); + mod.status = Mod.Status.DependenciesUnsatisfied; + } + else + { + Plugin.logger.LogWarning(message); + } + } + } + } + } + + /// + /// Sorts mods based on their dependencies using a topological sort. + /// + /// The dictionary of mods to sort. + /// True if the mods could be sorted (no circular dependencies), false otherwise. + internal static bool SortMods(Dictionary mods) + { + Stopwatch s = new(); + Dictionary> graph = new(); + Dictionary inDegree = new(); + Dictionary successfulMods = new(); + Dictionary unsuccessfulMods = new(); + foreach (var (id, mod) in mods) + { + if (mod.status == Mod.Status.Success) successfulMods.Add(id, mod); + else unsuccessfulMods.Add(id, mod); + } + foreach (var (id, _) in successfulMods) + { + graph[id] = new(); + inDegree[id] = 0; + } + foreach (var (id, mod) in successfulMods) + { + foreach (var dependency in mod.dependencies ?? Array.Empty()) + { + if (!graph.ContainsKey(dependency.id)) continue; + graph[dependency.id].Add(id); + inDegree[id]++; + } + } + Queue queue = new(); + foreach (var (id, _) in successfulMods) + { + if (inDegree[id] == 0) + { + queue.Enqueue(id); + } + } + Dictionary sorted = new(); + while (queue.Count > 0) + { + var id = queue.Dequeue(); + var mod = successfulMods[id]; + sorted.Add(id, mod); + foreach (var neighbor in graph[id]) + { + inDegree[neighbor]--; + if (inDegree[neighbor] == 0) + { + queue.Enqueue(neighbor); + } + } + } + if (sorted.Count != successfulMods.Count) + { + return false; + } + mods.Clear(); + mods.AddRange(sorted); + mods.AddRange(unsuccessfulMods); + + return true; + } + + /// + /// Loads an assembly file from a mod. + /// + /// The mod the assembly belongs to. + /// The assembly file to load. + public static void LoadAssemblyFile(Mod mod, Mod.File file) + { + try + { + Assembly assembly = Assembly.Load(file.bytes); + if (assembly + .GetTypes() + .FirstOrDefault(t => t.IsSubclassOf(typeof(Api.PolyScriptBase))) + is { } modType) + { + var modInstance = (Api.PolyScriptBase) Activator.CreateInstance(modType)!; + modInstance.Initialize(mod, BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}")); + modInstance.Load(); + return; + } + foreach (Type type in assembly.GetTypes()) + { + MethodInfo? loadWithLogger = type.GetMethod("Load", new Type[] { typeof(ManualLogSource) }); + if (loadWithLogger != null) + { + loadWithLogger.Invoke(null, new object[] + { + BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}") + }); + Plugin.logger.LogInfo($"Invoked Load method with logger from {type.FullName} from {mod.id} mod"); + } + MethodInfo? load = type.GetMethod("Load", Array.Empty()); + if (load != null) + { + load.Invoke(null, null); + Plugin.logger.LogInfo($"Invoked Load method from {type.FullName} from {mod.id} mod"); + } + } + } + catch (TargetInvocationException exception) + { + if (exception.InnerException != null) + { + Plugin.logger.LogError($"Error on loading assembly from {mod.id} mod: {exception.InnerException.Message}"); + mod.status = Mod.Status.Error; + } + } + } + + /// + /// Loads a localization file from a mod. + /// + /// The mod the localization file belongs to. + /// The localization file to load. + public static void LoadLocalizationFile(Mod mod, Mod.File file) + { + try + { + Loc.BuildAndLoadLocalization(JsonSerializer + .Deserialize>>(file.bytes)!); + Plugin.logger.LogInfo($"Registered localization from {mod.id} mod"); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading locatization from {mod.id} mod: {e.StackTrace}"); + } + } + + public static void LoadLanguageFile(Mod mod, Mod.File file, string languageName) + { + try + { + var dict = JsonSerializer.Deserialize>(file.bytes); + if(dict != null) + { + Loc.languagesToAdd.Add(languageName, dict); + Plugin.logger.LogInfo($"Registered language from {mod.id} mod"); + } + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading language file from {mod.id} mod: {e.StackTrace}"); + } + } + + /// + /// Loads a sprite file from a mod. + /// + /// The mod the sprite file belongs to. + /// The sprite file to load. + public static void LoadTextureFile(Mod mod, Mod.File file) + { + string name = Path.GetFileNameWithoutExtension(file.name); + Texture2D texture2D = Visual.BuildTexture(file.bytes); + Registry.textures[name] = new KeyValuePair(texture2D, false); + } + + /// + /// Loads a sprite info file from a mod. + /// + /// The mod the sprite info file belongs to. + /// The sprite info file to load. + /// A dictionary of sprite information, or null if an error occurred. + public static Dictionary? LoadSpriteInfoFile(Mod mod, Mod.File file) + { + try + { + 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.StackTrace}"); + return null; + } + } + + /// + /// Loads an audio file from a mod. + /// + /// The mod the audio file belongs to. + /// The audio file to load. + public static void LoadAudioFile(Mod mod, Mod.File file) + { + string name = Path.GetFileNameWithoutExtension(file.name).ToLower(); + try + { + AudioClip clip = Managers.Audio.BuildAudioClip(file.bytes, name); + AudioSource audioSource = new GameObject($"PolyModAudio_{name}").AddComponent(); + UnityEngine.Object.DontDestroyOnLoad(audioSource.gameObject); + audioSource.clip = clip; + Registry.audioClips[name] = audioSource; + + Plugin.logger.LogInfo( + $"Registered audio {name} file from {mod.id} mod" + + $" ({clip.channels}ch {clip.frequency}Hz {clip.length:F2}s)." + ); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading audio {name} file from {mod.id} mod: {e.Message}"); + } + } + + public static void LoadPrefabsFile(Mod mod, Mod.File file) + { + try + { + var prefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions + { + Converters = { new Vector2Json() }, + PropertyNameCaseInsensitive = true, + }); + if (prefab == null) + return; + + Prefabs.unitInfos.AddRange(prefab.units); + Prefabs.improvementInfos.AddRange(prefab.improvements); + Prefabs.resourceInfos.AddRange(prefab.resources); + + Plugin.logger.LogInfo($"Registered prefabs {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod."); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading prefabs {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod: {e.StackTrace}."); + } + } + + public static void LoadUnitPrefabFile(Mod mod, Mod.File file) + { + try + { + var unitPrefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions + { + Converters = { new Vector2Json() }, + PropertyNameCaseInsensitive = true, + }); + if (unitPrefab == null) + return; + + Prefabs.unitInfos.Add(unitPrefab); + + Plugin.logger.LogInfo($"Registered unit prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod."); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading unit prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod: {e.StackTrace}."); + } + } + + public static void LoadImprovementPrefabFile(Mod mod, Mod.File file) + { + try + { + var improvementPrefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions + { + Converters = { new Vector2Json() }, + PropertyNameCaseInsensitive = true, + }); + if (improvementPrefab == null) + return; + + Prefabs.improvementInfos.Add(improvementPrefab); + + Plugin.logger.LogInfo($"Registered improvement prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod."); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading improvement prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod: {e.StackTrace}."); + } + } + + public static void LoadResourcePrefabFile(Mod mod, Mod.File file) + { + try + { + var resourcePrefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions + { + Converters = { new Vector2Json() }, + PropertyNameCaseInsensitive = true, + }); + if (resourcePrefab == null) + return; + + Prefabs.resourceInfos.Add(resourcePrefab); + + Plugin.logger.LogInfo($"Registered resource prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod."); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading resource prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod: {e.StackTrace}."); + } + } + + /// + /// Loads and applies a game logic data patch from a mod. + /// + /// The mod the patch belongs to. + /// The original game logic data. + /// The patch to apply. + public static void LoadGameLogicDataPatch(Mod mod, JObject gld, JObject patch) + { + try + { + // Merge the patch into the game logic data + gld = JsonMerger.Merge(gld, patch); + Plugin.logger.LogInfo($"Registered patch from {mod.id} mod"); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading patch from {mod.id} mod: {e.StackTrace}"); + mod.status = Mod.Status.Error; + } + } + + /// + /// Loads an asset bundle from a mod. + /// + /// The mod the asset bundle belongs to. + /// The asset bundle file to load. + public static void LoadAssetBundle(Mod mod, Mod.File file) + { + Registry.assetBundles.Add( + Path.GetFileNameWithoutExtension(file.name), + AssetBundle.LoadFromMemory(file.bytes) + ); + } + + /// + /// Processes the merged game logic data after all mods have been loaded and patched. + /// + /// The game logic data object to populate. + /// The root JObject of the merged game logic data. + internal static void ProcessGameLogicData(GameLogicData gameLogicData, JObject rootObject) + { + try + { + CreateMappings(rootObject); + ProcessCustomTribes(); + ProcessEmbarkOverrides(); + ProcessAttractOverrides(); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on processing modified game logic data : {e.StackTrace}"); + } + } + + internal static void ProcessCustomTribes() + { + foreach (var tribe in Registry.customTribes) + { + if(!GameLogicData.legacyTribeTypesOrder.Contains(tribe)) + { + GameLogicData.legacyTribeTypesOrder.Add(tribe); + } + } + } + + /// + /// Creates EnumCache mappings for custom enum values and invokes type handlers. + /// + /// + internal static void CreateMappings(JObject rootObject) + { + foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray()) + { + JObject? token = jtoken.TryCast(); + if (token == null) + continue; + + string dataType = Util.GetJTokenName(token, 2); + if (!typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping)) + continue; + + if (token["idx"] == null || !typeMapping.shouldCreateCache) + continue; + + Type targetType = typeMapping.type; + if (!targetType.IsEnum) + { + Plugin.logger.LogWarning($"Type {targetType.FullName} is not an enum, skipping!"); + continue; + } + + string id = Util.GetJTokenName(token); + + if((int)token["idx"] == -1) + { + token["idx"] = Registry.autoidx; + Registry.autoidx++; + } + else if(Plugin.config.allowUnsafeIndexes) + { + Array values = Enum.GetValues(targetType); + + var maxValue = values.Cast().Max(); + + if(maxValue >= (int)token["idx"]) + { + continue; + } + } + else + { + continue; + } + + MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(targetType).GetMethod("AddMapping"); + if (methodInfo == null) + { + Plugin.logger.LogWarning($"Missing AddMapping method for {targetType.FullName}"); + continue; + } + + methodInfo.Invoke(null, new object[] { id, (int)token["idx"] }); + methodInfo.Invoke(null, new object[] { id, (int)token["idx"] }); + + if (typeHandlers.TryGetValue(targetType, out var handlers)) + { + foreach(var handler in handlers) + { + handler(token, true); + } + } + Plugin.logger.LogInfo("Created mapping for " + targetType.ToString() + " with id " + id + " and index " + (int)token["idx"]); + } + foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray()) + { + JObject? token = jtoken.TryCast(); + if (token != null) + { + string dataType = Util.GetJTokenName(token, 2); + if (typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping)) + { + if (typeHandlers.TryGetValue(typeMapping.type, out var handlers)) + { + foreach(var handler in handlers) + { + handler(token, false); + } + } + } + } + } + } + + /// + /// Processes the prefab registry and populates the PrefabManager with custom prefabs. + /// + internal static void ProcessFilesQueue() + { + try + { + foreach (KeyValuePair keyValuePair in Registry.filesQueueToBeProcessedAfterGLDMerge) + { + Mod mod = keyValuePair.Key; + Mod.File file = keyValuePair.Value; + Match prefabsMatch = Regex.Match(Path.GetFileName(file.name), @"^prefabs(?:_(.*))?\.json$"); + if (prefabsMatch.Success) + { + LoadPrefabsFile(mod, file); + continue; + } + + Match unitPrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^unitPrefab(?:_(.*))?\.json$"); + if (unitPrefabMatch.Success) + { + LoadUnitPrefabFile(mod, file); + continue; + } + + Match improvementPrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^improvementPrefab(?:_(.*))?\.json$"); + if (improvementPrefabMatch.Success) + { + LoadImprovementPrefabFile(mod, file); + continue; + } + + Match resourcePrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^resourcePrefab(?:_(.*))?\.json$"); + if (resourcePrefabMatch.Success) + { + LoadResourcePrefabFile(mod, file); + continue; + } + + } + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on processing post gld merge files queue : {e.StackTrace}"); + } + } + + /// + /// Processes embark overrides by mapping original embark unit types to configured overrides. + /// + internal static void ProcessEmbarkOverrides() + { + foreach (KeyValuePair entry in Main.embarkNames) + { + try + { + UnitData.Type unit = EnumCache.GetType(entry.Key); + UnitData.Type newUnit = EnumCache.GetType(entry.Value); + Main.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}"); + } + } + } + + /// + /// Processes attract overrides by mapping improvements to resources and terrain types based on configured overrides. + /// + internal static void ProcessAttractOverrides() + { + foreach (KeyValuePair entry in Main.attractsResourceNames) + { + try + { + ImprovementData.Type improvement = EnumCache.GetType(entry.Key); + ResourceData.Type resource = EnumCache.GetType(entry.Value); + Main.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 Main.attractsTerrainNames) + { + try + { + ImprovementData.Type improvement = EnumCache.GetType(entry.Key); + Polytopia.Data.TerrainData.Type terrain = EnumCache.GetType(entry.Value); + Main.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}"); + } + } + } } diff --git a/src/Managers/Audio.cs b/src/Managers/Audio.cs index 100cec7..aefd86c 100644 --- a/src/Managers/Audio.cs +++ b/src/Managers/Audio.cs @@ -1,5 +1,4 @@ using HarmonyLib; -using Polytopia.Data; using UnityEngine; using PolytopiaBackendBase.Common; @@ -17,7 +16,7 @@ public static class Audio [HarmonyPatch(typeof(MusicData), nameof(MusicData.GetNatureAudioClip))] private static bool MusicData_GetNatureAudioClip(ref AudioClip __result, TribeType type, SkinType skinType) { - AudioClip? audioClip = Registry.GetAudioClip("nature", Util.GetStyle(type, skinType)); + AudioClip? audioClip = Registry.GetAudioClip("nature", type, skinType); if (audioClip != null) { __result = audioClip; @@ -33,7 +32,7 @@ private static bool MusicData_GetNatureAudioClip(ref AudioClip __result, TribeTy [HarmonyPatch(typeof(MusicData), nameof(MusicData.GetMusicAudioClip))] private static bool MusicData_GetMusicAudioClip(ref AudioClip __result, TribeType type, SkinType skinType) { - AudioClip? audioClip = Registry.GetAudioClip("music", Util.GetStyle(type, skinType)); + AudioClip? audioClip = Registry.GetAudioClip("music", type, skinType); if (audioClip != null) { __result = audioClip; @@ -51,7 +50,7 @@ private static bool AudioSFXData_GetClip(ref AudioClip __result, SFXTypes id, Sk { AudioClip? audioClip = Registry.GetAudioClip( EnumCache.GetName(id), - EnumCache.GetName(skinType) + skin: skinType ); if (audioClip != null) { @@ -61,15 +60,43 @@ private static bool AudioSFXData_GetClip(ref AudioClip __result, SFXTypes id, Sk return true; } + private const string CLIP_CREATION_UNAVAILABLE = + "this game build cannot create audio clips at runtime." + + " AudioClip.Create marshals its arguments as spans, and" + + " Il2CppSystem.ReadOnlySpan.GetPinnableReference() is missing from its IL2CPP build." + + " Custom audio stays disabled until the game ships calling AudioClip.Create and SetData"; + + private static bool clipCreationUnavailable = false; + /// /// Builds an audio clip from raw byte data. /// - /// The byte data of the audio file. + /// The byte data of the audio file, which has to be a WAVE file. + /// The name to give the clip inside Unity. /// The created audio clip. - public static AudioClip BuildAudioClip(byte[] data) + /// The data is not a WAVE file that can be decoded. + /// The game build cannot create audio clips. + public static AudioClip BuildAudioClip(byte[] data, string name = "polymod_audio") { - // TODO: This is a placeholder. The actual implementation is tracked in issue #71. - return new AudioClip(new()); + WavDecoder.WavData wav = WavDecoder.Decode(data); + if (clipCreationUnavailable) + throw new NotSupportedException($"Cannot build {name}, {CLIP_CREATION_UNAVAILABLE}."); + + try + { + AudioClip clip = AudioClip.Create(name, wav.lengthSamples, wav.channels, wav.frequency, false); + if (clip == null) + throw new InvalidOperationException($"Unity returned no audio clip for {name}."); + if (!clip.SetData(wav.samples, 0)) + throw new InvalidOperationException($"Unity rejected the samples for {name}."); + return clip; + } + catch (MissingMethodException exception) + { + // Remembered so that a mod shipping many files reports this once rather than per file. + clipCreationUnavailable = true; + throw new NotSupportedException($"Cannot build {name}, {CLIP_CREATION_UNAVAILABLE}.", exception); + } } /// diff --git a/src/Managers/AutoUpdate.cs b/src/Managers/AutoUpdate.cs deleted file mode 100644 index 58ea639..0000000 --- a/src/Managers/AutoUpdate.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System.Diagnostics; -using System.IO.Compression; -using System.Text.Json; -using HarmonyLib; -using UnityEngine; - -namespace PolyMod.Managers; - -/// -/// Manages the automatic update process for PolyMod. -/// -internal static class AutoUpdate -{ - /// - /// Checks for updates when the start screen is shown. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnShow))] - private static void StartScreen_UI2_OnShow() - { - 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 - { - // Fetch release information from GitHub API - 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; - - // Determine the correct BepInEx URL for the current OS - 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); - - // The actual update logic - void Update() - { - Time.timeScale = 0; - - // Download new PolyMod DLL and BepInEx files - 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); - - // Create and run the appropriate update script for the OS - 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(); - } - - // Show a popup to the user asking if they want to update - PopupManager.GetBasicPopupWithData(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}"); - } - } - - /// - /// Initializes the AutoUpdate manager by patching the necessary methods. - /// - 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 1d55bb7..c2d2498 100644 --- a/src/Managers/Compatibility.cs +++ b/src/Managers/Compatibility.cs @@ -1,4 +1,5 @@ using HarmonyLib; +using Il2CppInterop.Runtime; using System.Text; using UnityEngine; using UnityEngine.EventSystems; @@ -20,6 +21,12 @@ internal static class Compatibility /// internal static bool shouldResetSettings = false; private static bool sawSignatureWarning; + private const string downloadLink = "https://polymod.dev/download/"; + + // TODO: rewrite all of this bullshit! each mod should have a checksum. and then using per mod checksum, we should calculate overall checksum + // why? launcher will let you install a mod if u dont have it. if u have a mod with this id, but it doesnt match any checksum of any ver of + // this mod on polymod.dev, then we just leave it as "install". if there is a new ver, we can simply put "update" + // also add a client mod check or whatever /// /// Hashes the signatures of all loaded mods to create a checksum. @@ -87,6 +94,36 @@ private static bool CheckSignatures(Action action, Il2CppSystem.Guid gameId) [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnShow))] private static void StartScreen_UI2_OnShow() { + if(!Plugin.ValidLaunch && Application.platform != RuntimePlatform.Android) + { + PopupManager.GetBasicPopupWithData( + new( + Localization.Get("polymod.title.invalid"), + Localization.Get("polymod.description.invalid"), + new PopupBase.PopupButtonData[] { + new( + "buttons.exitgame", + PopupBase.PopupButtonData.States.None, + callback: (Il2CppSystem.Action)Application.Quit, + closesPopup: false + ), + new( + "buttons.download.launcher", + PopupBase.PopupButtonData.States.None, + callback: DelegateSupport.ConvertDelegate(OpenSite), + closesPopup: false + ) + } + ) + ).Show(); + + void OpenSite() + { + NativeHelpers.OpenURL(downloadLink, false); + Application.Quit(); + } + return; + } string lastChecksum = checksum; try { diff --git a/src/Managers/Hub.cs b/src/Managers/Hub.cs index 5afdaf9..99db641 100644 --- a/src/Managers/Hub.cs +++ b/src/Managers/Hub.cs @@ -1,443 +1,388 @@ -using System.Text.Json; using Cpp2IL.Core.Extensions; using HarmonyLib; -using I2.Loc; using Il2CppInterop.Runtime; -using TMPro; using UnityEngine; -using UnityEngine.EventSystems; -using UnityEngine.UI; using static PopupBase; -using PolytopiaBackendBase.Common; -using System.Text.Encodings.Web; -using Il2CppSystem.Linq; -using System.Text.RegularExpressions; - namespace PolyMod.Managers; -/// -/// Manages the PolyMod hub, including UI elements and popups. -/// internal static class Hub { - private const string HEADER_PREFIX = ""; - private const string HEADER_POSTFIX = ""; - private const int POPUP_WIDTH = 1400; - private static UIRoundButton_UI2? polyModButton = null; - private static UIRoundButton_UI2? polyModVersion = null; - - /// - /// Whether the configuration popup is currently active. - /// - public static bool isConfigPopupActive = false; - - /// - /// Patches the splash screen to play a custom intro video. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(SplashController), nameof(SplashController.LoadAndPlayClip))] - private static bool SplashController_LoadAndPlayClip(SplashController __instance) - { - string name = "intro.mp4"; - string path = Path.Combine(Application.persistentDataPath, name); - File.WriteAllBytesAsync(path, Plugin.GetResource(name).ReadBytes()); - __instance.lastPlayTime = Time.realtimeSinceStartup; - __instance.videoPlayer.url = path; - __instance.videoPlayer.Play(); - return false; - } - - /// - /// Patches the popup button container to correctly anchor buttons. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(PopupButtonContainer), nameof(PopupButtonContainer.SetButtonData))] - private static void PopupButtonContainer_SetButtonData(PopupButtonContainer __instance) - { - int num = __instance.buttons.Length; - for (int i = 0; i < num; i++) - { - UITextButton uitextButton = __instance.buttons[i]; - Vector2 vector = new((num == 1) ? 0.5f : (i / (num - 1.0f)), 0.5f); - uitextButton.rectTransform.anchorMin = vector; - uitextButton.rectTransform.anchorMax = vector; - uitextButton.rectTransform.pivot = vector; - } - } - - [HarmonyPostfix] - [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.Init))] - private static void StartScreen_UI2_Init(StartScreen_UI2 __instance, RectTransform transform) - { - polyModButton = UILibrary.NewRoundButton(transform).SetStyle(UIButtonBase_UI2.ButtonStyle.Suggested); - polyModButton.bg.sprite = Visual.BuildSprite(Plugin.GetResource("polymod_icon.png").ReadBytes()); - polyModButton.OnClickedSignal.Add(DelegateSupport.ConvertDelegate(ShowPolyModHub)); - - polyModVersion = UILibrary.NewRoundButton(transform).SetStyle(UIButtonBase_UI2.ButtonStyle.Suggested); - polyModVersion.Text = $"PolyMod {Plugin.VERSION}"; - polyModVersion.titleTextField.textField.fontSize = 18f; - - if (Main.dependencyCycle) - { - BasicPopup popup = PopupManager.GetBasicPopupWithData( - new( - Localization.Get("polymod.cycle"), - Localization.Get("polymod.cycle.description"), - new PopupBase.PopupButtonData[] { - new( - "buttons.exitgame", - PopupBase.PopupButtonData.States.None, - (Il2CppSystem.Action)Application.Quit, - closesPopup: false, - customColorStates: ColorConstants.redButtonColorStates - ) - } - ) - ); - - popup.IsUnskippable = true; - popup.Show(); - } - - } - - /// - /// Patches the start screen to add the PolyMod hub button and version text. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RunLayout))] - private static void StartScreen_UI2_RunLayout(StartScreen_UI2 __instance, ScreenBase_UI2.ScreenSize screenSize) - { - if (polyModButton == null) - { - Plugin.logger.LogWarning("PolyMod Hub button is null when running layout!"); - return; - } - polyModButton.iconContainer.gameObject.SetActive(false); - polyModButton.outline.gameObject.SetActive(false); - polyModButton.bg.color = Color.white; - polyModButton.Text = Localization.Get("polymod.hub"); - float num = 50f; - polyModButton.SetPosition(screenSize.safeRect.Right - (num * 2.5f), screenSize.safeRect.Top - num); - - if (polyModVersion == null) - { - Plugin.logger.LogWarning("PolyMod Version is null when running layout!"); - return; - } - polyModVersion.iconContainer.gameObject.SetActive(false); - polyModVersion.outline.gameObject.SetActive(false); - polyModVersion.bg.gameObject.SetActive(false); - polyModVersion.SetPosition(screenSize.safeRect.Left + num * 1.15f, screenSize.safeRect.Bottom + num * 1.15f); - } - - /// - /// Patches the game manager to handle key presses for the config popup. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(GameManager), nameof(GameManager.Update))] - private static void GameManager_Update() - { - if (Input.GetKey(KeyCode.LeftControl) && Input.GetKeyDown(KeyCode.Tab) && !isConfigPopupActive) - { - ShowConfigPopup(); - } - } - - /// - /// Updates sprite information from dumped files. - /// - 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); - } - - /// - /// Dump all data. - /// - internal static void DumpData() - { - Directory.CreateDirectory(Plugin.DUMPED_DATA_PATH); - Directory.CreateDirectory(Plugin.LOGIC_DUMP_PATH); - Directory.CreateDirectory(Plugin.LOCALIZATION_DUMP_PATH); - File.WriteAllTextAsync( - Path.Combine(Plugin.LOGIC_DUMP_PATH, "gameLogicData.json"), - PolytopiaDataManager.provider.LoadGameLogicData(VersionManager.GameLogicDataVersion) - ); - File.WriteAllTextAsync( - Path.Combine(Plugin.LOGIC_DUMP_PATH, "avatarData.json"), - PolytopiaDataManager.provider.LoadAvatarData(1337) - ); - var source = LocalizationManager.Sources[0]; - foreach (var language in source.GetLanguages()) - { - int languageIndex = source.GetLanguageIndex(language); - var dict = new Dictionary(); - - foreach (var term in source.mTerms) - { - var translation = term.GetTranslation(languageIndex); - - if (!string.IsNullOrEmpty(translation)) - dict[term.Term] = translation; - } - var options = new JsonSerializerOptions - { - WriteIndented = true, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - - string json = JsonSerializer.Serialize(dict, options); - - File.WriteAllText( - Path.Combine(Plugin.LOCALIZATION_DUMP_PATH, $"language_{source.mLanguages[languageIndex].Code}.json"), - json - ); - } - foreach (var category in source.GetCategories()) - File.WriteAllTextAsync( - Path.Combine(Plugin.LOCALIZATION_DUMP_PATH, $"localization_{category}.csv"), - source.Export_CSV(category) - ); - foreach (KeyValuePair entry in Registry.mods) - { - foreach (Mod.File file in entry.Value.files) - { - Match spritesMatch = Regex.Match(Path.GetFileName(file.name), @"^sprites(?:_(.*))?\.json$"); - if (spritesMatch.Success) - File.WriteAllBytes( - Path.Combine( - Plugin.DUMPED_DATA_PATH, - $"{Path.GetFileNameWithoutExtension(file.name)}_{entry.Key}.json" - ), - file.bytes - ); - } - } - foreach (TribeType type in Enum.GetValues(typeof(TribeType))) - { - 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")); - } - - /// - /// Shows the configuration popup. - /// - internal static void ShowConfigPopup() - { - BasicPopupLegacy polymodPopup = Visual.GetBasicPopupLegacy(); - polymodPopup.Header = Localization.Get("polymod.hub.config"); - polymodPopup.Description = ""; - polymodPopup.buttonData = CreateConfigPopupButtonData(); - polymodPopup.ShowSetWidth(POPUP_WIDTH); - } - - internal static void ShowPolyModHub() - { - BasicPopupLegacy popup = Visual.GetBasicPopupLegacy(); - popup.Header = Localization.Get("polymod.hub"); - popup.Description = Localization.Get("polymod.hub.header", new Il2CppSystem.Object[] { - HEADER_PREFIX, - HEADER_POSTFIX - }) + "\n\n"; - foreach (var mod in Registry.mods.Values) - { - popup.Description += Localization.Get("polymod.hub.mod", new Il2CppSystem.Object[] { - mod.name, - Localization.Get("polymod.hub.mod.status." - + Enum.GetName(typeof(Mod.Status), mod.status)!.ToLower()), - string.Join(", ", mod.authors), - mod.version.ToString(), - mod.description ?? "" - }); - popup.Description += "\n\n"; - } - popup.Description += Localization.Get("polymod.hub.footer", new Il2CppSystem.Object[] { - HEADER_PREFIX, - HEADER_POSTFIX - }); - - void OpenDiscord() - { - NativeHelpers.OpenURL(Plugin.DISCORD_LINK, false); - } - - List popupButtons = new() - { - new("buttons.back"), - new( - "polymod.hub.discord", - callback: DelegateSupport.ConvertDelegate(OpenDiscord) - ), - new( - "polymod.hub.config", - callback: DelegateSupport.ConvertDelegate(ShowConfigPopup) - ) - }; - if (Plugin.config.debug) - { - popupButtons.Add(new( - "polymod.hub.dump", - callback: DelegateSupport.ConvertDelegate(DumpData), - closesPopup: false - )); - popupButtons.Add(new( - "polymod.hub.spriteinfo.update", - callback: DelegateSupport.ConvertDelegate(UpdateSpriteInfos), - closesPopup: false - )); - } - popup.buttonData = popupButtons.ToArray(); - popup.ShowSetWidth(POPUP_WIDTH); - } - - /// - /// Creates the button data for the configuration popup. - /// - /// An array of popup button data. - internal static PopupButtonData[] CreateConfigPopupButtonData() - { - List popupButtons = new() - { - new(Localization.Get("buttons.back"), PopupButtonData.States.None, DelegateSupport.ConvertDelegate(OnBackButtonClicked), -1, true, null) - }; - - if (GameManager.Instance.isLevelLoaded) - { - popupButtons.Add(new PopupButtonData(Localization.Get("polymod.hub.spriteinfo.update"), PopupButtonData.States.None, DelegateSupport.ConvertDelegate(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, DelegateSupport.ConvertDelegate(OnDebugButtonClicked), -1, true, null)); - popupButtons.Add(new PopupButtonData(autoUpdateButtonName, PopupButtonData.States.None, DelegateSupport.ConvertDelegate(OnAutoUpdateButtonClicked), -1, true, null)); - popupButtons.Add(new PopupButtonData(includeAlphasButtonName, Plugin.config.autoUpdate ? PopupButtonData.States.None : PopupButtonData.States.Disabled, DelegateSupport.ConvertDelegate(OnIncludeAlphasButtonClicked), -1, true, null)); - } - return popupButtons.ToArray(); - - void OnDebugButtonClicked() - { - 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() - { - 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() - { - 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() - { - UpdateSpriteInfos(); - isConfigPopupActive = false; - } - - void OnBackButtonClicked() - { - isConfigPopupActive = false; - } - } - - /// - /// Initializes the Hub manager by patching the necessary methods. - /// - internal static void Init() - { - Harmony.CreateAndPatchAll(typeof(Hub)); - } + private record Setting( + string buttonKey, + string nameKey, + string descriptionKey, + Func isEnabled, + Action toggle + ); + + private const string HEADER_PREFIX = ""; + private const string HEADER_POSTFIX = ""; + + private const string COLOR_MUTED = "#9AA0A6"; + private const string COLOR_ENABLED = "#4CC76A"; + private const string COLOR_DISABLED = "#E36A5B"; + private const string COLOR_WARNING = "#E0A52E"; + private const float HUB_POPUP_WIDTH_SHARE = 0.78f; + private const float HUB_POPUP_MAX_WIDTH = 1400f; + private const float SETTINGS_POPUP_WIDTH_SHARE = 0.5f; + private const float SETTINGS_POPUP_MAX_WIDTH = 900f; + private const float START_SCREEN_MARGIN = 50f; + private const float VERSION_LABEL_FONT_SIZE = 18f; + private const float VERSION_LABEL_GAP = 10f; + /// Margin the game keeps between the safe area and its own corner widgets. + private const float VERSION_LABEL_MARGIN = 15f; + private const string INTRO = "intro.mp4"; + + private const KeyCode HOTKEY_MODIFIER = KeyCode.LeftControl; + private const KeyCode HOTKEY_MODIFIER_ALTERNATIVE = KeyCode.RightControl; + private const KeyCode HOTKEY = KeyCode.Tab; + + private static readonly Setting[] SETTINGS = new Setting[] + { + new( + "polymod.hub.config.debug", + "polymod.debug", + "polymod.debug.description", + () => Plugin.config.debug, + () => + { + Plugin.config = Plugin.config with { debug = !Plugin.config.debug }; + Plugin.UpdateConsole(); + } + ), + new( + "polymod.hub.config.coordinates", + "polymod.coordinates", + "polymod.coordinates.description", + () => Plugin.config.showCoordinates, + () => Plugin.config = Plugin.config with { showCoordinates = !Plugin.config.showCoordinates } + ), + }; + + private static UIRoundButton_UI2? polyModButton = null; + private static NotificationBadge? brokenModsBadge = null; + private static TextField_UI2? polyModVersion = null; + + private static PopupBase? openPopup = null; + + public static bool IsPopupOpen + { + get + { + if (openPopup != null) + { + try + { + if (openPopup.IsShowing()) return true; + } + catch (Exception) {} + openPopup = null; + } + return false; + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(SplashController), nameof(SplashController.LoadAndPlayClip))] + private static bool SplashController_LoadAndPlayClip(SplashController __instance) + { + string path = Path.Combine(Application.persistentDataPath, INTRO); + File.WriteAllBytesAsync(path, Plugin.GetResource(INTRO).ReadBytes()); + __instance.lastPlayTime = Time.realtimeSinceStartup; + __instance.videoPlayer.url = path; + __instance.videoPlayer.Play(); + return false; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.Init))] + private static void StartScreen_UI2_Init(RectTransform transform) + { + polyModButton = UILibrary.NewRoundButton(transform).SetStyle(UIButtonBase_UI2.ButtonStyle.Suggested); + polyModButton.bg.sprite = Visual.BuildSprite(Plugin.GetResource("polymod_icon.png").ReadBytes()); + polyModButton.OnClickedSignal.Add(Callback(ShowPolyModHub)); + + brokenModsBadge = UILibrary.NewNotificationBadge(polyModButton.rectTransform).SetValue(CountBrokenMods()); + polyModButton.SetOverlay(brokenModsBadge, UIButtonBase_UI2.OverlayPosition.TopRight); + + // NewHeaderTinyText is the prefab a round button builds its own title from, so the label + // keeps the game's header font rather than the lighter body font NewText would give it. + polyModVersion = UILibrary.NewHeaderTinyText(transform); + polyModVersion.gameObject.name = "PolyModVersion"; + polyModVersion.textField.fontSize = VERSION_LABEL_FONT_SIZE; + polyModVersion.textField.color = Color.white; + polyModVersion.Text = $"PolyMod {Plugin.VERSION}"; + + if (Main.dependencyCycle) + ShowDependencyCyclePopup(); + } + + /// + /// Patches the start screen layout to place the PolyMod hub button and the version label. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RunLayout))] + private static void StartScreen_UI2_RunLayout(StartScreen_UI2 __instance, ScreenBase_UI2.ScreenSize screenSize) + { + if (polyModButton == null || polyModVersion == null) + { + Plugin.logger.LogWarning("PolyMod start screen widgets are missing, skipping their layout."); + return; + } + + polyModButton.iconContainer.gameObject.SetActive(false); + polyModButton.outline.gameObject.SetActive(false); + polyModButton.bg.color = Color.white; + // Setting the text re-runs the button's own layout, which also repositions the badge. + polyModButton.Text = Localization.Get("polymod.hub"); + polyModButton.SetPosition( + screenSize.safeRect.Right - START_SCREEN_MARGIN * 2.5f, + screenSize.safeRect.Top - START_SCREEN_MARGIN + ); + brokenModsBadge?.SetValue(CountBrokenMods()); + + // Anchored by its edges, not its centre, so a longer version string cannot run off screen. + float versionX = screenSize.safeRect.Left + VERSION_LABEL_MARGIN + polyModVersion.GetHalfWidth(); + float versionY = screenSize.safeRect.Bottom + VERSION_LABEL_MARGIN + polyModVersion.GetHalfHeight(); + polyModVersion.SetPosition(versionX, versionY); + + // The bottom row of buttons is centred and of fixed width, so a narrow window walks it into + // the version label. The row is already laid out by now, so lift the label clear of it. + UIRoundButton_UI2 bottomRowStart = __instance.settingsButton; + if (bottomRowStart != null + && polyModVersion.GetRight() + VERSION_LABEL_GAP > bottomRowStart.GetLeft()) + { + polyModVersion.SetPosition( + versionX, + bottomRowStart.GetTop() + polyModVersion.GetHalfHeight() + VERSION_LABEL_GAP + ); + } + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(GameManager), nameof(GameManager.Update))] + private static void GameManager_Update() + { + bool modifierHeld = Input.GetKey(HOTKEY_MODIFIER) || Input.GetKey(HOTKEY_MODIFIER_ALTERNATIVE); + if (modifierHeld && Input.GetKeyDown(HOTKEY) && !IsPopupOpen) + ShowPolyModHub(); + } + + internal static void ShowPolyModHub() + { + BasicPopupLegacy popup = Visual.GetBasicPopupLegacy(); + popup.Header = Localization.Get("polymod.hub"); + popup.Description = string.Join( + "\n\n", + Localization.Get("polymod.hub.header", Args(HEADER_PREFIX, HEADER_POSTFIX)), + BuildModList(), + Localization.Get("polymod.hub.footer", Args(HEADER_PREFIX, HEADER_POSTFIX)) + ); + + void OpenDiscord() => NativeHelpers.OpenURL(Plugin.DISCORD_LINK, false); + + List buttons = new() + { + new("buttons.back"), + new("polymod.hub.discord", callback: Callback(OpenDiscord)), + new("polymod.hub.config", callback: Callback(ShowConfigPopup)), + }; + if (Plugin.config.debug) + { + buttons.Add(new("polymod.hub.dump", callback: Callback(DumpData), closesPopup: false)); + buttons.Add(new("polymod.hub.hotreload", callback: Callback(HotReload), closesPopup: false)); + } + + popup.buttonData = buttons.ToArray(); + openPopup = popup; + popup.ShowSetWidth(HUB_POPUP_WIDTH_SHARE, HUB_POPUP_MAX_WIDTH); + } + + internal static void ShowConfigPopup() + { + BasicPopupLegacy popup = Visual.GetBasicPopupLegacy(); + popup.Header = Localization.Get("polymod.hub.config"); + popup.Description = BuildSettingsList(); + + List buttons = new() + { + new("buttons.back", callback: Callback(ShowPolyModHub)), + }; + foreach (Setting setting in SETTINGS) + { + void OnSettingClicked() + { + setting.toggle(); + Plugin.WriteConfig(); + popup.Description = BuildSettingsList(); + } + buttons.Add(new(setting.buttonKey, callback: Callback(OnSettingClicked), closesPopup: false)); + } + + popup.buttonData = buttons.ToArray(); + openPopup = popup; + popup.ShowSetWidth(SETTINGS_POPUP_WIDTH_SHARE, SETTINGS_POPUP_MAX_WIDTH); + } + + private static void ShowDependencyCyclePopup() + { + BasicPopup popup = PopupManager.GetBasicPopupWithData( + new( + Localization.Get("polymod.cycle"), + Localization.Get("polymod.cycle.description"), + new PopupButtonData[] + { + new( + "buttons.exitgame", + PopupButtonData.States.None, + (Il2CppSystem.Action)Application.Quit, + closesPopup: false, + customColorStates: ColorConstants.redButtonColorStates + ), + } + ) + ); + popup.IsUnskippable = true; + popup.Show(); + } + + private static void DumpData() + { + Dumper.DumpAll(); + string path = Dumper.RootPath; + + BasicPopup popup = PopupManager.GetBasicPopup(); + popup.Header = Localization.Get("polymod.hub.dump"); + popup.Description = Localization.Get("polymod.hub.dumped", Args(path)); + + void OpenDumpFolder() + { + try + { + Application.OpenURL(new Uri(path).AbsoluteUri); + } + catch (Exception exception) + { + Plugin.logger.LogError($"Could not open the dump folder at {path}: {exception}"); + } + } + + popup.buttonData = new PopupButtonData[] + { + new("polymod.hub.openfolder", callback: Callback(OpenDumpFolder)), + new("buttons.ok"), + }; + popup.Show(); + } + + private static void HotReload() + { + BasicPopup popup = PopupManager.GetBasicPopup(); + popup.Header = Localization.Get("polymod.hub.hotreload"); + popup.Description = Localization.Get("polymod.hotreload.unavailable"); + popup.SetDefaultOkbutton(); + popup.Show(); + } + + private static string BuildModList() + { + if (Registry.mods.Count == 0) + return Muted(Localization.Get("polymod.hub.mods.none")); + + int loaded = 0; + foreach (Mod mod in Registry.mods.Values) + { + if (mod.status == Mod.Status.Success) loaded++; + } + + List blocks = new() + { + Muted(Localization.Get( + "polymod.hub.summary", + Args(loaded.ToString(), Registry.mods.Count.ToString()) + )), + }; + foreach (Mod mod in Registry.mods.Values) + { + blocks.Add(BuildModEntry(mod)); + } + return string.Join("\n\n", blocks); + } + + private static string BuildModEntry(Mod mod) + { + string status = Localization.Get( + "polymod.hub.mod.status." + Enum.GetName(mod.status)!.ToLowerInvariant() + ); + string title = $"{mod.name} " + Muted($"v{mod.version}"); + string meta = $"{status}"; + if (mod.authors.Length > 0) + { + string authors = Localization.Get( + "polymod.hub.mod.authors", + Args(string.Join(", ", mod.authors)) + ); + meta += " " + Muted($"{authors}"); + } + + List lines = new() { title, meta }; + if (!string.IsNullOrWhiteSpace(mod.description)) + lines.Add($"{mod.description}"); + + return string.Join("\n", lines); + } + + private static string BuildSettingsList() + { + List blocks = new(); + foreach (Setting setting in SETTINGS) + { + bool enabled = setting.isEnabled(); + string state = Localization.Get(enabled ? "polymod.on" : "polymod.off"); + string color = enabled ? COLOR_ENABLED : COLOR_DISABLED; + blocks.Add( + $"{Localization.Get(setting.nameKey)} {state}\n" + + Muted($"{Localization.Get(setting.descriptionKey)}") + ); + } + return string.Join("\n\n", blocks); + } + + private static string Muted(string text) => $"{text}"; + + private static string StatusColor(Mod.Status status) => status switch + { + Mod.Status.Success => COLOR_ENABLED, + Mod.Status.DependenciesUnsatisfied => COLOR_WARNING, + _ => COLOR_DISABLED, + }; + + private static int CountBrokenMods() + { + int broken = 0; + foreach (Mod mod in Registry.mods.Values) + { + if (mod.status != Mod.Status.Success) broken++; + } + return broken; + } + + private static Il2CppSystem.Action Callback(Action callback) + => DelegateSupport.ConvertDelegate(callback)!; + + private static Il2CppSystem.Object[] Args(params string[] values) + { + Il2CppSystem.Object[] arguments = new Il2CppSystem.Object[values.Length]; + for (int i = 0; i < values.Length; i++) + { + arguments[i] = values[i]; + } + return arguments; + } + + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(Hub)); + } } diff --git a/src/Managers/Main.cs b/src/Managers/Main.cs index f271e77..188544b 100644 --- a/src/Managers/Main.cs +++ b/src/Managers/Main.cs @@ -17,432 +17,474 @@ namespace PolyMod.Managers; /// public static class Main { - internal static bool dependencyCycle; - - /// - /// The maximum tier for technology, used to extend the tech tree. - /// - internal const int MAX_TECH_TIER = 100; - - /// - /// A stopwatch to measure the time taken to load mods. - /// - internal static readonly Stopwatch stopwatch = new(); - - /// - /// Whether the mod has been fully initialized. - /// - internal static bool fullyInitialized; - - /// - /// A dictionary mapping unit IDs to the IDs of the units they embark into. - /// - internal static Dictionary embarkNames = new(); - - /// - /// A dictionary mapping unit types to the types of the units they embark into. - /// - internal static Dictionary embarkOverrides = new(); - - /// - /// Whether an embark action is currently being executed. - /// - internal static bool currentlyEmbarking = false; - - /// - /// A dictionary mapping improvement IDs to the IDs of the resources they attract. - /// - internal static Dictionary attractsResourceNames = new(); - - /// - /// A dictionary mapping improvement IDs to the IDs of the terrain types they attract resources on. - /// - internal static Dictionary attractsTerrainNames = new(); - - /// - /// A dictionary mapping improvement types to the types of the resources they attract. - /// - internal static Dictionary attractsResourceOverrides = new(); - - /// - /// A dictionary mapping improvement types to the types of the terrain they attract resources on. - /// - internal static Dictionary attractsTerrainOverrides = new(); - - /// - /// Patches the game logic data parsing to load PolyMod content. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(GameLogicData), nameof(GameLogicData.AddGameLogicPlaceholders))] - private static void GameLogicData_AddGameLogicPlaceholders(GameLogicData __instance, ref JObject rootObject) - { - if (!fullyInitialized) - { - Load(__instance, rootObject); - fullyInitialized = true; - } - } - - /// - /// Patches the purchase manager to unlock custom skins. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.IsSkinUnlocked))] - private static bool PurchaseManager_IsSkinUnlockedInternal(ref bool __result, SkinType skinType) - { - __result = (int)skinType >= Plugin.AUTOIDX_STARTS_FROM && skinType != SkinType.Test; - return !__result; - } - - /// - /// Patches the purchase manager to unlock custom tribes. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.IsTribeUnlocked))] - private static void PurchaseManager_IsTribeUnlocked(ref bool __result, TribeType type) - { - __result = (int)type >= Plugin.AUTOIDX_STARTS_FROM || __result; - } - - /// - /// Patches the steam purchase manager to unlock custom content. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(SteamPlatformPurchaseManager), nameof(SteamPlatformPurchaseManager.IsProductUnlocked))] - private static bool SteamPlatformPurchaseManager_IsProductUnlocked(ref bool __result, IAPProduct iapProduct) - { - __result = iapProduct == null; - return iapProduct != null; - } - - /// - /// Patches the purchase manager to add custom tribes to the list of unlocked tribes. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.GetUnlockedTribes))] - private static void PurchaseManager_GetUnlockedTribes( - ref Il2CppSystem.Collections.Generic.List __result, - bool forceUpdate = false - ) - { - foreach (var tribe in Registry.customTribes) __result.Add(tribe); - } - - /// - /// Patches the Unity log callback to filter out spammy messages. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(IL2CPPUnityLogSource), nameof(IL2CPPUnityLogSource.UnityLogCallback))] - private static bool IL2CPPUnityLogSource_UnityLogCallback(string logLine, string exception, LogType type) - { - foreach (string stringToIgnore in Plugin.LOG_MESSAGES_IGNORE) - { - if (logLine.Contains(stringToIgnore)) - return false; - } - return true; - } - - /// - /// Patches the game mode screen to add custom game modes. - /// - [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); - Sprite? sprite = item.sprite; - if (item.sprite == null && item.spriteName != null) - { - sprite = Registry.GetSprite(item.spriteName); - } - Loader.gamemodes[i] = new Loader.GameModeButtonsInformation(item.gameModeIndex, item.action, __instance.buttons.Length, sprite, item.spriteName); - } - - 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); - } - } - } - } - - /// - /// Patches the tech view to correctly display the tech tree with custom technologies. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(TechView), nameof(TechView.CreateNode))] - public static bool TechView_CreateNode(TechView __instance, TechData data, TechItem parentItem, float angle) - { - // This patch is a reimplementation of the original CreateNode method to fix layout issues with custom techs. - // It recalculates the angles for each node to ensure they are displayed correctly. - 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; - } - - /// - /// Patches the embark action to set a flag indicating that an embark is in progress. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(EmbarkAction), nameof(EmbarkAction.Execute))] - private static bool EmbarkAction_Execute_Prefix(EmbarkAction __instance, GameState gameState) - { - currentlyEmbarking = true; - return true; - } - - /// - /// Patches the unit training method to handle custom embark units. - /// - [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 || 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; - } - - /// - /// Patches the start turn action to handle the 'Attract' ability for custom improvements. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(StartTurnAction), nameof(StartTurnAction.Execute))] - private static void StartTurnAction_Execute(StartTurnAction __instance, GameState state) - { - // Clear any existing CreateResource actions to prevent duplicates. - for (int i = state.ActionStack.Count - 1; i >= 0; i--) - { - if (state.ActionStack[i].GetActionType() == ActionType.CreateResource) - { - state.ActionStack.RemoveAt(i); - } - } - - // Iterate through all tiles owned by the current player. - 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 the improvement has the 'Attract' ability and is ready to spawn a resource. - 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; - } - - // Find a valid tile to spawn the resource on. - 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; - } - } - } - } - } - } - } - - /// - /// Patches the unit creation method to handle custom unit prefabs. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(Unit), nameof(Unit.CreateUnit))] - private static bool Unit_CreateUnit(Unit __instance, UnitData unitData, TribeType 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; - } - - /// - /// Initializes the Main manager. - /// - internal static void Init() - { - stopwatch.Start(); - Harmony.CreateAndPatchAll(typeof(Main)); - Mod.Manifest polytopia = new( - "polytopia", - "The Battle of Polytopia", - null, - new(Application.version.ToString()), - new string[] { "Midjiwan AB" }, - Array.Empty() - ); - Registry.mods.Add(polytopia.id, new(polytopia, Mod.Status.Success, new())); - Loader.RegisterMods(Registry.mods); - Loader.LoadMods(Registry.mods, out var cycle); - dependencyCycle = cycle; - stopwatch.Stop(); - } - - /// - /// Loads all mod content. - /// - /// The game logic data to patch. - /// The JSON object representing the game logic data. - internal static void Load(GameLogicData gameLogicData, JObject json) - { - stopwatch.Start(); - Loc.BuildAndLoadLocalization( - JsonSerializer.Deserialize>>( - Plugin.GetResource("localization.json") - )! - ); - if (dependencyCycle) return; - - foreach (var (id, mod) in Registry.mods) - { - if (mod.status != Mod.Status.Success) continue; - foreach (var file in mod.files) - { - if (mod.status != Mod.Status.Success) break; - Match localizationMatch = Regex.Match(Path.GetFileName(file.name), @"^localization(_.*)?\.json$"); - if (localizationMatch.Success) - { - Loader.LoadLocalizationFile(mod, file); - continue; - } - Match patchMatch = Regex.Match(Path.GetFileName(file.name), @"^patch(_.*)?\.json$"); - if (patchMatch.Success) - { - var patchText = new StreamReader(new MemoryStream(file.bytes)).ReadToEnd(); - var template = new Api.GldConfigTemplate(patchText, mod.id); - var text = template.Render(); - if (text is null) - { - mod.status = Mod.Status.Error; - continue; - } - Loader.LoadGameLogicDataPatch( - mod, - json, - JObject.Parse(text) - ); - continue; - } - Match prefabMatch = Regex.Match(Path.GetFileName(file.name), @"^prefab(?:_(.*))?\.json$"); - if (prefabMatch.Success) - { - Loader.LoadPrefabInfoFile( - mod, - file - ); - continue; - } - - switch (Path.GetExtension(file.name)) - { - case ".png": - Loader.LoadSpriteFile(mod, file); - break; - case ".wav": - Loader.LoadAudioFile(mod, file); - break; - case ".bundle": - Loader.LoadAssetBundle(mod, file); - break; - } - } - } - TechItem.techTierFirebaseId.Clear(); - for (int i = 0; i <= MAX_TECH_TIER; i++) - { - TechItem.techTierFirebaseId.Add($"tech_research_{i}"); - } - Loader.ProcessGameLogicData(gameLogicData, json); - stopwatch.Stop(); - Plugin.logger.LogInfo($"Loaded all mods in {stopwatch.ElapsedMilliseconds}ms"); - } + internal static bool dependencyCycle; + + /// + /// The maximum tier for technology, used to extend the tech tree. + /// + internal const int MAX_TECH_TIER = 100; + + /// + /// A stopwatch to measure the time taken to load mods. + /// + internal static readonly Stopwatch stopwatch = new(); + + /// + /// Whether the mod has been fully initialized. + /// + internal static bool fullyInitialized; + + /// + /// A dictionary mapping unit IDs to the IDs of the units they embark into. + /// + internal static Dictionary embarkNames = new(); + + /// + /// A dictionary mapping unit types to the types of the units they embark into. + /// + internal static Dictionary embarkOverrides = new(); + + /// + /// Whether an embark action is currently being executed. + /// + internal static bool currentlyEmbarking = false; + + /// + /// A dictionary mapping improvement IDs to the IDs of the resources they attract. + /// + internal static Dictionary attractsResourceNames = new(); + + /// + /// A dictionary mapping improvement IDs to the IDs of the terrain types they attract resources on. + /// + internal static Dictionary attractsTerrainNames = new(); + + /// + /// A dictionary mapping improvement types to the types of the resources they attract. + /// + internal static Dictionary attractsResourceOverrides = new(); + + /// + /// A dictionary mapping improvement types to the types of the terrain they attract resources on. + /// + internal static Dictionary attractsTerrainOverrides = new(); + + /// + /// Patches the game logic data parsing to load PolyMod content. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(GameLogicData), nameof(GameLogicData.AddGameLogicPlaceholders))] + private static void GameLogicData_AddGameLogicPlaceholders(GameLogicData __instance, ref JObject rootObject) + { + if (!fullyInitialized) + { + Load(__instance, rootObject); + fullyInitialized = true; + } + } + + /// + /// Patches the purchase manager to unlock custom skins. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.IsSkinUnlocked))] + private static bool PurchaseManager_IsSkinUnlockedInternal(ref bool __result, SkinType skinType) + { + __result = (int)skinType >= Plugin.AUTOIDX_STARTS_FROM && skinType != SkinType.Test; + return !__result; + } + + /// + /// Patches the purchase manager to unlock custom tribes. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.IsTribeUnlocked))] + private static void PurchaseManager_IsTribeUnlocked(ref bool __result, TribeType type) + { + __result = (int)type >= Plugin.AUTOIDX_STARTS_FROM || __result; + } + + /// + /// Patches the steam purchase manager to unlock custom content. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(SteamPlatformPurchaseManager), nameof(SteamPlatformPurchaseManager.IsProductUnlocked))] + private static bool SteamPlatformPurchaseManager_IsProductUnlocked(ref bool __result, IAPProduct iapProduct) + { + __result = iapProduct == null; + return iapProduct != null; + } + + /// + /// Patches the purchase manager to add custom tribes to the list of unlocked tribes. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.GetUnlockedTribes))] + private static void PurchaseManager_GetUnlockedTribes( + ref Il2CppSystem.Collections.Generic.List __result, + bool forceUpdate = false + ) + { + foreach (var tribe in Registry.customTribes) __result.Add(tribe); + } + + /// + /// Patches the Unity log callback to filter out spammy messages. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(IL2CPPUnityLogSource), nameof(IL2CPPUnityLogSource.UnityLogCallback))] + private static bool IL2CPPUnityLogSource_UnityLogCallback(string logLine, string exception, LogType type) + { + foreach (string stringToIgnore in Plugin.LOG_MESSAGES_IGNORE) + { + if (logLine.Contains(stringToIgnore)) + return false; + } + return true; + } + + /// + /// Patches the game mode screen to add custom game modes. + /// + [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); + Sprite? sprite = item.sprite; + if (item.sprite == null && item.spriteName != null) + { + sprite = Registry.GetSprite(item.spriteName); + } + Loader.gamemodes[i] = new Loader.GameModeButtonsInformation(item.gameModeIndex, item.action, __instance.buttons.Length, sprite, item.spriteName); + } + + 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); + } + } + } + } + + /// + /// Patches the tech view to correctly display the tech tree with custom technologies. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(TechView), nameof(TechView.CreateNode))] + public static bool TechView_CreateNode(TechView __instance, TechData data, TechItem parentItem, float angle) + { + // This patch is a reimplementation of the original CreateNode method to fix layout issues with custom techs. + // It recalculates the angles for each node to ensure they are displayed correctly. + 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; + } + + /// + /// Patches the embark action to set a flag indicating that an embark is in progress. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(EmbarkAction), nameof(EmbarkAction.Execute))] + private static bool EmbarkAction_Execute_Prefix(EmbarkAction __instance, GameState gameState) + { + currentlyEmbarking = true; + return true; + } + + /// + /// Patches the unit training method to handle custom embark units. + /// + [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 || 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; + } + + /// + /// Patches the start turn action to handle the 'Attract' ability for custom improvements. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(StartTurnAction), nameof(StartTurnAction.Execute))] + private static void StartTurnAction_Execute(StartTurnAction __instance, GameState state) + { + // Clear any existing CreateResource actions to prevent duplicates. + for (int i = state.ActionStack.Count - 1; i >= 0; i--) + { + if (state.ActionStack[i].GetActionType() == ActionType.CreateResource) + { + state.ActionStack.RemoveAt(i); + } + } + + // Iterate through all tiles owned by the current player. + 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 the improvement has the 'Attract' ability and is ready to spawn a resource. + 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; + } + + // Find a valid tile to spawn the resource on. + 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; + } + } + } + } + } + } + } + + /// + /// Serialization fixes for custom (modded) content. + /// GamePlayerSummary stores the tribe as a single byte, which custom tribe ids (>= 1000) overflow into garbage. + /// Instead the tribe is clamped to None before the untouched native serializer runs and menu summaries show a generic icon for custom tribes, nothing more. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(GamePlayerSummary), nameof(GamePlayerSummary.Serialize))] + private static bool GamePlayerSummary_Serialize(GamePlayerSummary __instance) + { + if ((int)__instance.TribeType >= byte.MaxValue) + { + __instance.TribeType = TribeType.None; + } + + return true; + } + + /// + /// Serialization fixes for custom (modded) content. + /// PlayerState stores the play climate as a single byte, which custom climate ids (>= 1000) overflow into garbage. + /// Instead the climate is overriden by the tribe value, as those are effectively the same. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(PlayerState), nameof(PlayerState.Deserialize))] + private static void PlayerState_Deserialize(PlayerState __instance, Il2CppSystem.IO.BinaryReader reader, int version) + { + if ((int)__instance.tribe >= Plugin.AUTOIDX_STARTS_FROM) + { + __instance.climate = __instance.tribe; + } + } + + /// + /// Initializes the Main manager. + /// + internal static void Init() + { + stopwatch.Start(); + Harmony.CreateAndPatchAll(typeof(Main)); + Mod.Manifest polytopia = new( + "polytopia", + "The Battle of Polytopia", + null, + new(Application.version.ToString()), + new string[] { "Midjiwan AB" }, + Array.Empty() + ); + Registry.mods.Add(polytopia.id, new(polytopia, Mod.Status.Success, new())); + Loader.RegisterMods(Registry.mods); + Loader.LoadMods(Registry.mods, out var cycle); + dependencyCycle = cycle; + stopwatch.Stop(); + } + + /// + /// Loads all mod content. + /// + /// The game logic data to patch. + /// The JSON object representing the game logic data. + internal static void Load(GameLogicData gameLogicData, JObject json) + { + stopwatch.Start(); + Loc.BuildAndLoadLocalization( + JsonSerializer.Deserialize>>( + Plugin.GetResource("localization.json") + )! + ); + if (dependencyCycle) return; + + foreach (var (id, mod) in Registry.mods) + { + if (mod.status != Mod.Status.Success) continue; + foreach (var file in mod.files) + { + if (mod.status != Mod.Status.Success) break; + Match localizationMatch = Regex.Match(Path.GetFileName(file.name), @"^localization(_.*)?\.json$"); + if (localizationMatch.Success) + { + Loader.LoadLocalizationFile(mod, file); + continue; + } + Match patchMatch = Regex.Match(Path.GetFileName(file.name), @"^patch(_.*)?\.json$"); + if (patchMatch.Success) + { + var patchText = new StreamReader(new MemoryStream(file.bytes)).ReadToEnd(); + var template = new Api.GldConfigTemplate(patchText, mod.id); + var text = template.Render(); + if (text is null) + { + mod.status = Mod.Status.Error; + continue; + } + Loader.LoadGameLogicDataPatch( + mod, + json, + JObject.Parse(text) + ); + continue; + } + + Match prefabsMatch = Regex.Match(Path.GetFileName(file.name), @"^prefabs(?:_(.*))?\.json$"); + if (prefabsMatch.Success) + { + Registry.filesQueueToBeProcessedAfterGLDMerge.Add(mod, file); + continue; + } + + Match unitPrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^unitPrefab(?:_(.*))?\.json$"); + if (unitPrefabMatch.Success) + { + Registry.filesQueueToBeProcessedAfterGLDMerge.Add(mod, file); + continue; + } + + Match improvementPrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^improvementPrefab(?:_(.*))?\.json$"); + if (improvementPrefabMatch.Success) + { + Registry.filesQueueToBeProcessedAfterGLDMerge.Add(mod, file); + continue; + } + + Match resourcePrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^resourcePrefab(?:_(.*))?\.json$"); + if (resourcePrefabMatch.Success) + { + Registry.filesQueueToBeProcessedAfterGLDMerge.Add(mod, file); + continue; + } + + switch (Path.GetExtension(file.name)) + { + case ".png": + Loader.LoadTextureFile(mod, file); + break; + case ".wav": + Loader.LoadAudioFile(mod, file); + break; + case ".bundle": + Loader.LoadAssetBundle(mod, file); + break; + } + } + } + TechItem.techTierFirebaseId.Clear(); + for (int i = 0; i <= MAX_TECH_TIER; i++) + { + TechItem.techTierFirebaseId.Add($"tech_research_{i}"); + } + Loader.ProcessGameLogicData(gameLogicData, json); + Loader.ProcessFilesQueue(); + Visual.CreateSprites(); + Prefabs.CreatePrefabs(); + stopwatch.Stop(); + Plugin.logger.LogInfo($"Loaded all mods in {stopwatch.ElapsedMilliseconds}ms"); + } } diff --git a/src/Managers/Prefabs.cs b/src/Managers/Prefabs.cs new file mode 100644 index 0000000..3295f59 --- /dev/null +++ b/src/Managers/Prefabs.cs @@ -0,0 +1,654 @@ +using HarmonyLib; +using Il2CppInterop.Runtime; +using Polytopia.Data; +using PolytopiaBackendBase.Common; +using UnityEngine; +using static PrefabManager; +using static SkinVisualsReference; + +namespace PolyMod.Managers; + +public static class Prefabs +{ + internal static Dictionary unitsNew = new(); + internal static Dictionary> unitOverridesNew = new(); // maybe in the future will implement + internal static Dictionary unitUIOverridesNew = new(); + + internal static Material? cachedUnitMaterial; + internal static Material? cachedSharedMaterial; + internal const string CACHED_SHARED_MATERIAL_NAME = "SpriteMaterial"; + + public static List unitInfos = new(); + + public static List resourceInfos = new(); + + public static List improvementInfos = new(); + internal static Dictionary improvementPrefabInfosToCreate = new(); + internal static List resourcePrefabsInfosToCreate = new(); + + private static List weaponInfos = new(); + private static Dictionary weaponProjectilesCache = new(); + private static Dictionary weaponAnimTimeCache = new() + { + {UnitData.WeaponEnum.Sting, 0.5f}, + {UnitData.WeaponEnum.IceArrow, 1f}, + {UnitData.WeaponEnum.Gun, 1.5f}, // __instance.Sway(); __instance.Tile.SpawnDarkPuff(); + {UnitData.WeaponEnum.FireBlow, 2f}, // __instance.Tile.SpawnDarkPuff(); + {UnitData.WeaponEnum.Poison, 2f}, // __instance.Tile.SpawnPoison(__instance.Owner.GetVisualSkin()); + {UnitData.WeaponEnum.Pierce, 1f}, + {UnitData.WeaponEnum.Water, 1.5f}, // __instance.Sway(); __instance.Tile.SpawnPuff(); + }; + + [HarmonyPostfix] + [HarmonyPatch(typeof(PrefabManager), nameof(PrefabManager.Awake))] + private static void PrefabManager_Awake_Postfix(PrefabManager __instance) + { + unitsNew.Clear(); + unitOverridesNew.Clear(); + unitUIOverridesNew.Clear(); + + foreach (UnitPrefabData unitPrefabData in __instance.unitPrefabs) + { + if(cachedUnitMaterial == null) + { + SkinVisualsReference skinVisualsReference = unitPrefabData.prefab.GetComponent(); + if (skinVisualsReference != null && skinVisualsReference.visualParts != null) + { + foreach (VisualPart currentPart in skinVisualsReference.visualParts) + { + if (currentPart.visualPart == null) + { + continue; + } + + SpriteRenderer headSpriteRenderer = currentPart.visualPart.GetComponent(); + if (headSpriteRenderer != null) + { + cachedUnitMaterial = headSpriteRenderer.sharedMaterial; + break; + } + } + } + } + unitsNew[new SkinnedKey(unitPrefabData.type, unitPrefabData.tribe, unitPrefabData.skin)] = unitPrefabData.prefab; + } + + foreach (UnitOverridePrefabData unitOverridePrefabData in __instance.unitOverridePrefabs) + { + if (!unitOverridesNew.TryGetValue(unitOverridePrefabData.overrideCondition, out Dictionary? overridesForCondition)) + { + overridesForCondition = new Dictionary(); + unitOverridesNew[unitOverridePrefabData.overrideCondition] = overridesForCondition; + } + overridesForCondition[unitOverridePrefabData.type] = unitOverridePrefabData.prefab; + } + + foreach (UnitPrefabData unitUIOverridePrefabData in __instance.unitUIOverridePrefabs) + { + unitUIOverridesNew[new SkinnedKey(unitUIOverridePrefabData.type, unitUIOverridePrefabData.tribe, unitUIOverridePrefabData.skin)] = unitUIOverridePrefabData.prefab; + } + + Plugin.logger.LogInfo($"Built lookups: {unitsNew.Count} units, {unitUIOverridesNew.Count} UI overrides"); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(PrefabManager), nameof(PrefabManager.HasUIUnitOverride))] + public static bool HasUIUnitOverride(ref bool __result, UnitData.Type unitType, TribeType tribe, SkinType skin) + { + __result = unitUIOverridesNew.ContainsKey(new SkinnedKey(unitType, tribe, skin)); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(PrefabManager), nameof(PrefabManager.GetUnitOrUiOverride))] + public static bool GetUnitOrUiOverride(ref Unit __result, UnitData.Type unitType, TribeType tribe, SkinType skin) + { + if (unitUIOverridesNew.TryGetValue(new SkinnedKey(unitType, tribe, skin), out Unit? uiOverridePrefab)) + { + __result = uiOverridePrefab; + } + else + { + __result = ResolveUnitPrefab(unitType, tribe, skin); + } + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(PrefabManager), nameof(PrefabManager.GetPrefab), new Type[] { typeof(UnitData.Type), typeof(TribeType), typeof(SkinType) })] + public static bool PrefabManager_GetPrefab(ref Unit __result, UnitData.Type type, TribeType tribe, SkinType skin) + { + __result = ResolveUnitPrefab(type, tribe, skin); + return false; + } + + [HarmonyPostfix] // prolly remove later + [HarmonyPatch(typeof(SkinVisualsReference), nameof(SkinVisualsReference.SetupVisualPart))] + public static void SetupVisualPart(ref VisualPart visualSetting, bool isImprovement) + { + bool flag = false; + flag = visualSetting.outline != null && visualSetting.outline.transform.parent == visualSetting.visualPart.transform; + if (!flag && visualSetting.visualPart.transform.childCount > 0) + { + visualSetting.outline = visualSetting.visualPart.transform.GetChild(0).gameObject; + flag = true; + } + bool flag2 = false; + if (flag) + { + string defaultSpriteName = visualSetting.DefaultSpriteName; + if (visualSetting.outline != null && visualSetting.outline.name == defaultSpriteName + "_Outline") + { + flag2 = true; + } + } + visualSetting.hasValidOutline = flag2; + } + + // [HarmonyPrefix] // overrides again....... + // [HarmonyPatch(typeof(Unit), nameof(Unit.IsProjectile))] + // private static bool Unit_IsProjectile(ref bool __result, UnitData.WeaponEnum weapon) + // { + // if(!weaponProjectilesCache.ContainsKey(weapon)) + // return true; + + // __result = weaponProjectilesCache[weapon]; + // return false; + // } + + // [HarmonyPrefix] // overrides again....... + // [HarmonyPatch(typeof(Unit), nameof(Unit.Attack))] + // private static bool Unit_Attack(Unit __instance, WorldCoordinates target, bool moveToTarget, Action onComplete) + // { + // UnitData.WeaponEnum weaponEnum = __instance.unitData.weapon; + // List weaponPrefabInfos = weaponInfos.Where(weaponInfo => weaponInfo.weaponEnum == weaponEnum).ToList(); + // if(weaponPrefabInfos.Count == 0) + // return true; + + // WeaponPrefabInfo weaponPrefabInfo = weaponPrefabInfos.Last(); + // float chebyshevDistance = MapDataExtensions.ChebyshevDistance(__instance.Tile.Coordinates, target); + + // float animationTime = 0.2f + chebyshevDistance * 0.05f; + // Tile targetTile = MapRenderer.Current.GetTileInstance(target); + + // WeaponGFX pooledObject = ObjectPool.GetPooledObject(GetPrefab(weaponEnum).gameObject); + // pooledObject.IsUsed = true; + // Vector3 center = SkinVisualsRenderer.GetSpriteBounds(__instance.skinVisuals).center; + // pooledObject.transform.position = __instance.transform.position + center; + // pooledObject.SetSkin(__instance.Owner.GetVisualSkin()); + + // if ((bool)targetTile.Unit) + // { + // targetTile.Unit.Flipped = targetTile.Unit.UnitState.flipped; + // } + // __instance.Flipped = __instance.UnitState.flipped; + + // if(weaponPrefabInfo.weaponPuffInfo != null) + // { + // animationTime *= weaponPrefabInfo.weaponPuffInfo.animationMultiplier; + // if(weaponPrefabInfo.weaponPuffInfo.sway) + // __instance.Sway(); + // } + // if (Unit.IsProjectile(weaponEnum)) + // { + // if(weaponPrefabInfo.weaponPuffInfo != null && weaponPrefabInfo.weaponPuffInfo!.animationDelegate != null) + // pooledObject.AnimateRanged(animationTime, target, DelegateSupport.ConvertDelegate(weaponPrefabInfo.weaponPuffInfo!.animationDelegate!)); + // else + // pooledObject.AnimateRanged(animationTime, target); + // } + // else + // { + // pooledObject.AnimateCloseRangeAttack(0.2f); + // } + + // if (__instance.unitData.GetRange() < 2) + // { + // __instance.AnimateMeleeAttackMovement(targetTile, moveToTarget, onComplete); + // } + // else + // { + // GameManager.DelayCall((int)(animationTime * 1000f), onComplete); + // } + + // return false; + // } + + // [HarmonyPrefix] // testing....... + // [HarmonyPatch(typeof(WeaponGFX), nameof(WeaponGFX.AnimateCloseRangeAttack))] + // public static bool WeaponGFX_AnimateCloseRangeAttack(ref float animTime, Action onComplete) + // { + // animTime = 100000f; + // return true; + // } + + // [HarmonyPrefix] // testing....... + // [HarmonyPatch(typeof(WeaponGFX), nameof(WeaponGFX.AnimateRanged))] + // public static bool WeaponGFX_AnimateRanged(ref float animTime, WorldCoordinates to, Action onComplete) + // { + // animTime = 100000f; + // return true; + // } + + private static Unit ResolveUnitPrefab(UnitData.Type unitType, TribeType tribeType, SkinType skinType) + { + if (SeasonManager.IsChristmas() + && unitOverridesNew.TryGetValue(OverrideCondition.Christmas, out Dictionary? christmasOverrides) + && christmasOverrides.TryGetValue(unitType, out Unit? christmasPrefab) + && christmasPrefab != null) + { + return christmasPrefab; + } + + SkinnedKey exactKey = new SkinnedKey(unitType, tribeType, skinType); + if (unitsNew.TryGetValue(exactKey, out Unit? exactPrefab)) + { + return exactPrefab; + } + + if (skinType != SkinType.Default || tribeType != TribeType.None) + { + SkinnedKey noTribeKey = new SkinnedKey(unitType, TribeType.None, skinType); + SkinnedKey defaultSkinKey = new SkinnedKey(unitType, tribeType, SkinType.Default); + SkinnedKey noTribeDefaultSkinKey = new SkinnedKey(unitType, TribeType.None, SkinType.Default); + + if (unitsNew.TryGetValue(noTribeKey, out Unit? fallbackPrefab) + || unitsNew.TryGetValue(defaultSkinKey, out fallbackPrefab) + || unitsNew.TryGetValue(noTribeDefaultSkinKey, out fallbackPrefab)) + { + unitsNew[exactKey] = fallbackPrefab; + return fallbackPrefab; + } + } + + Plugin.logger.LogInfo($"Couldn't find prefab for type: {unitType}"); + unitsNew.TryGetValue(new SkinnedKey(default, TribeType.None, SkinType.Default), out Unit? lastResortPrefab); + return lastResortPrefab!; + } + + internal static void CreatePrefabs() + { + // get materials + Material[] allLoadedMaterials = Resources.FindObjectsOfTypeAll(); + foreach (Material loadedMaterial in allLoadedMaterials) + { + // Plugin.logger.LogInfo($"Material: {loadedMaterial.name}, shader: {loadedMaterial.shader.name}"); + + if(loadedMaterial.name == CACHED_SHARED_MATERIAL_NAME) + { + cachedSharedMaterial = loadedMaterial; + GameObject.DontDestroyOnLoad(cachedSharedMaterial); + break; + } + } + + // creates dummy improvements. those have one visual part, and default name is the enum cache name of the type + foreach (KeyValuePair improvementInfo in improvementPrefabInfosToCreate) + { + ImprovementData.Type improvementType = improvementInfo.Key; + improvementInfos.Add( + new ImprovementPrefabInfo() + { + improvementType = improvementType, + visualParts = new() + { + new() + { + defaultSpriteName = EnumCache.GetName(improvementType), + levels = improvementInfo.Value, + } + } + } + ); + } + + // creates dummy resources. those have one visual part, and default name is the enum cache name of the type + foreach (ResourceData.Type resourceType in resourcePrefabsInfosToCreate) + { + resourceInfos.Add( + new ResourcePrefabInfo() + { + resourceType = resourceType, + visualParts = new() + { + new() + { + defaultSpriteName = EnumCache.GetName(resourceType), + } + } + } + ); + } + + foreach (var unitPrefabInfo in unitInfos) + { + ProcessUnitPrefabInfo(unitPrefabInfo); + } + + foreach (var improvementPrefabInfo in improvementInfos) + { + ProcessImprovementPrefabInfo(improvementPrefabInfo); + } + + foreach (var resourcePrefabInfo in resourceInfos) + { + ProcessResourcePrefabInfo(resourcePrefabInfo); + } + } + + #region Units + internal static void ProcessUnitPrefabInfo(UnitPrefabInfo unitPrefabInfo) + { + // maybe just init new unit? hmmmmm will try later + var baseUnit = PrefabManager.GetPrefab(UnitData.Type.Warrior, TribeType.Imperius, SkinType.Default); + if (baseUnit == null) + return; + + var unitInstance = GameObject.Instantiate(baseUnit); + if (unitInstance == null) + return; + + GameObject.Destroy(unitInstance.spriteContainer.gameObject); + + var spriteContainerObj = new GameObject("SpriteContainer"); + spriteContainerObj.transform.SetParent(unitInstance.transform, false); + var spriteContainer = spriteContainerObj.transform; + unitInstance.spriteContainer = spriteContainer; + + var visualParts = ApplyUnitVisualParts(unitPrefabInfo, spriteContainer); + + Transform? headPositionMarker = null; + foreach (var vp in visualParts) + { + if (vp.visualPart.gameObject.name == unitPrefabInfo.headPositionMarker) + { + headPositionMarker = vp.visualPart.gameObject.transform; + break; + } + } + + unitInstance.headPositionMarker = headPositionMarker ?? visualParts[0].visualPart.transform; + + var svr = unitInstance.GetComponent(); + svr.visualParts = visualParts.ToArray(); + + GameObject.DontDestroyOnLoad(unitInstance.gameObject); + + if(unitPrefabInfo.uiOverride) + { + unitUIOverridesNew[unitPrefabInfo.skinnedKey] = unitInstance; + return; + } + unitsNew[unitPrefabInfo.skinnedKey] = unitInstance; + } + + private static List ApplyUnitVisualParts( + UnitPrefabInfo unitPrefabInfo, + Transform spriteContainer) + { + List parts = new(); + List visualPartInfos = unitPrefabInfo.visualParts; + + if (visualPartInfos.Count == 0) + return parts; + + int nextPartSortingOrder = 0; + int nextOutlineSortingOrder = -visualPartInfos.Count; + var newVisualInfo = visualPartInfos; + newVisualInfo.Reverse(); + foreach (var info in newVisualInfo) + { + parts.Add(CreateUnitVisualPart(info, spriteContainer, info.sortingOrderOverride ?? nextPartSortingOrder, info.outlineSortingOrderOverride ?? nextOutlineSortingOrder)); + if(nextOutlineSortingOrder < -1) + { + nextOutlineSortingOrder++; + } + nextPartSortingOrder++; + } + + return parts; + } + + private static SkinVisualsReference.VisualPart CreateUnitVisualPart( + VisualPartInfo info, + Transform parent, + int visualPartSortingOrder, + int outlineSortingOrder) + { + var visualPartObj = new GameObject(info.defaultSpriteName); + visualPartObj.transform.SetParent(parent); + + var outlineObj = new GameObject($"{info.defaultSpriteName}_Outline"); + outlineObj.transform.SetParent(visualPartObj.transform); + + visualPartObj.transform.position = info.coordinates; + outlineObj.transform.position = info.coordinates; + visualPartObj.transform.rotation = outlineObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation); + + // this bs was hunting me for so long. that is the thing which makes outline hidden in ui + outlineObj.SetActive(false); + + var visualPart = info.ToVisualPart(false); + visualPart.visualPart = visualPartObj; + visualPart.outline = outlineObj; + + var renderer = visualPartObj.AddComponent(); + renderer.material = cachedUnitMaterial; + renderer.sortingOrder = visualPartSortingOrder; + renderer.sortingLayerName = "Units"; + visualPart.renderer = new SkinVisualsReference.RendererUnion { spriteRenderer = renderer }; + + var outlineRenderer = outlineObj.AddComponent(); + outlineRenderer.material = cachedUnitMaterial; + outlineRenderer.sortingOrder = outlineSortingOrder; + outlineRenderer.sortingLayerName = "Units"; + visualPart.outlineRenderer = new SkinVisualsReference.RendererUnion { spriteRenderer = outlineRenderer }; + + return visualPart; + } + + #endregion + #region Improvements + internal static void ProcessImprovementPrefabInfo(ImprovementPrefabInfo improvementPrefabInfo) + { + if(improvementPrefabInfo.improvementType == null) + return; + + var baseBuilding = PrefabManager.GetPrefab(ImprovementData.Type.LumberHut); + if (baseBuilding == null) + return; + + var buildingInstance = GameObject.Instantiate(baseBuilding); + if (buildingInstance == null) + return; + + Transform buildingTransform = buildingInstance.transform; + for (int childIndex = buildingTransform.childCount - 1; childIndex >= 0; childIndex--) + { + GameObject.Destroy(buildingTransform.GetChild(childIndex).gameObject); + } + + var visualParts = ApplyBuildingVisualParts(improvementPrefabInfo, buildingTransform); + + var svr = buildingInstance.GetComponent(); + svr.visualParts = visualParts.ToArray(); + + GameObject.DontDestroyOnLoad(buildingInstance.gameObject); + + PrefabManager.improvements[(Polytopia.Data.ImprovementData.Type)improvementPrefabInfo.improvementType] = buildingInstance; + } + + private static List ApplyBuildingVisualParts( + ImprovementPrefabInfo improvementPrefabInfo, + Transform spriteContainer) + { + List parts = new(); + List visualPartInfos = improvementPrefabInfo.visualParts; + + if (visualPartInfos.Count == 0) + return parts; + + int nextPartSortingOrder = 0; + int nextOutlineSortingOrder = -visualPartInfos.Count; + foreach (var info in visualPartInfos) + { + parts.Add(CreateBuildingVisualPart(info, spriteContainer, nextPartSortingOrder, nextOutlineSortingOrder, improvementPrefabInfo.needsOutlines)); + if(nextOutlineSortingOrder < -1) + { + nextOutlineSortingOrder++; + } + nextPartSortingOrder++; + } + + return parts; + } + + private static SkinVisualsReference.VisualPart CreateBuildingVisualPart( + VisualPartInfo info, + Transform parent, + int visualPartSortingOrder, + int outlineSortingOrder, + bool needsOutline = false) + { + var visualPartObj = new GameObject(info.defaultSpriteName); + visualPartObj.transform.SetParent(parent); + + var outlineObj = new GameObject($"{info.defaultSpriteName}_Outline"); + outlineObj.transform.SetParent(visualPartObj.transform); + + visualPartObj.transform.position = outlineObj.transform.position = info.coordinates; + visualPartObj.transform.rotation = outlineObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation); + + outlineObj.SetActive(false); + + var visualPart = info.ToVisualPart(true); + visualPart.visualPart = visualPartObj; + visualPart.outline = outlineObj; + + var renderer = AddPolytopiaSpriteRenderer(visualPartObj, visualPartSortingOrder); + visualPart.renderer = new SkinVisualsReference.RendererUnion { polytopiaSpriteRenderer = renderer }; + + if(needsOutline) + { + var outlineRenderer = AddPolytopiaSpriteRenderer(outlineObj, outlineSortingOrder); + visualPart.outlineRenderer = new SkinVisualsReference.RendererUnion { polytopiaSpriteRenderer = outlineRenderer }; + } + + return visualPart; + } + + private static PolytopiaSpriteRenderer AddPolytopiaSpriteRenderer(GameObject targetGameObject, int sortingOrder, string sortingLayerName = "Terrain") + { + MeshFilter meshFilter = targetGameObject.AddComponent(); + MeshRenderer meshRenderer = targetGameObject.AddComponent(); + meshRenderer.receiveShadows = false; + meshRenderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + meshRenderer.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; + meshRenderer.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; + + PolytopiaSpriteRenderer polytopiaSpriteRenderer = targetGameObject.AddComponent(); + polytopiaSpriteRenderer.meshFilter = meshFilter; + polytopiaSpriteRenderer.meshRenderer = meshRenderer; + + polytopiaSpriteRenderer.SortingLayer = SortingLayer.NameToID(sortingLayerName); + polytopiaSpriteRenderer.SortingOrder = sortingOrder; + if (cachedSharedMaterial != null) + { + polytopiaSpriteRenderer.SharedMaterial = cachedSharedMaterial; + } + return polytopiaSpriteRenderer; + } + + #endregion + #region Resources + + internal static void ProcessResourcePrefabInfo(ResourcePrefabInfo resourcePrefabInfo) + { + if(resourcePrefabInfo.resourceType == null) + return; + + var baseResource = PrefabManager.GetPrefab(ResourceData.Type.Game); + if (baseResource == null) + return; + + var resourceInstance = GameObject.Instantiate(baseResource); + if (resourceInstance == null) + return; + + Transform resourceTransform = resourceInstance.transform; + for (int childIndex = resourceTransform.childCount - 1; childIndex >= 0; childIndex--) + { + GameObject.Destroy(resourceTransform.GetChild(childIndex).gameObject); + } + + var visualParts = ApplyResourceVisualParts(resourcePrefabInfo, resourceTransform); + + var svr = resourceInstance.GetComponent(); + svr.visualParts = visualParts.ToArray(); + + GameObject.DontDestroyOnLoad(resourceInstance.gameObject); + + PrefabManager.resources[(Polytopia.Data.ResourceData.Type)resourcePrefabInfo.resourceType] = resourceInstance; + } + + private static List ApplyResourceVisualParts( + ResourcePrefabInfo resourcePrefabInfo, + Transform spriteContainer) + { + List parts = new(); + List visualPartInfos = resourcePrefabInfo.visualParts; + + if (visualPartInfos.Count == 0) + return parts; + + int nextPartSortingOrder = 0; + int nextOutlineSortingOrder = -visualPartInfos.Count; + foreach (var info in visualPartInfos) + { + parts.Add(CreateResourceVisualPart(info, spriteContainer, nextPartSortingOrder, nextOutlineSortingOrder)); + if(nextOutlineSortingOrder < -1) + { + nextOutlineSortingOrder++; + } + nextPartSortingOrder++; + } + + return parts; + } + + private static SkinVisualsReference.VisualPart CreateResourceVisualPart( + VisualPartInfo info, + Transform parent, + int visualPartSortingOrder, + int outlineSortingOrder) + { + var visualPartObj = new GameObject(info.defaultSpriteName); + visualPartObj.transform.SetParent(parent); + + var outlineObj = new GameObject($"{info.defaultSpriteName}_Outline"); + outlineObj.transform.SetParent(visualPartObj.transform); + + visualPartObj.transform.position = outlineObj.transform.position = info.coordinates; + visualPartObj.transform.rotation = outlineObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation); + + outlineObj.SetActive(false); + + var visualPart = info.ToVisualPart(false); + visualPart.visualPart = visualPartObj; + visualPart.outline = outlineObj; + + var renderer = AddPolytopiaSpriteRenderer(visualPartObj, visualPartSortingOrder); + visualPart.renderer = new SkinVisualsReference.RendererUnion { polytopiaSpriteRenderer = renderer }; + + var outlineRenderer = AddPolytopiaSpriteRenderer(outlineObj, outlineSortingOrder); + visualPart.outlineRenderer = new SkinVisualsReference.RendererUnion { polytopiaSpriteRenderer = outlineRenderer }; + + return visualPart; + } + + #endregion + + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(Prefabs)); + } +} \ No newline at end of file diff --git a/src/Managers/TribePreviews.cs b/src/Managers/TribePreviews.cs new file mode 100644 index 0000000..b0c41fd --- /dev/null +++ b/src/Managers/TribePreviews.cs @@ -0,0 +1,234 @@ +using HarmonyLib; +using Polytopia.Data; +using UnityEngine; +using Il2CppSystem.Linq; +using PolyMod.Json; +using System.Text.Json.Serialization; +using PolytopiaBackendBase.Common; + +namespace PolyMod.Managers; + +public static class TribePreviews +{ + /// + /// Represents a tile in a tribe preview. + /// + public class PreviewTile + { + /// The x-coordinate of the tile. + [JsonInclude] + public int? x = null; + /// The y-coordinate of the tile. + [JsonInclude] + public int? y = null; + /// The terrain type of the tile. + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public Polytopia.Data.TerrainData.Type terrainType = Polytopia.Data.TerrainData.Type.None; + /// The resource type on the tile. + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public ResourceData.Type? resourceType = null; + /// The unit type on the tile. + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public UnitData.Type? unitType = null; + /// The improvement type on the tile. + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public ImprovementData.Type? improvementType = null; + /// The effect types on the tile. + [JsonInclude] + [JsonConverter(typeof(EnumCacheListJson))] + public List tileEffects = new(); + } + + public record PreviewInfo(int arrayIdx, SaveStateData saveStateData, PreviewTile[] customPreview); + + [HarmonyPostfix] + [HarmonyPatch(typeof(TribePreviewRegistry), nameof(TribePreviewRegistry.PreloadTribePreviews))] + public static void PreloadTribePreviews() + { + Dictionary previewSaveStateDatas = new(); + + SaveStateData fallbackSaveStateData = TribePreviewRegistry.saveStateDatas[TribePreviewRegistry.fallbackStateIndex]; + foreach(string tribe in Registry.tribePreviews.Keys) + { + string previewId = "worldpreview_" + tribe; + PreviewTile[] customPreview = Registry.tribePreviews[tribe]; + previewSaveStateDatas[previewId] = new(TribePreviewRegistry.fallbackStateIndex, fallbackSaveStateData, customPreview); + } + + for (int i = 0; i < TribePreviewRegistry.saveStateDatas.Length; i++) + { + SaveStateData saveStateData = TribePreviewRegistry.saveStateDatas[i]; + if(previewSaveStateDatas.ContainsKey(saveStateData.name)) + { + previewSaveStateDatas[saveStateData.name] = new(i, saveStateData, previewSaveStateDatas[saveStateData.name].customPreview); + } + } + + foreach (string previewId in previewSaveStateDatas.Keys) + { + var data = previewSaveStateDatas[previewId]; + SaveStateData? customSaveStateData = ApplyCustomPreview(previewId, data.saveStateData, data.customPreview); + if(customSaveStateData == null) + continue; + + if(data.arrayIdx == TribePreviewRegistry.fallbackStateIndex) + { + List saveStateDatas = TribePreviewRegistry.saveStateDatas.ToList(); + saveStateDatas.Add(customSaveStateData); + TribePreviewRegistry.saveStateDatas = saveStateDatas.ToArray(); + } + else + { + TribePreviewRegistry.saveStateDatas[data.arrayIdx] = customSaveStateData; + } + } + } + + private static SaveStateData? ApplyCustomPreview(string previewId, SaveStateData originalPreview, PreviewTile[] preview) + { + SaveStateData? saveStateData = null; + if (DiskSerializationHelpers.FromLZ4CompressedByteArray( + originalPreview.byteArray, out ClientSerializationWrapper clientSerializationWrapper, out int version)) + { + GameState currentGameState = clientSerializationWrapper.GetCurrentGameState(); + foreach (var previewTile in preview) + { + if(previewTile.x == null || previewTile.y == null) + continue; + + WorldCoordinates coordinates = new((int)previewTile.x, (int)previewTile.y); + TileData? tileData = currentGameState.Map.GetTile(coordinates); + if(tileData == null) + continue; + + if(previewTile.terrainType != Polytopia.Data.TerrainData.Type.None) + tileData.terrain = previewTile.terrainType; + + if(previewTile.resourceType != null) + { + ResourceState? resourceState = null; + ResourceData.Type resourceType = (ResourceData.Type)previewTile.resourceType; + if(resourceType != ResourceData.Type.None) + { + resourceState = new ResourceState { type = resourceType }; + } + tileData.resource = resourceState; + } + + if(previewTile.unitType != null) + { + tileData.unit = null; + if(previewTile.unitType != Polytopia.Data.UnitData.Type.None && + currentGameState.TryGetPlayer(currentGameState.CurrentPlayer, out PlayerState playerState) && + currentGameState.GameLogicData.TryGetData((UnitData.Type)previewTile.unitType, out UnitData unitData)) + { + UnitState unitState = ActionUtils.TrainUnit(currentGameState, playerState, tileData, unitData); + unitState.moved = false; + unitState.attacked = false; + } + } + + + if(previewTile.improvementType != null) + { + tileData.improvement = null; + ImprovementData.Type improvementType = (ImprovementData.Type)previewTile.improvementType; + if( + previewTile.improvementType != ImprovementData.Type.None && + currentGameState.GameLogicData.TryGetData( + improvementType, + out ImprovementData improvementData) + ) + { + tileData.improvement = new ImprovementState + { + type = improvementData.type, + borderSize = (ushort)improvementData.borderSize, + level = 0, + xp = 0, + production = 1, + founded = (ushort)currentGameState.CurrentTurn, + baseScore = (ushort)improvementData.GetScoreReward(), + founder = currentGameState.CurrentPlayer + }; + } + } + + if(previewTile.tileEffects.Count > 0) + tileData.effects = new(); + + foreach (TileData.EffectType tileEffect in previewTile.tileEffects) + { + tileData.AddEffect(tileEffect); + } + } + + saveStateData = ScriptableObject.CreateInstance(); + saveStateData.name = previewId; + saveStateData.byteArray = DiskSerializationHelpers.ToLZ4CompressedByteArray(clientSerializationWrapper, version); + } + + return saveStateData; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(SelectTribePopup), nameof(SelectTribePopup.SetBackground))] + private static void SelectTribePopup_SetBackground(SelectTribePopup __instance) + { + if(!Plugin.config.showCoordinates) + return; + + try + { + TribeType tribeType = (__instance.mixTribeData != null) ? __instance.mixTribeData.type : __instance.tribeData.type; + + __instance.mapSnapshot = new SpriteCamera.SnapshotMapData(); + __instance.mapSnapshot.LoadMapState( + TribePreviewRegistry.GetTribePreviewData(tribeType, __instance.SkinType) + ); + + float width = __instance.worldPreviewRawImage.rectTransform.GetWidth(); + float height = __instance.worldPreviewRawImage.rectTransform.GetHeight(); + + int tribeColor = PolytopiaDataManager.GetGameLogicData( + VersionManager.GameLogicDataVersion + ).GetTribeColor( + tribeType, + __instance.SkinType + ); + + var instance = SpriteCamera.instance; + float baseOrthographicCameraSize = instance.spriteCamera.orthographicSize; + + instance.spriteCamera.orthographicSize = (float)baseOrthographicCameraSize * 2; + + Texture2D texture2D = SpriteCamera.TakeSnapshotOfMapState( + __instance.mapSnapshot, + width, + height, + tribeType, + __instance.SkinType, + tribeType, + tribeColor + ); + + instance.spriteCamera.orthographicSize = baseOrthographicCameraSize; + + __instance.worldPreviewRawImage.texture = texture2D; + __instance.worldPreviewRawImage.color = Color.white; + } + catch (Exception ex) + { + Plugin.logger.LogWarning($"Failed to load debug tribe preview {ex.Message}"); + } + } + + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(TribePreviews)); + } +} \ No newline at end of file diff --git a/src/Managers/Visual.cs b/src/Managers/Visual.cs index 628b7b2..82acd4d 100644 --- a/src/Managers/Visual.cs +++ b/src/Managers/Visual.cs @@ -1,14 +1,11 @@ -using System.Reflection; using HarmonyLib; using Polytopia.Data; using UnityEngine; -using UnityEngine.U2D; -using UnityEngine.UI; using Il2CppSystem.Linq; -using PolyMod.Json; -using System.Text.Json.Serialization; using PolytopiaBackendBase.Common; using Il2CppInterop.Runtime; +using System.Text.Json.Serialization; +using PolyMod.Json; using TMPro; namespace PolyMod.Managers; @@ -18,109 +15,76 @@ namespace PolyMod.Managers; /// public static class Visual { - /// - /// Represents a tile in a tribe preview. - /// - public class PreviewTile - { - /// The x-coordinate of the tile. - [JsonInclude] - public int? x = null; - /// The y-coordinate of the tile. - [JsonInclude] - public int? y = null; - /// The terrain type of the tile. - [JsonInclude] - [JsonConverter(typeof(EnumCacheJson))] - public Polytopia.Data.TerrainData.Type terrainType = Polytopia.Data.TerrainData.Type.None; - /// The resource type on the tile. - [JsonInclude] - [JsonConverter(typeof(EnumCacheJson))] - public ResourceData.Type? resourceType = null; - /// The unit type on the tile. - [JsonInclude] - [JsonConverter(typeof(EnumCacheJson))] - public UnitData.Type? unitType = null; - /// The improvement type on the tile. - [JsonInclude] - [JsonConverter(typeof(EnumCacheJson))] - public ImprovementData.Type? improvementType = null; - } - - /// Represents information about a sprite, such as its pivot and pixels per unit. - public record SpriteInfo(float? pixelsPerUnit, Vector2? pivot); + /// Represents information about a sprite, such as its pivot and pixels per unit. + public class SpriteInfo + { + [JsonInclude] + public float? pixelsPerUnit; + + [JsonInclude] + public Vector2? pivot; + + [JsonInclude] + public SpriteOrigin? spriteOrigin = null; + + [JsonInclude] + public bool transformToOutline = false; + + [JsonInclude] + public float? gaussianBlurRadius = null; + + public SpriteInfo(float? pixelsPerUnit, Vector2? pivot, bool transformToOutline = false, SpriteOrigin? spriteOrigin = null, float? gaussianBlurRadius = null) + { + this.pixelsPerUnit = pixelsPerUnit; + this.pivot = pivot; + this.spriteOrigin = spriteOrigin; + this.transformToOutline = transformToOutline; + this.gaussianBlurRadius = gaussianBlurRadius; + } + } + + public class SpriteOrigin + { + [JsonInclude] + public string? name; + + [JsonInclude] + [JsonConverter(typeof(EnumNameJson))] + public OriginType originType = OriginType.Outer; + } + + public enum OriginType + { + Outer, // Outer means sprite comes from a mod, and not from game's files + Inner // Inner means sprite is to be fetched from game's files + } /// Represents information about a custom skin. public record SkinInfo(int idx, string id, SkinData? skinData); - /// A dictionary of custom widths for basic popups. - public static Dictionary basicPopupWidths = new(); - /// Represents information about a unit prefab. - public struct UnitPrefabInfo - { - public UnitPrefabInfo(string type, string tribe, string skin) - { - this.type = type; - this.tribe = tribe; - this.skin = skin; - } - public string type; - public string tribe; - public string skin; - } - private const int BASIC_POPUP_LEGACY_ID = 29; - public static Dictionary customPrefabs = new(); - private static UnitData.Type currentUnitTypeUI = UnitData.Type.None; - private static TribeType attackerTribe = TribeType.None; - /// - /// A dictionary of skin types of tribes, which can flood tiles, keyed by custom flood tile effect. - /// - internal static Dictionary customFloodingSkins = new(); - private static bool isTakingSnapshot = false; - private static float? baseOrthographicCameraSize = null; - - /// The type of a custom prefab. - public enum PrefabType - { - Unit, - Improvement, - Resource - } + /// A dictionary of custom widths for basic popups. + public static Dictionary basicPopupWidths = new(); - /// Represents information about a custom prefab. - public record PrefabInfo - { - public PrefabType type { get; init; } - public string name { get; init; } - public List visualParts { get; init; } - public string headPositionMarker { get; init; } - - [JsonConstructor] - public PrefabInfo( - PrefabType type, - List visualParts, - string name = "", - string headPositionMarker = "") - { - this.type = type; - this.name = name.ToLowerInvariant(); - this.visualParts = visualParts; - this.headPositionMarker = headPositionMarker; - } - } + public record PopupWidth(BasicPopupLegacy popup, float share, float maximum); + + private const int BASIC_POPUP_LEGACY_ID = 29; + + /// The width the game gives its own popups, used as the floor for custom widths. + private const float DEFAULT_POPUP_WIDTH = 402f; + + /// Smallest gap left between two buttons before they are moved onto separate rows. + private const float POPUP_BUTTON_GAP = 8f; - /// Represents information about a visual part of a prefab. - 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; - private static bool seenWarningWCPopup = false; - public record PreviewInfo(int arrayIdx, SaveStateData saveStateData, PreviewTile[] customPreview); + /// Vertical gap between two rows of popup buttons. + private const float POPUP_BUTTON_ROW_GAP = 10f; + /// + /// A dictionary of skin types of tribes, which can flood tiles, keyed by custom flood tile effect. + /// + internal static Dictionary customFloodingSkins = new(); + private static bool seenWarningWCPopup = false; + + private static Dictionary? cachedSpriteDataReverse = null; + private static Dictionary cachedReversedNames = new(); #region General @@ -131,242 +95,222 @@ private static void TechItem_SetupComplete() { } - /// Patches the sprite atlas manager to load custom sprites. - [HarmonyPrefix] - [HarmonyPatch(typeof(SpriteAtlasManager), nameof(SpriteAtlasManager.LoadSprite), typeof(string), typeof(string), typeof(SpriteCallback))] - private static bool SpriteAtlasManager_LoadSprite(SpriteAtlasManager __instance, string atlas, string sprite, SpriteCallback completion) - { - bool found = false; - __instance.LoadSpriteAtlas(atlas, (Il2CppSystem.Action)GetAtlas); - - return !found; - - void GetAtlas(SpriteAtlas spriteAtlas) + /// Patches the sprite atlas manager to look up custom sprites. + internal static void CacheSpriteNames() + { + cachedSpriteDataReverse = new(); + foreach (Polytopia.Data.TerrainData.Type terrainType in Enum.GetValues()) { - if (spriteAtlas != null) + string result = SpriteData.TerrainToString(terrainType); + if(terrainType == Polytopia.Data.TerrainData.Type.Wetland) { - List names = sprite.Split('_').ToList(); - List filteredNames = new List(names); - string style = ""; - foreach (string item in names) - { - string upperitem = char.ToUpper(item[0]) + item[1..]; - if (EnumCache.TryGetType(item, out TribeType tribe) || EnumCache.TryGetType(item, out SkinType skin) - || EnumCache.TryGetType(upperitem, out TribeType tribeUpper) || EnumCache.TryGetType(upperitem, out SkinType skinUpper)) - { - filteredNames.Remove(item); - style = item; - continue; - } - } - string name = string.Join("_", filteredNames); - Sprite? newSprite = Registry.GetSprite(name, style); - if (newSprite != null) - { - completion?.Invoke(atlas, sprite, newSprite); - found = true; - } + cachedSpriteDataReverse.TryAdd(result, EnumCache.GetName(Polytopia.Data.TerrainData.Type.Field) + "_flooded"); + continue; } - } - } - - /// Patches the sprite atlas manager to look up custom sprites. - [HarmonyPostfix] - [HarmonyPatch(typeof(SpriteAtlasManager), nameof(SpriteAtlasManager.DoSpriteLookup))] - private static void SpriteAtlasManager_DoSpriteLookup(ref SpriteAtlasManager.SpriteLookupResult __result, SpriteAtlasManager __instance, string baseName, TribeType tribe, SkinType skin, int level) - { - baseName = Util.FormatSpriteName(baseName); - - Sprite? sprite = Registry.GetSprite(baseName, Util.GetStyle(tribe, skin), level); - if (sprite != null) - __result.sprite = sprite; - } - - #endregion - #region Units - - /// Enables outlines when the interaction bar is shown. - [HarmonyPrefix] - [HarmonyPatch(typeof(InteractionBar), nameof(InteractionBar.Show))] - private static bool InteractionBar_Show(InteractionBar __instance, bool instant, bool force) - { - enableOutlines = true; - return true; - } - - /// Prevents outlines from being created when they are disabled. - [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); - } - - /// Disables outlines after the interaction bar is shown. - [HarmonyPostfix] - [HarmonyPatch(typeof(InteractionBar), nameof(InteractionBar.Show))] - private static void InteractionBar_Show_Postfix(InteractionBar __instance, bool instant, bool force) - { - enableOutlines = false; - } - - /// Sets the current unit type being rendered in the UI. - [HarmonyPrefix] - [HarmonyPatch(typeof(UIUnitRenderer), nameof(UIUnitRenderer.CreateUnit))] - private static bool UIUnitRenderer_CreateUnit_Prefix(UIUnitRenderer __instance) - { - currentUnitTypeUI = __instance.unitType; - return true; - } - - /// Resets the current unit type being rendered in the UI. - [HarmonyPostfix] - [HarmonyPatch(typeof(UIUnitRenderer), nameof(UIUnitRenderer.CreateUnit))] - private static void UIUnitRenderer_CreateUnit_Postfix(UIUnitRenderer __instance) - { - currentUnitTypeUI = UnitData.Type.None; - } - /// Skins the visual parts of a unit with custom sprites. - [HarmonyPostfix] - [HarmonyPatch(typeof(SkinVisualsRenderer), nameof(SkinVisualsRenderer.SkinWorldObject))] - private static void SkinVisualsRenderer_SkinWorldObject( - SkinVisualsRenderer.SkinWorldType type, - SkinVisualsReference skinVisuals, - SkinVisualsTransientData transientSkinData, - bool checkOutlines, - int level) - { - if (type != SkinVisualsRenderer.SkinWorldType.Unit || skinVisuals == null || - skinVisuals.visualParts == null || transientSkinData == null) - return; - - Unit unit = skinVisuals.gameObject.GetComponent(); - - string unitTypeName = EnumCache.GetName(UnitData.Type.Warrior); - - if (unit != null && unit.UnitData != null) - unitTypeName = EnumCache.GetName(unit.unitData.type); - if (currentUnitTypeUI != UnitData.Type.None) - unitTypeName = EnumCache.GetName(currentUnitTypeUI); - - string style = Util.GetStyle(transientSkinData.unitSettings.tribe, transientSkinData.unitSettings.skin); + if(result == SpriteData.TILE_UNKNOWN) + continue; - foreach (var visualPart in skinVisuals.visualParts) - { - if (visualPart.visualPart != null) - UpdateVisualPart(visualPart, $"{visualPart.visualPart.name}_{unitTypeName}", style); + cachedSpriteDataReverse.TryAdd(result, EnumCache.GetName(terrainType)); } - } - [HarmonyPostfix] - [HarmonyPatch(typeof(PrefabManager), nameof(PrefabManager.GetPrefab), - typeof(UnitData.Type), typeof(TribeType), typeof(SkinType) - )] - private static void PrefabManager_GetPrefab(ref Unit __result, UnitData.Type type, TribeType tribe, SkinType skin) - { - UnitPrefabInfo unitPrefabInfo = new( - EnumCache.GetName(type), - EnumCache.GetName(TribeType.None), - EnumCache.GetName(SkinType.Default) - ); - if (customPrefabs.ContainsKey( - unitPrefabInfo - )) + foreach (ResourceData.Type resourceType in Enum.GetValues()) { - __result = customPrefabs[unitPrefabInfo]; - } - } - - #endregion - #region Level - - /// Updates the visual parts of a resource with custom sprites. - [HarmonyPostfix] - [HarmonyPatch(typeof(Resource), nameof(Resource.UpdateObject), typeof(MapRenderContext), typeof(SkinVisualsTransientData))] - private static void Resource_UpdateObject(Resource __instance, MapRenderContext ctx, SkinVisualsTransientData transientSkinData) - { - if (__instance.data != null) - { - string style = Util.GetStyle(__instance.tile.data.climate, __instance.tile.data.Skin); - string name = EnumCache.GetName(__instance.tile.data.resource.type); - - foreach (SkinVisualsReference.VisualPart visualPart in __instance.GetSkinVisualsReference().visualParts) - { - UpdateVisualPart(visualPart, name, style); - } - } - } + string result = SpriteData.ResourceToString(resourceType); + if(result == SpriteData.IMPROVEMENT_PLACEHOLDER) + continue; - /// Updates a building with a custom sprite. - [HarmonyPostfix] - [HarmonyPatch(typeof(Building), nameof(Building.UpdateObject), typeof(MapRenderContext), typeof(SkinVisualsTransientData))] - private static void Building_UpdateObject(Building __instance, MapRenderContext ctx, SkinVisualsTransientData transientSkinData) - { - string style = Util.GetStyle(transientSkinData.foundingTribeSettings.tribe, transientSkinData.foundingTribeSettings.skin); - string name = EnumCache.GetName(__instance.tile.data.improvement.type); - Sprite? sprite = Registry.GetSprite(name, style, __instance.Level); - if (sprite != null) - { - __instance.Sprite = sprite; + cachedSpriteDataReverse.TryAdd(result, EnumCache.GetName(resourceType)); } - } - - /// Updates the terrain graphics with custom sprites. - [HarmonyPostfix] - [HarmonyPatch(typeof(TerrainRenderer), nameof(TerrainRenderer.UpdateGraphics))] - private static void TerrainRenderer_UpdateGraphics(TerrainRenderer __instance, Tile tile) - { - string terrain = EnumCache.GetName(tile.data.terrain) ?? string.Empty; - - TribeType tribe = tile.data.climate; - SkinType skinType = tile.data.Skin; - string flood = ""; - if ( - tile.data.effects.Contains(TileData.EffectType.Flooded) || - (tribe == TribeType.Aquarion && tile.data.terrain == Polytopia.Data.TerrainData.Type.Mountain) - ) // TODO: Check all tribes with flooded abil instead of Aquarion only. - { - foreach (var effect in tile.data.effects) - { - if (customFloodingSkins.ContainsKey(effect)) - { - skinType = customFloodingSkins[effect]; - break; - } - } - flood = "_flooded"; - } - if (tile.data.terrain is Polytopia.Data.TerrainData.Type.Forest or Polytopia.Data.TerrainData.Type.Mountain) + foreach (ImprovementData.Type improvementType in Enum.GetValues()) { - string propertyName = terrain.ToLower(); - terrain = "field"; + string result = SpriteData.ImprovementToString(improvementType); + if(result == SpriteData.IMPROVEMENT_PLACEHOLDER) + continue; - PropertyInfo? rendererProperty = tile.GetType().GetProperty(propertyName + "Renderer", - BindingFlags.Public | BindingFlags.Instance); + cachedSpriteDataReverse.TryAdd(result, EnumCache.GetName(improvementType)); + } + } + internal static string FormatSpriteName(string baseName) + { + if(cachedSpriteDataReverse == null) + CacheSpriteNames(); + + if(cachedReversedNames.ContainsKey(baseName)) + return cachedReversedNames[baseName]; + + string formattedName = baseName; + foreach (var valuePair in cachedSpriteDataReverse!) + { + formattedName = formattedName.Replace(valuePair.Key, valuePair.Value); + } + cachedReversedNames[baseName] = formattedName; + return formattedName; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(SpriteAtlasManager), nameof(SpriteAtlasManager.TrySolveSpriteNameForLevelSprites))] + private static void SpriteAtlasManager_TrySolveSpriteNameForLevelSprites( + ref bool __result, + SpriteAtlasManager __instance, + Il2CppInterop.Runtime.InteropTypes.Arrays.Il2CppStringArray spriteNameVariants, int level, ref string result) + { + if(!__result) __result = true; // will crash original lookup if false.... + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(SpriteAtlasManager), nameof(SpriteAtlasManager.DoSpriteLookup))] + private static void SpriteAtlasManager_DoSpriteLookup( + ref SpriteAtlasManager.SpriteLookupResult __result, + SpriteAtlasManager __instance, + string baseName, TribeType tribe, SkinType skin, int level) + { + ApplyRegisteredSprites(ref __result, baseName, tribe, skin, level); + + string formattedName = FormatSpriteName(baseName); + if (formattedName != baseName) + { + ApplyRegisteredSprites(ref __result, formattedName, tribe, skin, level); + } + } + + private static void ApplyRegisteredSprites( + ref SpriteAtlasManager.SpriteLookupResult lookupResult, + string spriteName, TribeType tribe, SkinType skin, int level) + { + Sprite? registeredSprite = Registry.GetSprite(spriteName, tribe, skin, level); + if (registeredSprite != null) + lookupResult.sprite = registeredSprite; + + Sprite? registeredOutlineSprite = Registry.GetSprite(spriteName + "_outline", tribe, skin, level); + if (registeredOutlineSprite != null) + lookupResult.outlineSprite = registeredOutlineSprite; + } + + [HarmonyPrefix] // its obsolete so may be removed soon by midjiwan + [HarmonyPatch(typeof(SpriteAtlasManager), nameof(SpriteAtlasManager.LoadSprite), new Type[]{ typeof(string), typeof(string), typeof(SpriteCallback) })] + private static bool LoadSprite(string atlas, string sprite, SpriteCallback completion) + { + string formattedName = FormatSpriteName(sprite); + + Sprite? resolvedSprite = Registry.GetSprite(formattedName); + + if (resolvedSprite == null && formattedName != sprite) + { + resolvedSprite = Registry.GetSprite(sprite); + } + + if (resolvedSprite == null) + { + return true; + } + + completion.Invoke(atlas, sprite, resolvedSprite); + return false; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(SpriteData), nameof(SpriteData.TerrainToString))] + private static void SpriteData_TerrainToString(ref string __result, Polytopia.Data.TerrainData.Type terrain) + { + if((int)terrain >= Plugin.AUTOIDX_STARTS_FROM) + __result = EnumCache.GetName(terrain); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(SpriteData), nameof(SpriteData.ResourceToString))] + private static void SpriteData_ResourceToString(ref string __result, ResourceData.Type resource) + { + if((int)resource >= Plugin.AUTOIDX_STARTS_FROM) + __result = EnumCache.GetName(resource); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(SpriteData), nameof(SpriteData.ImprovementToString))] + private static void SpriteData_ImprovementToString(ref string __result, ImprovementData.Type improvement) + { + if((int)improvement >= Plugin.AUTOIDX_STARTS_FROM) + __result = EnumCache.GetName(improvement); + } - if (rendererProperty != null) - { - PolytopiaSpriteRenderer? renderer = (PolytopiaSpriteRenderer?)rendererProperty.GetValue(tile); - if (renderer != null) - { - Sprite? additionalSprite = Registry.GetSprite(propertyName + flood, Util.GetStyle(tribe, skinType)); - if (additionalSprite != null) - { - renderer.Sprite = additionalSprite; - rendererProperty.SetValue(tile, renderer); - } - } - } - } + #endregion + #region Level - Sprite? sprite = Registry.GetSprite(terrain + flood, Util.GetStyle(tribe, skinType)); - if (sprite != null) - { - __instance.spriteRenderer.Sprite = sprite; - } - } + [HarmonyPostfix] + [HarmonyPatch(typeof(Tile), nameof(Tile.RenderDebug))] + public static void Tile_RenderDebug(Tile __instance) + { + if(!Plugin.config.showCoordinates) + return; + + GameObject textObj = new GameObject("CoordinateText"); + textObj.transform.SetParent(__instance.transform); + textObj.transform.localPosition = new Vector3(0, 0.0f, 0); + + TextMeshPro textMesh = textObj.AddComponent(); + textMesh.text = __instance.Data.coordinates.ToString(); + textMesh.fontSize = 3; + textMesh.alignment = TextAlignmentOptions.Center; + textMesh.color = Color.white; + + MeshRenderer renderer = textObj.GetComponent(); + renderer.sortingLayerID = MeshCache.TERRAIN_LAYER_ID; + renderer.sortingOrder = __instance.Depth + 10; + } + + private static string GetLandSpriteName(TileData tileData) + { + if (!tileData.HasEffect(TileData.EffectType.Flooded)) + { + return SpriteData.TerrainToString(Polytopia.Data.TerrainData.Type.Field); + } + + string wetlandSpriteName = SpriteData.TerrainToString(Polytopia.Data.TerrainData.Type.Wetland); + + if (tileData.HasEffect(TileData.EffectType.Swamped)) + { + return wetlandSpriteName + "_swamp"; + } + + foreach (TileData.EffectType effectType in tileData.effects) + { + if (customFloodingSkins.TryGetValue(effectType, out SkinType skinType)) + { + return $"{wetlandSpriteName}_{EnumCache.GetName(skinType)}"; + } + } + + return wetlandSpriteName; + } + + /// Updates the terrain graphics with custom sprites. + [HarmonyPrefix] + [HarmonyPatch(typeof(TerrainRenderer), nameof(TerrainRenderer.UpdateGraphics))] // Why prefix? I really hate repeating the logic. I would have to do the same bs 2 times.... anyway reviewers tell me ok? + private static bool TerrainRenderer_UpdateGraphics(TerrainRenderer __instance, Tile tile, TribeType climate, SkinType skin, bool shouldDesaturate) + { + + string spriteName = tile.Data.terrain switch + { + Polytopia.Data.TerrainData.Type.Water => __instance.waterSprites.GetSpriteName(tile), + Polytopia.Data.TerrainData.Type.Ocean => __instance.oceanSprites.GetSpriteName(tile), + Polytopia.Data.TerrainData.Type.Ice => __instance.iceSprites.GetSpriteName(tile), + _ => GetLandSpriteName(tile.Data), + }; + + SpriteAtlasManager.SpriteLookupResult spriteLookupResult = GameManager.GetSpriteAtlasManager().DoSpriteLookup(spriteName, climate, skin); + if (spriteLookupResult.HasSprite()) + { + __instance.spriteRenderer.Sprite = spriteLookupResult.sprite; + } + if (shouldDesaturate != __instance.isDesaturated) + { + TerrainMaterialHelper.SetSpriteSaturated(__instance.SpriteRenderer, shouldDesaturate); + __instance.isDesaturated = shouldDesaturate; + } + + return false; + } /// Adds custom flood tile effect when tile is flooded for custom aqua skins. [HarmonyPostfix] @@ -396,372 +340,19 @@ public static void GetTileTipHeader(ref string __result, TileData tileData, Skin } } - /// Forces an update of the mesh for a PolytopiaSpriteRenderer with a custom sprite. - [HarmonyPostfix] - [HarmonyPatch(typeof(PolytopiaSpriteRenderer), nameof(PolytopiaSpriteRenderer.ForceUpdateMesh))] - private static void PolytopiaSpriteRenderer_ForceUpdateMesh(PolytopiaSpriteRenderer __instance) - { - if (__instance.sprite != null && string.IsNullOrEmpty(__instance.atlasName)) - { - MaterialPropertyBlock materialPropertyBlock = new(); - materialPropertyBlock.SetVector("_Flip", new Vector4(1f, 1f, 0f, 0f)); - materialPropertyBlock.SetTexture("_MainTex", __instance.sprite.texture); - __instance.meshRenderer.SetPropertyBlock(materialPropertyBlock); - } - } + /// Forces an update of the mesh for a PolytopiaSpriteRenderer with a custom sprite. + [HarmonyPostfix] + [HarmonyPatch(typeof(PolytopiaSpriteRenderer), nameof(PolytopiaSpriteRenderer.ForceUpdateMesh))] + private static void PolytopiaSpriteRenderer_ForceUpdateMesh(PolytopiaSpriteRenderer __instance) + { + if (__instance.sprite == null || !string.IsNullOrEmpty(__instance.atlasName)) + return; - [HarmonyPostfix] - [HarmonyPatch(typeof(Tile), nameof(Tile.Render), typeof(MapRenderContext), typeof(SkinVisualsTransientData))] - private static void Tile_Render(Tile __instance, MapRenderContext ctx, SkinVisualsTransientData transientSkinningData) - { - Sprite? sprite = Registry.GetSprite("hidden"); - if (sprite != null) - { - __instance.fogOfWarRenderer.atlasName = ""; - __instance.fogOfWarRenderer.sprite = sprite; - __instance.fogOfWarRenderer.spriteRenderer.sprite = sprite; - } - } - - #endregion - #region Tribe Preview - - [HarmonyPostfix] - [HarmonyPatch(typeof(TribePreviewRegistry), nameof(TribePreviewRegistry.PreloadTribePreviews))] - public static void PreloadTribePreviews() - { - Dictionary previewSaveStateDatas = new(); - - SaveStateData fallbackSaveStateData = TribePreviewRegistry.saveStateDatas[TribePreviewRegistry.fallbackStateIndex]; - foreach (string tribe in Registry.tribePreviews.Keys) - { - string previewId = GetTribePreviewName(tribe); - PreviewTile[] customPreview = Registry.tribePreviews[tribe]; - previewSaveStateDatas[previewId] = new(TribePreviewRegistry.fallbackStateIndex, fallbackSaveStateData, customPreview); - } - - for (int i = 0; i < TribePreviewRegistry.saveStateDatas.Length; i++) - { - SaveStateData saveStateData = TribePreviewRegistry.saveStateDatas[i]; - if (previewSaveStateDatas.ContainsKey(saveStateData.name)) - { - previewSaveStateDatas[saveStateData.name] = new(i, saveStateData, previewSaveStateDatas[saveStateData.name].customPreview); - } - } - - foreach (string previewId in previewSaveStateDatas.Keys) - { - var data = previewSaveStateDatas[previewId]; - SaveStateData? customSaveStateData = ApplyCustomPreview(previewId, data.saveStateData, data.customPreview); - if (customSaveStateData == null) - continue; - - if (data.arrayIdx == TribePreviewRegistry.fallbackStateIndex) - { - List saveStateDatas = TribePreviewRegistry.saveStateDatas.ToList(); - saveStateDatas.Add(customSaveStateData); - TribePreviewRegistry.saveStateDatas = saveStateDatas.ToArray(); - } - else - { - TribePreviewRegistry.saveStateDatas[data.arrayIdx] = customSaveStateData; - } - } - } - - private static string GetTribePreviewName(string tribeType) - { - return "worldpreview_" + tribeType; - } - - private static SaveStateData? ApplyCustomPreview(string previewId, SaveStateData originalPreview, PreviewTile[] preview) - { - SaveStateData? saveStateData = null; - if (DiskSerializationHelpers.FromLZ4CompressedByteArray( - originalPreview.byteArray, out ClientSerializationWrapper clientSerializationWrapper, out int version)) - { - GameState currentGameState = clientSerializationWrapper.GetCurrentGameState(); - foreach (var previewTile in preview) - { - if (previewTile.x == null || previewTile.y == null) - continue; - - WorldCoordinates coordinates = new((int)previewTile.x, (int)previewTile.y); - TileData? tileData = currentGameState.Map.GetTile(coordinates); - if (tileData == null) - continue; - - if (previewTile.terrainType != Polytopia.Data.TerrainData.Type.None) - tileData.terrain = previewTile.terrainType; - - if (previewTile.resourceType != null) - { - ResourceState? resourceState = null; - ResourceData.Type resourceType = (ResourceData.Type)previewTile.resourceType; - if (resourceType != ResourceData.Type.None) - { - resourceState = new ResourceState { type = resourceType }; - } - tileData.resource = resourceState; - } - - if (previewTile.unitType != null) - { - tileData.unit = null; - if (previewTile.unitType != Polytopia.Data.UnitData.Type.None && - currentGameState.TryGetPlayer(currentGameState.CurrentPlayer, out PlayerState playerState) && - currentGameState.GameLogicData.TryGetData((UnitData.Type)previewTile.unitType, out UnitData unitData)) - { - UnitState unitState = ActionUtils.TrainUnit(currentGameState, playerState, tileData, unitData); - unitState.moved = false; - unitState.attacked = false; - } - } - - - if (previewTile.improvementType != null) - { - tileData.improvement = null; - ImprovementData.Type improvementType = (ImprovementData.Type)previewTile.improvementType; - if ( - previewTile.improvementType != ImprovementData.Type.None && - currentGameState.GameLogicData.TryGetData( - improvementType, - out ImprovementData improvementData) - ) - { - tileData.improvement = new ImprovementState - { - type = improvementData.type, - borderSize = (ushort)improvementData.borderSize, - level = 0, - xp = 0, - production = 1, - founded = (ushort)currentGameState.CurrentTurn, - baseScore = (ushort)improvementData.GetScoreReward(), - founder = currentGameState.CurrentPlayer - }; - } - } - } - - saveStateData = ScriptableObject.CreateInstance(); - saveStateData.name = previewId; - saveStateData.byteArray = DiskSerializationHelpers.ToLZ4CompressedByteArray(clientSerializationWrapper, version); - } - - return saveStateData; - } - - [HarmonyPrefix] - [HarmonyPatch(typeof(SpriteCamera), nameof(SpriteCamera.TakeSnapshotOfMapState))] - private static bool SpriteCamera_TakeSnapshotOfMapState(ref Texture2D __result, SpriteCamera.SnapshotMapData mapSnapshot, - float w, float h, TribeType tribe, SkinType skin, TribeType climate, int color, bool clearMapAfterwards) - { - if (!Plugin.config.debug) - return true; - - var instance = SpriteCamera.instance; - if (baseOrthographicCameraSize == null) - baseOrthographicCameraSize = instance.spriteCamera.orthographicSize; - - instance.spriteCamera.orthographicSize = (float)baseOrthographicCameraSize * 2; - isTakingSnapshot = true; - - return true; - } - - [HarmonyPostfix] - [HarmonyPatch(typeof(SpriteCamera), nameof(SpriteCamera.TakeSnapshotOfMapState))] - private static void SpriteCamera_TakeSnapshotOfMapState_Postfix(ref Texture2D __result, SpriteCamera.SnapshotMapData mapSnapshot, - float w, float h, TribeType tribe, SkinType skin, TribeType climate, int color, bool clearMapAfterwards) - { - if (isTakingSnapshot) - isTakingSnapshot = false; - } - - [HarmonyPostfix] - [HarmonyPatch(typeof(Tile), nameof(Tile.RenderDebug))] - public static void Tile_RenderDebug(Tile __instance) - { - if (!isTakingSnapshot) - return; - - GameObject textObj = new GameObject("CoordinateText"); - textObj.transform.SetParent(__instance.transform); - textObj.transform.localPosition = new Vector3(0, 0.0f, 0); - - TextMeshPro textMesh = textObj.AddComponent(); - textMesh.text = __instance.Data.coordinates.ToString(); - textMesh.fontSize = 3; - textMesh.alignment = TextAlignmentOptions.Center; - textMesh.color = Color.white; - - MeshRenderer renderer = textObj.GetComponent(); - renderer.sortingLayerID = MeshCache.TERRAIN_LAYER_ID; - renderer.sortingOrder = __instance.Depth + 10; - } - - #endregion - #region UI - - /// Provides custom sprites for improvements in the UI. - [HarmonyPostfix] - [HarmonyPatch(typeof(UIUtils), nameof(UIUtils.GetImprovementSprite), typeof(ImprovementData.Type), typeof(TribeType), typeof(SkinType), typeof(SpriteAtlasManager))] - private static void UIUtils_GetImprovementSprite(ref Sprite __result, ImprovementData.Type improvement, TribeType tribe, SkinType skin, SpriteAtlasManager atlasManager) - { - Sprite? sprite = Registry.GetSprite(EnumCache.GetName(improvement), Util.GetStyle(tribe, skin)); - if (sprite != null) - { - __result = sprite; - } - } - - /// Provides custom sprites for improvements in the UI. - [HarmonyPostfix] - [HarmonyPatch(typeof(UIUtils), nameof(UIUtils.GetImprovementSprite), typeof(SkinVisualsTransientData), typeof(ImprovementData.Type), typeof(SpriteAtlasManager))] - private static void UIUtils_GetImprovementSprite_2(ref Sprite __result, SkinVisualsTransientData data, ImprovementData.Type improvement, SpriteAtlasManager atlasManager) - { - UIUtils_GetImprovementSprite(ref __result, improvement, data.foundingTribeSettings.tribe, data.foundingTribeSettings.skin, atlasManager); - } - - /// Provides custom sprites for resources in the UI. - [HarmonyPostfix] - [HarmonyPatch(typeof(UIUtils), nameof(UIUtils.GetResourceSprite))] - private static void UIUtils_GetResourceSprite(ref Sprite __result, SkinVisualsTransientData data, ResourceData.Type resource, SpriteAtlasManager atlasManager) - { - Sprite? sprite = Registry.GetSprite(EnumCache.GetName(resource), Util.GetStyle(data.tileClimateSettings.tribe, data.tileClimateSettings.skin)); - if (sprite != null) - { - __result = sprite; - } - } - - #endregion - #region Houses - - /// Provides custom sprites for houses in the city view. - [HarmonyPostfix] - [HarmonyPatch(typeof(CityRenderer), nameof(CityRenderer.GetHouse))] - private static void CityRenderer_GetHouse(ref PolytopiaSpriteRenderer __result, CityRenderer __instance, TribeType tribe, int type, SkinType skinType) - { - PolytopiaSpriteRenderer polytopiaSpriteRenderer = __result; - - if (type != __instance.HOUSE_WORKSHOP && type != __instance.HOUSE_PARK) - { - Sprite? sprite = Registry.GetSprite("house", Util.GetStyle(tribe, skinType), type); - if (sprite != null) - { - polytopiaSpriteRenderer.Sprite = sprite; - TerrainMaterialHelper.SetSpriteSaturated(polytopiaSpriteRenderer, __instance.IsEnemyCity); - __result = polytopiaSpriteRenderer; - } - } - } - - /// Provides custom sprites for houses in the UI city renderer. - [HarmonyPostfix] - [HarmonyPatch(typeof(UICityRenderer), nameof(UICityRenderer.GetResource))] - private static void UICityRenderer_GetResource(ref GameObject __result, string baseName, TribeType tribe, SkinType skin) - { - Image imageComponent = __result.GetComponent(); - string[] tokens = baseName.Split('_'); - if (tokens.Length > 0) - { - if (tokens[0] == "House") - { - int level = 0; - if (tokens.Length > 1) - { - _ = int.TryParse(tokens[1], out level); - } - - Sprite? sprite = Registry.GetSprite("house", Util.GetStyle(tribe, skin), level); - if (sprite == null) - { - return; - } - imageComponent.sprite = sprite; - imageComponent.SetNativeSize(); - } - } - } - - #endregion - #region Icons - - /// Provides custom sprites for UI icons. - [HarmonyPostfix] - [HarmonyPatch(typeof(UIIconData), nameof(UIIconData.GetImage))] - private static void UIIconData_GetImage(ref Image __result, string id) - { - Sprite? sprite; - if (GameManager.LocalPlayer != null) - { - sprite = Registry.GetSprite(id, Util.GetStyle(GameManager.LocalPlayer.tribe, GameManager.LocalPlayer.skinType)); - } - else - { - sprite = Registry.GetSprite(id); - } - if (sprite != null) - { - __result.sprite = sprite; - __result.useSpriteMesh = true; - __result.SetNativeSize(); - } - } - - /// Provides custom sprites for face icons in the game info row. - [HarmonyPostfix] - [HarmonyPatch(typeof(GameInfoRow), nameof(GameInfoRow.LoadFaceIcon), typeof(TribeType), typeof(SkinType))] - private static void GameInfoRow_LoadFaceIcon(GameInfoRow __instance, TribeType type, SkinType skinType) - { - string style = EnumCache.GetName(type); - - if (style == "None") - { - for (int i = 0; i < 20; i++) - { - type += byte.MaxValue + 1; - style = EnumCache.GetName(type); - - if (style != "None") - { - break; - } - } - } - - Sprite? sprite = Registry.GetSprite("head", Util.GetStyle(type, skinType)); - - if (sprite != null) - { - __instance.SetFaceIcon(sprite); - } - - if (__instance.icon.sprite == null) - { - __instance.LoadFaceIcon(SpriteData.SpecialFaceIcon.neutral); - } - } - - /// Provides custom sprites for player info icons. - [HarmonyPostfix] - [HarmonyPatch(typeof(PlayerInfoIcon), nameof(PlayerInfoIcon.SetData), typeof(TribeType), typeof(SkinType), typeof(SpriteData.SpecialFaceIcon), typeof(Color), typeof(DiplomacyRelationState), typeof(PlayerInfoIcon.Mood))] - private static void PlayerInfoIcon_SetData(PlayerInfoIcon __instance, TribeType tribe, SkinType skin, SpriteData.SpecialFaceIcon face, Color color, DiplomacyRelationState diplomacyState, PlayerInfoIcon.Mood mood) - { - if (face == SpriteData.SpecialFaceIcon.tribe) - { - Sprite? sprite = Registry.GetSprite("head", Util.GetStyle(tribe, skin)); - if (sprite != null) - { - __instance.HeadImage.sprite = sprite; - Vector2 size = sprite.rect.size; - __instance.HeadImage.rectTransform.sizeDelta = size * __instance.rectTransform.GetHeight() / 512f; - } - } - } + MaterialPropertyBlock materialPropertyBlock = new(); + materialPropertyBlock.SetVector("_Flip", new Vector4(1f, 1f, 0f, 0f)); + materialPropertyBlock.SetTexture("_MainTex", __instance.sprite.texture); + __instance.meshRenderer.SetPropertyBlock(materialPropertyBlock); + } #endregion #region Popups @@ -774,40 +365,141 @@ private static void PopupBase_RefreshHeightWhenHeightIsKnownAndDontUseCoroutines UpdateWidth(__instance); } - private static void UpdateWidth(PopupBase __instance) - { - int id = __instance.GetInstanceID(); - if (basicPopupWidths.ContainsKey(id)) - __instance.rectTransform.SetWidth(basicPopupWidths[id]); - } - - /// Sets the attacker's tribe before a unit attacks. - [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; - } - - /// Sets the skin of a weapon's graphics, using custom sprites if available. - [HarmonyPostfix] - [HarmonyPatch(typeof(WeaponGFX), nameof(WeaponGFX.SetSkin))] - private static void WeaponGFX_SetSkin(WeaponGFX __instance, SkinType skinType) - { - if (attackerTribe != TribeType.None) - { - Sprite? sprite = Registry.GetSprite(__instance.defaultSprite.name, Util.GetStyle(attackerTribe, skinType)); - if (sprite != null) - { - __instance.spriteRenderer.sprite = sprite; - } - attackerTribe = TribeType.None; - } - } + private static void UpdateWidth(PopupBase popup) + { + if (!basicPopupWidths.TryGetValue(popup.GetInstanceID(), out PopupWidth? width)) return; + + popup.rectTransform.SetWidth(GetResponsivePopupWidth(width.share, width.maximum)); + BasicPopupLegacy? legacy = popup.TryCast(); + if (legacy != null && legacy.buttonContainer != null) + LayoutPopupButtons(legacy.buttonContainer); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(SystemEvents), nameof(SystemEvents.ScreenSizeChanged))] + private static void SystemEvents_ScreenSizeChanged() + { + RelayoutSizedPopups(); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(SystemEvents), nameof(SystemEvents.SafeAreaChanged))] + private static void SystemEvents_SafeAreaChanged() + { + RelayoutSizedPopups(); + } + + private static void RelayoutSizedPopups() + { + foreach (PopupWidth width in new List(basicPopupWidths.Values)) + { + try + { + if (width.popup == null || !width.popup.IsShowing()) continue; + width.popup.RefreshHeightWhenHeightIsKnownAndDontUseCoroutines(ScreenManager.SafeHeight); + } + catch (Exception exception) + { + Plugin.logger.LogError($"Could not lay a popup out after a screen size change: {exception}"); + } + } + } + + /// + /// Turns a share of the usable screen width into a popup width in canvas units. + /// The safe area is reported in screen pixels, so it is converted with the canvas scale the + /// game itself uses when it sizes popups. + /// + /// Share of the usable width to take, between 0 and 1. + /// Widest the popup may get, so that text stays readable on wide screens. + /// A width in canvas units, never below the width of the game's own popups. + public static int GetResponsivePopupWidth(float share, float maximum) + { + if (share <= 0f) return Mathf.RoundToInt(maximum); + float usable = ScreenManager.SafeWidth * UICanvasScalerHelper.GetInvertedUIScale(); + float width = Mathf.Min(usable * share, maximum); + return Mathf.RoundToInt(Mathf.Max(width, DEFAULT_POPUP_WIDTH)); + } + + /// + /// Replaces the game's popup button layout. The game spaces the buttons with an integer division, + /// which stacks every button but the last on the left edge, and it never wraps, so a popup too + /// narrow for its buttons draws them on top of each other. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(PopupButtonContainer), nameof(PopupButtonContainer.SetButtonData))] + private static void PopupButtonContainer_SetButtonData(PopupButtonContainer __instance) + { + LayoutPopupButtons(__instance); + } + + /// + /// Lays a popup's buttons out in as many rows as the container's width needs. + /// + internal static void LayoutPopupButtons(PopupButtonContainer container) + { + if (container.buttons == null || container.buttons.Length == 0) return; + + float available = container.rectTransform.rect.width; + if (available <= 0f) return; + + List> rows = PackButtonsIntoRows(container, available); + + float rowHeight = 0f; + foreach (UITextButton button in container.buttons) + { + rowHeight = Mathf.Max(rowHeight, button.rectTransform.rect.height); + } + float stackHeight = rows.Count * rowHeight + (rows.Count - 1) * POPUP_BUTTON_ROW_GAP; + + // The container sits at the bottom of the popup with a bottom pivot, so it grows upwards. + container.rectTransform.SetHeight(stackHeight); + + for (int row = 0; row < rows.Count; row++) + { + float offsetY = stackHeight / 2f - rowHeight / 2f - row * (rowHeight + POPUP_BUTTON_ROW_GAP); + PlaceButtonRow(rows[row], offsetY); + } + } + + /// + /// Fills one row at a time until the next button would no longer fit beside the previous ones. + /// + private static List> PackButtonsIntoRows(PopupButtonContainer container, float available) + { + List> rows = new() { new() }; + float rowWidth = 0f; + foreach (UITextButton button in container.buttons) + { + float width = button.rectTransform.rect.width; + if (rows[^1].Count > 0 && rowWidth + POPUP_BUTTON_GAP + width > available) + { + rows.Add(new()); + rowWidth = 0f; + } + rowWidth += (rows[^1].Count > 0 ? POPUP_BUTTON_GAP : 0f) + width; + rows[^1].Add(button); + } + return rows; + } + + /// + /// Spreads one row of buttons across the container, or centres it when it holds a single button. + /// + private static void PlaceButtonRow(List row, float offsetY) + { + for (int i = 0; i < row.Count; i++) + { + // Anchoring to a fraction of the container keeps the row spread as the popup resizes, + // which is how the game places the buttons of its own popups. + Vector2 anchor = new((row.Count == 1) ? 0.5f : (i / (row.Count - 1.0f)), 0.5f); + RectTransform rect = row[i].rectTransform; + rect.anchorMin = anchor; + rect.anchorMax = anchor; + rect.pivot = anchor; + rect.anchoredPosition = new Vector2(0f, offsetY); + } + } /// Removes a popup's custom width when it is hidden. [HarmonyPostfix] @@ -817,12 +509,19 @@ private static void PopupBase_Hide(PopupBase __instance) basicPopupWidths.Remove(__instance.GetInstanceID()); } - [HarmonyPrefix] - [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnWeeklyChallengeClicked))] - private static bool StartScreen_OnWeeklyChallengeClicked(StartScreen_UI2 __instance) + [HarmonyPostfix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnShow))] + private static void StartScreen_UI2_OnShow() { - if (seenWarningWCPopup) - return true; + CreateInnerOriginSprites(); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnWeeklyChallengeClicked))] + private static bool StartScreen_OnWeeklyChallengeClicked(StartScreen_UI2 __instance) + { + if(seenWarningWCPopup) + return true; BasicPopup popup = PopupManager.GetBasicPopup(); popup.Header = Localization.Get("polymod.hub"); @@ -848,30 +547,19 @@ void WCProceed() return false; } - /// Shows a basic popup with a custom width. - public static void ShowSetWidth(this BasicPopupLegacy self, int width) - { - basicPopupWidths.Add(self.GetInstanceID(), width); - self.Show(); - } + /// Shows a basic popup with a custom width. + public static void ShowSetWidth(this BasicPopupLegacy self, int width) + { + basicPopupWidths[self.GetInstanceID()] = new(self, 0f, width); + self.Show(); + } - // [HarmonyPrefix] - // [HarmonyPatch(typeof(BasicPopup), nameof(BasicPopup.SetButtonData))] - // public static bool SetButtonData(BasicPopup __instance, PopupBase.PopupButtonData[] buttonDatas, bool updateOmniCursor) - // { - // if (buttonDatas == null) - // { - // __instance.SetDefaultOkbutton(); - // return false; - // } - // foreach (var buttonData in buttonDatas) - // { - // var MainButton = __instance.createButton(buttonData.text, buttonData.callback, UIButtonBase_UI2.ButtonStyle.Suggested); - // MainButton.OnClickedSignal.Add(DelegateSupport.ConvertDelegate(__instance.Hide)); - // } - // __instance.RunLayout(); - // return false; - // } + /// Shows a basic popup sized to a share of the usable screen width. + public static void ShowSetWidth(this BasicPopupLegacy self, float share, float maximum) + { + basicPopupWidths[self.GetInstanceID()] = new(self, share, maximum); + self.Show(); + } #endregion @@ -889,71 +577,410 @@ internal static BasicPopupLegacy GetBasicPopupLegacy() return basicPopupLegacy; } - /// Updates a visual part with a custom sprite. - private static void UpdateVisualPart(SkinVisualsReference.VisualPart? visualPart, string name, string style) - { - if (visualPart == null || visualPart.visualPart == null) - return; - - Sprite? sprite = Registry.GetSprite(name, style) ?? Registry.GetSprite(visualPart.visualPart.name, style); - - if (sprite != null && visualPart.renderer != null) - { - if (visualPart.renderer.spriteRenderer != null) - visualPart.renderer.spriteRenderer.sprite = sprite; - else if (visualPart.renderer.polytopiaSpriteRenderer != null) - visualPart.renderer.polytopiaSpriteRenderer.sprite = sprite; - } + public static Texture2D BuildTexture(byte[] data) + { + Texture2D texture = new(1, 1, TextureFormat.RGBA32, true); + texture.LoadImage(data); + texture.filterMode = FilterMode.Trilinear; + texture.Apply(); + return texture; + } + + /// Builds a sprite from raw byte data. + /// The raw byte data of the image. + /// The pivot point of the sprite. + /// The number of pixels per unit for the sprite. + /// The created sprite. + public static Sprite BuildSprite(byte[] data, Vector2? pivot = null, float pixelsPerUnit = 2112f, bool transformToOutline = false, float? gaussianBlurRadius = null) + { + return BuildSpriteWithTexture(BuildTexture(data), pivot, pixelsPerUnit, transformToOutline, gaussianBlurRadius); + } + + /// Builds a sprite from a texture. + /// The texture to create the sprite from. + /// The pivot point of the sprite. + /// The number of pixels per unit for the sprite. + /// The created sprite. + public static Sprite BuildSpriteWithTexture(Texture2D texture, Vector2? pivot = null, float? pixelsPerUnit = 2112f, bool transformToOutline = false, float? gaussianBlurRadius = null) + { + if(transformToOutline) texture = TransformToOutline(texture); + + if(gaussianBlurRadius != null) texture = ApplyGaussianBlur(texture, gaussianBlurRadius.Value); + + return Sprite.Create( + texture, + new(0, 0, texture.width, texture.height), + pivot ?? new(0.5f, 0.5f), + pixelsPerUnit ?? 2112f + );; + } + + public static Texture2D TransformToOutline(Texture2D sourceTexture, int borderSizeInPixels = 8, Color? color = null) + { + Plugin.logger.LogInfo("Transforming texture to outline..."); + if(color == null) + color = Color.white; + + int sourceWidthInPixels = sourceTexture.width; + int sourceHeightInPixels = sourceTexture.height; + int outputWidthInPixels = sourceWidthInPixels + borderSizeInPixels * 2; + int outputHeightInPixels = sourceHeightInPixels + borderSizeInPixels * 2; + + Color[] sourcePixels = sourceTexture.GetPixels(); + bool[] filledMask = new bool[outputWidthInPixels * outputHeightInPixels]; + int borderSquared = borderSizeInPixels * borderSizeInPixels; + + for (int pixelY = 0; pixelY < sourceHeightInPixels; pixelY++) + { + for (int pixelX = 0; pixelX < sourceWidthInPixels; pixelX++) + { + if (sourcePixels[pixelY * sourceWidthInPixels + pixelX].a <= 0.01f) continue; - Sprite? outlineSprite = Registry.GetSprite($"{name}_outline", style) ?? Registry.GetSprite($"{visualPart.visualPart.name}_outline", style); + int centerX = pixelX + borderSizeInPixels; + int centerY = pixelY + borderSizeInPixels; - if (outlineSprite != null && visualPart.outlineRenderer != null) - { - if (visualPart.outlineRenderer.spriteRenderer != null) - visualPart.outlineRenderer.spriteRenderer.sprite = outlineSprite; - else if (visualPart.outlineRenderer.polytopiaSpriteRenderer != null) - visualPart.outlineRenderer.polytopiaSpriteRenderer.sprite = outlineSprite; - } - } + for (int offsetY = -borderSizeInPixels; offsetY <= borderSizeInPixels; offsetY++) + { + for (int offsetX = -borderSizeInPixels; offsetX <= borderSizeInPixels; offsetX++) + { + if (offsetX * offsetX + offsetY * offsetY > borderSquared) continue; - /// Builds a sprite from raw byte data. - /// The raw byte data of the image. - /// The pivot point of the sprite. - /// The number of pixels per unit for the sprite. - /// The created sprite. - public static Sprite BuildSprite(byte[] data, Vector2? pivot = null, float pixelsPerUnit = 2112f) - { - Texture2D texture = new(1, 1, TextureFormat.RGBA32, true); - texture.LoadImage(data); - Color[] pixels = texture.GetPixels(); - for (int i = 0; i < pixels.Length; i++) - { - pixels[i] = new Color(pixels[i].r, pixels[i].g, pixels[i].b, pixels[i].a); + filledMask[(centerY + offsetY) * outputWidthInPixels + (centerX + offsetX)] = true; + } + } + } } - texture.SetPixels(pixels); - texture.filterMode = FilterMode.Trilinear; - texture.Apply(); - return BuildSpriteWithTexture(texture, pivot, pixelsPerUnit); - } - /// Builds a sprite from a texture. - /// The texture to create the sprite from. - /// The pivot point of the sprite. - /// The number of pixels per unit for the sprite. - /// The created sprite. - 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 ?? 2112f - ); - } - - /// Initializes the Visual manager by patching the necessary methods. - internal static void Init() - { - Harmony.CreateAndPatchAll(typeof(Visual)); - } + Color[] outputPixels = new Color[filledMask.Length]; + for (int pixelIndex = 0; pixelIndex < outputPixels.Length; pixelIndex++) + { + outputPixels[pixelIndex] = filledMask[pixelIndex] ? color.Value : Color.clear; + } + + Texture2D whiteSilhouetteTexture = new Texture2D(outputWidthInPixels, outputHeightInPixels, TextureFormat.RGBA32, false); + whiteSilhouetteTexture.SetPixels(outputPixels); + whiteSilhouetteTexture.Apply(); + return whiteSilhouetteTexture; + } + + private static float[] BuildGaussianKernelWeights(float sigma, int kernelRadiusInPixels) + { + float[] kernelWeights = new float[kernelRadiusInPixels * 2 + 1]; + float kernelWeightSum = 0f; + for (int kernelIndex = 0; kernelIndex < kernelWeights.Length; kernelIndex++) + { + int distanceFromCenter = kernelIndex - kernelRadiusInPixels; + kernelWeights[kernelIndex] = MathF.Exp(-(distanceFromCenter * distanceFromCenter) / (2f * sigma * sigma)); + kernelWeightSum += kernelWeights[kernelIndex]; + } + for (int kernelIndex = 0; kernelIndex < kernelWeights.Length; kernelIndex++) + { + kernelWeights[kernelIndex] /= kernelWeightSum; + } + return kernelWeights; + } + + private static byte ConvertUnitFloatToByte(float unitValue) + { + return (byte)Math.Clamp(unitValue * 255f + 0.5f, 0f, 255f); + } + + // basically ai generated as my bs was working, but 15 times slower + public static Texture2D ApplyGaussianBlur(Texture2D sourceTexture, float radius = 2f) + { + Plugin.logger.LogInfo($"Applying blur with radius {radius}..."); + int textureWidthInPixels = sourceTexture.width; + int textureHeightInPixels = sourceTexture.height; + + float sigma = Math.Max(radius / 2f, 0.01f); + int kernelRadiusInPixels = Math.Max(1, (int)MathF.Ceiling(sigma * 3f)); + float[] kernelWeights = BuildGaussianKernelWeights(sigma, kernelRadiusInPixels); + + Color32[] sourcePixels = sourceTexture.GetPixels32(); + int totalPixelCount = sourcePixels.Length; + const float byteToUnitFloat = 1f / 255f; + + float[] premultipliedChannels = new float[totalPixelCount * 4]; + for (int pixelIndex = 0; pixelIndex < totalPixelCount; pixelIndex++) + { + Color32 sourcePixel = sourcePixels[pixelIndex]; + float alpha = sourcePixel.a * byteToUnitFloat; + int channelOffset = pixelIndex * 4; + premultipliedChannels[channelOffset + 0] = sourcePixel.r * byteToUnitFloat * alpha; + premultipliedChannels[channelOffset + 1] = sourcePixel.g * byteToUnitFloat * alpha; + premultipliedChannels[channelOffset + 2] = sourcePixel.b * byteToUnitFloat * alpha; + premultipliedChannels[channelOffset + 3] = alpha; + } + + float[] horizontallyBlurredChannels = new float[premultipliedChannels.Length]; + Parallel.For(0, textureHeightInPixels, pixelY => + { + int rowStartPixelIndex = pixelY * textureWidthInPixels; + for (int pixelX = 0; pixelX < textureWidthInPixels; pixelX++) + { + float accumulatedRed = 0f, accumulatedGreen = 0f, accumulatedBlue = 0f, accumulatedAlpha = 0f; + for (int kernelIndex = 0; kernelIndex < kernelWeights.Length; kernelIndex++) + { + int sampleX = pixelX + kernelIndex - kernelRadiusInPixels; + if (sampleX < 0) sampleX = 0; + else if (sampleX >= textureWidthInPixels) sampleX = textureWidthInPixels - 1; + + int sampleOffset = (rowStartPixelIndex + sampleX) * 4; + float kernelWeight = kernelWeights[kernelIndex]; + accumulatedRed += premultipliedChannels[sampleOffset + 0] * kernelWeight; + accumulatedGreen += premultipliedChannels[sampleOffset + 1] * kernelWeight; + accumulatedBlue += premultipliedChannels[sampleOffset + 2] * kernelWeight; + accumulatedAlpha += premultipliedChannels[sampleOffset + 3] * kernelWeight; + } + int outputOffset = (rowStartPixelIndex + pixelX) * 4; + horizontallyBlurredChannels[outputOffset + 0] = accumulatedRed; + horizontallyBlurredChannels[outputOffset + 1] = accumulatedGreen; + horizontallyBlurredChannels[outputOffset + 2] = accumulatedBlue; + horizontallyBlurredChannels[outputOffset + 3] = accumulatedAlpha; + } + }); + + Color32[] blurredPixels = new Color32[totalPixelCount]; + Parallel.For(0, textureHeightInPixels, pixelY => + { + for (int pixelX = 0; pixelX < textureWidthInPixels; pixelX++) + { + float accumulatedRed = 0f, accumulatedGreen = 0f, accumulatedBlue = 0f, accumulatedAlpha = 0f; + for (int kernelIndex = 0; kernelIndex < kernelWeights.Length; kernelIndex++) + { + int sampleY = pixelY + kernelIndex - kernelRadiusInPixels; + if (sampleY < 0) sampleY = 0; + else if (sampleY >= textureHeightInPixels) sampleY = textureHeightInPixels - 1; + + int sampleOffset = (sampleY * textureWidthInPixels + pixelX) * 4; + float kernelWeight = kernelWeights[kernelIndex]; + accumulatedRed += horizontallyBlurredChannels[sampleOffset + 0] * kernelWeight; + accumulatedGreen += horizontallyBlurredChannels[sampleOffset + 1] * kernelWeight; + accumulatedBlue += horizontallyBlurredChannels[sampleOffset + 2] * kernelWeight; + accumulatedAlpha += horizontallyBlurredChannels[sampleOffset + 3] * kernelWeight; + } + + Color32 blurredPixel = default; + if (accumulatedAlpha > 0.0001f) + { + blurredPixel.r = ConvertUnitFloatToByte(accumulatedRed / accumulatedAlpha); + blurredPixel.g = ConvertUnitFloatToByte(accumulatedGreen / accumulatedAlpha); + blurredPixel.b = ConvertUnitFloatToByte(accumulatedBlue / accumulatedAlpha); + blurredPixel.a = ConvertUnitFloatToByte(accumulatedAlpha); + } + blurredPixels[pixelY * textureWidthInPixels + pixelX] = blurredPixel; + } + }); + + Texture2D blurredTexture = new Texture2D(textureWidthInPixels, textureHeightInPixels, TextureFormat.RGBA32, false); + blurredTexture.SetPixels32(blurredPixels); + blurredTexture.Apply(); + return blurredTexture; + } + + internal static List> pendingInnerOriginSpriteInfos = new(); + + private static Vector2 GetPivotPreset(string spriteName) + { + return spriteName.Split("_")[0] switch + { + "field" => new Vector2(0.5f, 0.0f), + "mountain" => new Vector2(0.5f, -0.375f), + _ => new Vector2(0.5f, 0.5f), + }; + } + + internal static void RegisterSprite(SpriteAtlasManager spriteAtlasManager, string spriteName, Sprite sprite) + { + Plugin.logger.LogInfo($"Registering sprite {spriteName}"); + spriteAtlasManager.cachedSprites.TryAdd("Heads", new()); + spriteAtlasManager.cachedSprites["Heads"][spriteName] = sprite; + Registry.sprites[spriteName] = sprite; + } + + // Runs early: only touches your own textures, nothing from SpriteAtlasManager's lookup state. + public static void CreateSprites() + { + Plugin.logger.LogInfo($"Creating sprites with outer origin..."); + pendingInnerOriginSpriteInfos.Clear(); + + SpriteAtlasManager spriteAtlasManager = GameManager.GetSpriteAtlasManager(); + const float defaultPixelsPerUnit = 2112f; + + foreach (KeyValuePair spriteInfoPair in Registry.spriteInfos) + { + string spriteName = spriteInfoPair.Key; + SpriteInfo spriteInfo = spriteInfoPair.Value; + + bool isInnerOrigin = spriteInfo.spriteOrigin != null + && spriteInfo.spriteOrigin.originType == OriginType.Inner; + + if (isInnerOrigin) + { + pendingInnerOriginSpriteInfos.Add(spriteInfoPair); + continue; + } + + KeyValuePair textureEntry; + if (!Registry.textures.TryGetValue(spriteName, out textureEntry)) // search texture + { + if (spriteInfo.spriteOrigin == null || + spriteInfo.spriteOrigin.originType != OriginType.Outer || + spriteInfo.spriteOrigin.name == null || + !Registry.textures.TryGetValue(spriteInfo.spriteOrigin.name, out textureEntry)) // search texture in outer sprite origin + { + continue; + } + } + + + Vector2 pivot = spriteInfo.pivot ?? GetPivotPreset(spriteName); + float pixelsPerUnit = spriteInfo.pixelsPerUnit ?? defaultPixelsPerUnit; + bool transformToOutline = spriteInfo.transformToOutline; + float? gaussianBlurRadius = spriteInfo.gaussianBlurRadius; + + Sprite sprite = BuildSpriteWithTexture(textureEntry.Key, pivot, pixelsPerUnit, transformToOutline, gaussianBlurRadius); + Registry.textures[spriteName] = new KeyValuePair(textureEntry.Key, true); + RegisterSprite(spriteAtlasManager, spriteName, sprite); + } + + foreach (KeyValuePair> texturePair in Registry.textures) + { + if (texturePair.Value.Value) + { + continue; + } + + string spriteName = texturePair.Key; + Sprite sprite = BuildSpriteWithTexture(texturePair.Value.Key, GetPivotPreset(spriteName), defaultPixelsPerUnit); + RegisterSprite(spriteAtlasManager, spriteName, sprite); + } + } + + internal static (byte[], SpriteInfo) SpriteToPng(Sprite sprite) + { + Texture2D bakedTexture = BakeSpriteToTexture(sprite); + + byte[] pngBytes = ImageConversion.EncodeToPNG(bakedTexture); + UnityEngine.Object.Destroy(bakedTexture); + + Vector2 normalizedPivot = GetNormalizedPivot(sprite); + + return (pngBytes, new SpriteInfo(sprite.pixelsPerUnit, normalizedPivot)); + } + + private static Vector2 GetNormalizedPivot(Sprite sourceSprite) + { + return new Vector2( + sourceSprite.pivot.x / sourceSprite.rect.width, + sourceSprite.pivot.y / sourceSprite.rect.height + ); + } + + private static Texture2D BakeSpriteToTexture(Sprite sourceSprite) + { + Texture2D atlasTexture = sourceSprite.texture; + int spriteWidth = (int)sourceSprite.rect.width; + int spriteHeight = (int)sourceSprite.rect.height; + float sourcePixelsPerUnit = sourceSprite.pixelsPerUnit; + Vector2 sourcePivotInPixels = sourceSprite.pivot; + + var meshVertices = sourceSprite.vertices; + var meshUvs = sourceSprite.uv; + var meshTriangles = sourceSprite.triangles; + + RenderTexture renderTexture = RenderTexture.GetTemporary( + spriteWidth, spriteHeight, 0, + RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); + RenderTexture previousActiveRenderTexture = RenderTexture.active; + RenderTexture.active = renderTexture; + GL.Clear(true, true, Color.clear); + + Material spriteMaterial = new Material(Shader.Find("Sprites/Default")); + spriteMaterial.mainTexture = atlasTexture; + spriteMaterial.SetPass(0); + + GL.PushMatrix(); + GL.LoadPixelMatrix(0, spriteWidth, 0, spriteHeight); + GL.Begin(GL.TRIANGLES); + for (int triangleCornerIndex = 0; triangleCornerIndex < meshTriangles.Length; triangleCornerIndex++) + { + int vertexIndex = meshTriangles[triangleCornerIndex]; + Vector2 uv = meshUvs[vertexIndex]; + Vector2 vertex = meshVertices[vertexIndex]; + + GL.TexCoord(new Vector3(uv.x, uv.y, 0)); + GL.Vertex3( + vertex.x * sourcePixelsPerUnit + sourcePivotInPixels.x, + vertex.y * sourcePixelsPerUnit + sourcePivotInPixels.y, + 0); + } + GL.End(); + GL.PopMatrix(); + + Texture2D bakedTexture = new Texture2D(spriteWidth, spriteHeight, TextureFormat.RGBA32, false); + bakedTexture.ReadPixels(new Rect(0, 0, spriteWidth, spriteHeight), 0, 0); + bakedTexture.Apply(); + bakedTexture.wrapMode = TextureWrapMode.Clamp; + + RenderTexture.active = previousActiveRenderTexture; + RenderTexture.ReleaseTemporary(renderTexture); + UnityEngine.Object.Destroy(spriteMaterial); + + return bakedTexture; + } + + public static void CreateInnerOriginSprites() + { + Plugin.logger.LogInfo($"Creating sprites with inner origin..."); + SpriteAtlasManager spriteAtlasManager = GameManager.GetSpriteAtlasManager(); + + foreach (KeyValuePair spriteInfoPair in pendingInnerOriginSpriteInfos) + { + string spriteName = spriteInfoPair.Key; + SpriteInfo spriteInfo = spriteInfoPair.Value; + + Sprite originalSprite = spriteAtlasManager + .DoSpriteLookup(spriteInfo.spriteOrigin!.name, TribeType.None, SkinType.Default) + .sprite; + + if (originalSprite == null) + { + Plugin.logger.LogWarning($"Inner origin sprite '{spriteInfo.spriteOrigin.name}' not found for '{spriteName}'"); + continue; + } + + bool hasPivotOverride = spriteInfo.pivot != null; + bool hasPixelsPerUnitOverride = spriteInfo.pixelsPerUnit != null; + + Sprite sprite = originalSprite; + + if (hasPivotOverride || hasPixelsPerUnitOverride) + { + Vector2 normalizedOriginalPivot = new( + originalSprite.pivot.x / originalSprite.rect.width, + originalSprite.pivot.y / originalSprite.rect.height + ); + + Vector2 finalPivot = hasPivotOverride ? spriteInfo.pivot!.Value : normalizedOriginalPivot; + float finalPixelsPerUnit = hasPixelsPerUnitOverride ? spriteInfo.pixelsPerUnit!.Value : originalSprite.pixelsPerUnit; + bool transformToOutline = spriteInfo.transformToOutline; + float? gaussianBlurRadius = spriteInfo.gaussianBlurRadius; + + Texture2D bakedTexture = BakeSpriteToTexture(originalSprite); + sprite = BuildSpriteWithTexture(bakedTexture, finalPivot, finalPixelsPerUnit, transformToOutline, gaussianBlurRadius); + sprite.name = spriteName; + } + + RegisterSprite(spriteAtlasManager, spriteName, sprite); + } + + pendingInnerOriginSpriteInfos.Clear(); + } + + /// Initializes the Visual manager by patching the necessary methods. + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(Visual)); + } } diff --git a/src/NullableFix.cs b/src/NullableFix.cs index adc6cdf..1c7e549 100644 --- a/src/NullableFix.cs +++ b/src/NullableFix.cs @@ -1,5 +1,5 @@ #nullable disable - +// verify whether it is needed at all bruv, i tested one time quickly and it easily compiled namespace System.Runtime.CompilerServices { /// diff --git a/src/Plugin.cs b/src/Plugin.cs index c273eb4..6fdfc0e 100644 --- a/src/Plugin.cs +++ b/src/Plugin.cs @@ -1,10 +1,10 @@ -using System.Reflection; +using System.IO.Pipes; +using System.Reflection; using System.Text.Json; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using PolyMod.Managers; -using UnityEngine; namespace PolyMod; @@ -14,18 +14,23 @@ namespace PolyMod; [BepInPlugin("com.polymod", "PolyMod", VERSION)] public partial class Plugin : BepInEx.Unity.IL2CPP.BasePlugin { - /// - /// Represents the configuration for PolyMod. - /// - /// Whether to enable debug mode. - /// Whether to automatically update PolyMod. - /// Whether to include pre-release versions when updating. - internal record PolyConfig( - bool debug = false, - bool autoUpdate = true, - bool updatePrerelease = false, - bool allowUnsafeIndexes = false - ); + internal static string? Token { get; set; } + internal static bool ValidLaunch { get; set; } = false; + internal static bool Disabled { get; set; } = false; + + /// + /// Represents the configuration for PolyMod. + /// + /// Whether to enable debug mode. + internal record PolyConfig( + bool debug = false, + bool showCoordinates = false, + bool allowUnsafeIndexes = false, + string overrideDeviceId = "" + ) + { + public List disabledMods { get; init; } = new(); + }; /// /// The starting index for automatically assigned IDs. @@ -38,10 +43,12 @@ internal record PolyConfig( internal const string INCOMPATIBILITY_WARNING_LAST_VERSION_KEY = "INCOMPATIBILITY_WARNING_LAST_VERSION"; - /// - /// The base path for PolyMod files. - /// - public static readonly string BASE_PATH = Path.Combine(BepInEx.Paths.BepInExRootPath, ".."); + internal const int LAUNCH_DATA_TIMEOUT = 2000; + + /// + /// The base path for PolyMod files. + /// + public static readonly string BASE_PATH = Path.Combine(BepInEx.Paths.BepInExRootPath, ".."); /// /// The path to the mods directory. @@ -63,10 +70,20 @@ internal const string INCOMPATIBILITY_WARNING_LAST_VERSION_KEY /// public static readonly string LOCALIZATION_DUMP_PATH = Path.Combine(DUMPED_DATA_PATH, "Localization"); - /// - /// The path to the PolyMod configuration file. - /// - internal static readonly string CONFIG_PATH = Path.Combine(BASE_PATH, "PolyMod.json"); + /// + /// The path to the directory where sprites and sprite infos are dumped. + /// + public static readonly string SPRITES_DUMP_PATH = Path.Combine(DUMPED_DATA_PATH, "Sprites"); + + /// + /// The path to the directory where prefabs datas are dumped. + /// + public static readonly string PREFABS_DUMP_PATH = Path.Combine(DUMPED_DATA_PATH, "Prefabs"); + + /// + /// The path to the PolyMod configuration file. + /// + internal static readonly string CONFIG_PATH = Path.Combine(BASE_PATH, "PolyMod.json"); /// /// The path to the checksum file. @@ -122,29 +139,60 @@ public override void Load() logger = Log; ConfigFile.CoreConfig[new("Logging.Disk", "WriteUnityLog")].BoxedValue = true; - AutoUpdate.Init(); + CaptureLaunchData(); - Compatibility.Init(); + if(Disabled) + return; - Audio.Init(); - Loc.Init(); - Visual.Init(); - Hub.Init(); + Compatibility.Init(); - Main.Init(); - } - - /// - /// Gets a resource stream from the assembly. - /// - /// The ID of the resource. - /// The resource stream. - internal static Stream GetResource(string id) - { - return Assembly.GetExecutingAssembly().GetManifestResourceStream( - $"{typeof(Plugin).Namespace}.resources.{id}" - )!; - } + Audio.Init(); + Loc.Init(); + + Visual.Init(); + Prefabs.Init(); + TribePreviews.Init(); + + Hub.Init(); + Main.Init(); + } + + internal static void CaptureLaunchData() + { + try + { + using var client = new NamedPipeClientStream( + ".", "PolyMod.Launcher", PipeDirection.In, PipeOptions.CurrentUserOnly); + client.Connect(LAUNCH_DATA_TIMEOUT); + + using var reader = new StreamReader(client); + var line = reader.ReadLine(); + if (string.IsNullOrEmpty(line)) return; + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + ValidLaunch = root.GetProperty("validLaunch").GetBoolean(); + Disabled = root.GetProperty("disabled").GetBoolean(); + Token = root.GetProperty("token").GetString() ?? ""; + } + catch + { + ValidLaunch = false; + } + ValidLaunch = true; + } + + /// + /// Gets a resource stream from the assembly. + /// + /// The ID of the resource. + /// The resource stream. + internal static Stream GetResource(string id) + { + return Assembly.GetExecutingAssembly().GetManifestResourceStream( + $"{typeof(Plugin).Namespace}.resources.{id}" + )!; + } /// /// Writes the configuration to disk. @@ -154,18 +202,19 @@ internal static void WriteConfig() File.WriteAllText(CONFIG_PATH, JsonSerializer.Serialize(config)); } - /// - /// Updates the console based on the debug configuration. - /// - internal static void UpdateConsole() - { - if (config.debug) - { - ConsoleManager.CreateConsole(); - } - else - { - ConsoleManager.DetachConsole(); - } - } + /// + /// Updates the console based on the debug configuration. + /// + internal static void UpdateConsole() + { + if (config.debug) + { + ConsoleManager.CreateConsole(); + } + else if (OperatingSystem.IsWindows()) + { + // BepInEx's Unix console driver throws unsupported on detach. Off-Windows there is no separate console window, so there is nothing to detach. + ConsoleManager.DetachConsole(); + } + } } diff --git a/src/PolyMod.csproj b/src/PolyMod.csproj index c1887a0..9c64b21 100644 --- a/src/PolyMod.csproj +++ b/src/PolyMod.csproj @@ -5,7 +5,7 @@ enable IL2CPP PolyMod - 1.2.19 + 2.0.0-pre 2.17.3.16375 PolyModdingTeam The Battle of Polytopia's mod loader. @@ -17,7 +17,7 @@ - + all diff --git a/src/Prefabs/OtherPrefabInfo.cs b/src/Prefabs/OtherPrefabInfo.cs new file mode 100644 index 0000000..1b5ca10 --- /dev/null +++ b/src/Prefabs/OtherPrefabInfo.cs @@ -0,0 +1,117 @@ + +using System.Text.Json.Serialization; +using PolyMod.Json; +using Polytopia.Data; +using DG.Tweening; + +namespace PolyMod; + +public class BasePrefabInfo +{ + [JsonInclude] + public List visualParts = new(); +} +public class UnitPrefabInfo : BasePrefabInfo +{ + [JsonInclude] + public SkinnedKey skinnedKey; + + [JsonInclude] + public string headPositionMarker = ""; + + [JsonInclude] + public bool uiOverride = false; +} + +public class ImprovementPrefabInfo : BasePrefabInfo +{ + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public ImprovementData.Type? improvementType = null; + + [JsonInclude] + public bool needsOutlines = false; +} + +public class ResourcePrefabInfo : BasePrefabInfo +{ + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public ResourceData.Type? resourceType = null; +} + +internal class WeaponPrefabInfo +{ + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + internal UnitData.WeaponEnum? weaponEnum = null; + + [JsonInclude] + internal string spriteName = ""; + + [JsonInclude] + [JsonConverter(typeof(EnumNameJson))] + internal PathType pathType = PathType.CatmullRom; + + [JsonInclude] + [JsonConverter(typeof(EnumNameJson))] + internal PathMode pathMode = PathMode.Sidescroller2D; + + [JsonInclude] + internal int pathResolution = 10; + + [JsonInclude] + [JsonConverter(typeof(EnumNameJson))] + internal Ease pathEase = Ease.Linear; + + [JsonInclude] + internal float lookAheadOffset = 0.01f; + + [JsonInclude] + internal float handleAngleOffset = 0.3f; + + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + internal SFXTypes sfxType = SFXTypes.None; + + [JsonInclude] + internal WeaponPuffInfo? weaponPuffInfo = null; + + [JsonInclude] + internal WeaponRangedInfo? weaponRangedInfo = null; +} + +internal class WeaponPuffInfo +{ + [JsonInclude] + internal bool sway = false; + + internal float animationMultiplier = 1f; + + internal Action? initialDelegate = null; + + internal Action? animationDelegate = null; + + // [JsonInclude] Nuh uh, I wont do ts + // public List puffsToSpawn = new(); +} + +internal class WeaponRangedInfo +{ + [JsonInclude] + internal float centerOffset = 0.15f; +} + +internal class GFXPrefabInfo +{ + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + internal GFXType? gfxType = null; +} + +internal class UnitEffectPrefabInfo +{ + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + internal UnitEffect? unitEffect = null; +} \ No newline at end of file diff --git a/src/Prefabs/PrefabsInfo.cs b/src/Prefabs/PrefabsInfo.cs new file mode 100644 index 0000000..09c867e --- /dev/null +++ b/src/Prefabs/PrefabsInfo.cs @@ -0,0 +1,19 @@ + +using System.Text.Json.Serialization; + +namespace PolyMod; + +public class PrefabsInfo +{ + [JsonInclude] + public List units = new(); + + [JsonInclude] + public List improvements = new(); + + [JsonInclude] + public List resources = new(); + + // [JsonInclude] + private List weapons = new(); +} \ No newline at end of file diff --git a/src/Prefabs/SkinnedKey.cs b/src/Prefabs/SkinnedKey.cs new file mode 100644 index 0000000..080b6df --- /dev/null +++ b/src/Prefabs/SkinnedKey.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using PolyMod.Json; +using Polytopia.Data; +using PolytopiaBackendBase.Common; + +namespace PolyMod; + +public readonly struct SkinnedKey : IEquatable +{ + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public UnitData.Type unitType { get; } + + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public TribeType tribeType { get; } + + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public SkinType skinType { get; } + + [JsonConstructor] + public SkinnedKey(UnitData.Type unitType, TribeType tribeType, SkinType skinType) + { + this.unitType = unitType; + this.tribeType = tribeType; + this.skinType = skinType; + } + + public bool Equals(SkinnedKey other) + { + return unitType == other.unitType + && tribeType == other.tribeType + && skinType == other.skinType; + } + + public override bool Equals(object? other) + { + return other is SkinnedKey otherKey && Equals(otherKey); + } + + public override int GetHashCode() + { + return HashCode.Combine((int)unitType, (int)tribeType, (int)skinType); + } +} \ No newline at end of file diff --git a/src/Prefabs/VisualPartInfo.cs b/src/Prefabs/VisualPartInfo.cs new file mode 100644 index 0000000..27105a2 --- /dev/null +++ b/src/Prefabs/VisualPartInfo.cs @@ -0,0 +1,81 @@ +using System.Text.Json.Serialization; +using PolyMod.Json; +using UnityEngine; + +namespace PolyMod; + +public class VisualPartInfo +{ + [JsonInclude] + public string? defaultSpriteName; + + [JsonInclude] + [JsonConverter(typeof(EnumNameJson))] + public SkinVisualsReference.SkinningLogic skinningLogic = SkinVisualsReference.SkinningLogic.UseTribe; + + [JsonInclude] + [JsonConverter(typeof(EnumNameJson))] + public SkinVisualsReference.ImprovementSkinningLogic improvementSkinningLogic = SkinVisualsReference.ImprovementSkinningLogic.FoundingTribe; // improvement specific + + [JsonInclude] + public Vector2 coordinates = new Vector2(0, 0); + + [JsonInclude] + public float rotation = 0f; + + [JsonInclude] + public bool tintable = false; + + [JsonInclude] + public bool levels = false; // improvement specific + + [JsonInclude] + public int? sortingOrderOverride = null; + + [JsonInclude] + public int? outlineSortingOrderOverride = null; + + public SkinVisualsReference.VisualPart ToVisualPart(bool isImprovement) + { + return new SkinVisualsReference.VisualPart + { + DefaultSpriteName = defaultSpriteName, + tintable = tintable, + skinLogic = skinningLogic, + improvementSkinLogic = improvementSkinningLogic, + levels = levels, + isImprovement = isImprovement, + }; + } + + public void CopyFromVisualPart(SkinVisualsReference.VisualPart visualPart) + { + int? sortingOrderOverride = null; + SpriteRenderer spriteRenderer = visualPart.visualPart.GetComponent(); + if (spriteRenderer != null) + { + sortingOrderOverride = spriteRenderer.sortingOrder; + } + + int? outlineSortingOrderOverride = null; + + if (visualPart.outline != null) + { + spriteRenderer = visualPart.outline.GetComponent(); + if (spriteRenderer != null) + { + outlineSortingOrderOverride = spriteRenderer.sortingOrder; + } + } + + coordinates = visualPart.visualPart.transform.position; + rotation = visualPart.visualPart.transform.rotation.z; + defaultSpriteName = visualPart.DefaultSpriteName; + tintable = visualPart.tintable; + skinningLogic = visualPart.skinLogic; + improvementSkinningLogic = visualPart.improvementSkinLogic; + levels = visualPart.levels; + this.sortingOrderOverride = sortingOrderOverride; + this.outlineSortingOrderOverride = outlineSortingOrderOverride; + } +} \ No newline at end of file diff --git a/src/Registry.cs b/src/Registry.cs index 4468130..e8a5527 100644 --- a/src/Registry.cs +++ b/src/Registry.cs @@ -21,50 +21,32 @@ public static class Registry /// public static Dictionary sprites = new(); - /// - /// A dictionary of all loaded audio clips, keyed by their name. - /// - public static Dictionary audioClips = new(); + public static Dictionary> textures = new(); // string = texture name, bool, whether texture was processed or not. reviwers, pls tell me how to improve + + /// + /// A dictionary of all loaded audio clips, keyed by their name. + /// + public static Dictionary audioClips = new(); /// /// A dictionary of all loaded mods, keyed by their ID. /// public static Dictionary mods = new(); - /// - /// A dictionary of tribe previews, keyed by tribe name. - /// - public static Dictionary tribePreviews = new(); + /// + /// A dictionary of tribe previews, keyed by tribe name. + /// + public static Dictionary tribePreviews = new(); /// /// A dictionary of sprite information, keyed by sprite name. /// public static Dictionary spriteInfos = new(); - /// - /// A dictionary of prefab names, keyed by their enum value. - /// - public static Dictionary prefabNames = new(); - - /// - /// A dictionary of custom unit prefabs, keyed by their prefab info. - /// - public static Dictionary unitPrefabs = new(); - - /// - /// A dictionary of custom resource prefabs, keyed by their prefab info. - /// - public static Dictionary resourcePrefabs = new(); - - /// - /// A dictionary of custom improvement prefabs, keyed by their prefab info. - /// - public static Dictionary improvementsPrefabs = new(); - - /// - /// A dictionary of loaded asset bundles, keyed by their name. - /// - public static Dictionary assetBundles = new(); + /// + /// A dictionary of loaded asset bundles, keyed by their name. + /// + public static Dictionary assetBundles = new(); /// /// A list of custom tribes. @@ -86,40 +68,80 @@ public static class Registry /// public static int gameModesAutoidx = Enum.GetValues(typeof(GameMode)).Length; - /// - /// Gets a sprite from the registry, trying to find the best match. - /// - /// The name of the sprite. - /// The style of the sprite (e.g., tribe or skin). - /// The level of the sprite (e.g., for cities). - /// The requested sprite, or null if not found. The lookup priority is: name_style_level, name__level, name_style_, name__. - public static Sprite? GetSprite(string name, string style = "", int level = 0) - { - Sprite? sprite = null; - name = name.ToLower(); - style = style.ToLower(); - // This looks backwards, but GetOrDefault returns the provided default value if the key isn't found. - // This means `sprite` is updated sequentially, and the last, most-specific match is the one that is kept. - sprite = sprites.GetOrDefault($"{name}__", sprite); - sprite = sprites.GetOrDefault($"{name}_{style}_", sprite); - sprite = sprites.GetOrDefault($"{name}__{level}", sprite); - sprite = sprites.GetOrDefault($"{name}_{style}_{level}", sprite); - return sprite; - } - - /// - /// Gets an audio clip from the registry. - /// - /// The name of the audio clip. - /// The style of the audio clip. - /// The requested audio clip, or null if not found. - public static AudioClip? GetAudioClip(string name, string style) - { - AudioSource? audioSource = null; - name = name.ToLower(); - style = style.ToLower(); - audioSource = audioClips.GetOrDefault($"{name}_{style}", audioSource); - if (audioSource == null) return null; - return audioSource.clip; - } + public static Dictionary filesQueueToBeProcessedAfterGLDMerge = new(); // I dont know whether I should do sum abt it + + public static Sprite? GetSprite(string baseName, TribeType tribe = TribeType.None, SkinType skin = SkinType.Default, int level = -1, bool outline = false) + { + string tribeText = (tribe == TribeType.None) ? "" : EnumCache.GetName(tribe); + string skinText = (skin == SkinType.Default) ? "" : EnumCache.GetName(skin); + string levelText = (level == -1) ? "" : level.ToString(); + return GetSpriteByText(baseName, tribeText, skinText, levelText, outline); + } + /// + /// Gets a sprite from the registry, trying to find the best match. + /// + public static Sprite? GetSpriteByText(string baseName, string tribeText = "", string skinText = "", string levelText = "", bool outline = false) + { + Sprite? sprite = null; + baseName = baseName.ToLower(); + tribeText = tribeText.ToLower(); + skinText = skinText.ToLower(); + + List spriteNameVariants = new(); + + if (levelText != string.Empty) + baseName += $"_{levelText}"; + + spriteNameVariants.Add(baseName); + spriteNameVariants.Add(baseName + "_" + tribeText); + spriteNameVariants.Add(baseName + "_" + skinText); + spriteNameVariants.Add(baseName + "_" + tribeText + "_" + skinText); + + foreach(string spriteName in spriteNameVariants) + { + sprite = sprites.GetOrDefault(outline ? spriteName + "_outline" : spriteName, sprite); + } + + return sprite; + } + + /// + /// Gets an audio clip from the registry. + /// + /// The name of the audio clip. + /// The style of the audio clip. + /// The requested audio clip, or null if not found. + public static AudioClip? GetAudioClip(string name, TribeType tribe = TribeType.None, SkinType skin = SkinType.Default) + { + string tribeText = (tribe == TribeType.None) ? "" : EnumCache.GetName(tribe); + string skinText = (skin == SkinType.Default) ? "" : EnumCache.GetName(skin); + return GetAudioClipByText(name, tribeText, skinText); + } + + /// + /// Gets an audio clip from the registry, trying to find the best match. + /// + public static AudioClip? GetAudioClipByText(string baseName, string tribeText = "", string skinText = "") + { + AudioSource? audioSource = null; + baseName = baseName.ToLower(); + tribeText = tribeText.ToLower(); + skinText = skinText.ToLower(); + + List audioNameVariants = new() + { + baseName, + baseName + "_" + tribeText, + baseName + "_" + skinText, + baseName + "_" + tribeText + "_" + skinText, + }; + + foreach (string audioName in audioNameVariants) + { + audioSource = audioClips.GetOrDefault(audioName, audioSource); + } + + if (audioSource == null) return null; + return audioSource.clip; + } } diff --git a/src/Util.cs b/src/Util.cs index aef2ebb..9c84c41 100644 --- a/src/Util.cs +++ b/src/Util.cs @@ -3,8 +3,6 @@ using Il2CppInterop.Runtime; using Il2CppInterop.Runtime.Injection; using Newtonsoft.Json.Linq; -using Polytopia.Data; -using PolytopiaBackendBase.Common; namespace PolyMod; @@ -140,74 +138,4 @@ private static int CompareIdentifier(string left, string right) if (rightNumeric) return 1; return string.Compare(left, right, StringComparison.Ordinal); } - - /// - /// Gets the style name for a tribe and skin combination. - /// - /// The tribe. - /// The skin. - /// The style name. - internal static string GetStyle(TribeType tribe, SkinType skin) - { - return skin != SkinType.Default ? EnumCache.GetName(skin) : EnumCache.GetName(tribe); - } - - /// - /// Formats a sprite name to match the enum naming convention. - /// This is a workaround for inconsistencies in the game's sprite naming. - /// - /// The original sprite name. - /// The formatted sprite name. - internal static string FormatSpriteName(string baseName) // I cant believe i had to do this shit #MIDJIWANFIXYOURSHITCODE - { - // This method is necessary because the sprite names in the game's data do not always - // match the names in the corresponding enums. This method replaces the hardcoded sprite - // names with the enum names to ensure consistency. - baseName = baseName.Replace(SpriteData.IMPROVEMENT_AQUA_FARM, EnumCache.GetName(ImprovementData.Type.Aquafarm)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_ATOLL, EnumCache.GetName(ImprovementData.Type.Atoll)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_BURN_FOREST, EnumCache.GetName(ImprovementData.Type.BurnForest)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_CLEAR_FOREST, EnumCache.GetName(ImprovementData.Type.ClearForest)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_CUSTOMS_HOUSE, EnumCache.GetName(ImprovementData.Type.CustomsHouse)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_FARM, EnumCache.GetName(ImprovementData.Type.Farm)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_FOREST_TEMPLE, EnumCache.GetName(ImprovementData.Type.ForestTemple)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_FORGE, EnumCache.GetName(ImprovementData.Type.Forge)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_GROW_FOREST, EnumCache.GetName(ImprovementData.Type.GrowForest)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_ICE_BANK, EnumCache.GetName(ImprovementData.Type.IceBank)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_ICE_PORT, EnumCache.GetName(ImprovementData.Type.Outpost)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_ICE_TEMPLE, EnumCache.GetName(ImprovementData.Type.IceTemple)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_LUMBER_HUT, EnumCache.GetName(ImprovementData.Type.LumberHut)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_MARKET, EnumCache.GetName(ImprovementData.Type.Market)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_MINE, EnumCache.GetName(ImprovementData.Type.Mine)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_MOUNTAIN_TEMPLE, EnumCache.GetName(ImprovementData.Type.MountainTemple)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_PORT, EnumCache.GetName(ImprovementData.Type.Port)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_ROAD, EnumCache.GetName(ImprovementData.Type.Road)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_RUIN, EnumCache.GetName(ImprovementData.Type.Ruin)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_SANCTUARY, EnumCache.GetName(ImprovementData.Type.Sanctuary)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_SAWMILL, EnumCache.GetName(ImprovementData.Type.Sawmill)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_TEMPLE, EnumCache.GetName(ImprovementData.Type.Temple)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_WATER_TEMPLE, EnumCache.GetName(ImprovementData.Type.WaterTemple)); - baseName = baseName.Replace(SpriteData.IMPROVEMENT_WINDMILL, EnumCache.GetName(ImprovementData.Type.Windmill)); - - baseName = baseName.Replace(SpriteData.RESOURCE_AQUACROP, EnumCache.GetName(ResourceData.Type.AquaCrop)); - baseName = baseName.Replace(SpriteData.RESOURCE_CROP, EnumCache.GetName(ResourceData.Type.Crop)); - baseName = baseName.Replace(SpriteData.RESOURCE_FISH, EnumCache.GetName(ResourceData.Type.Fish)); - baseName = baseName.Replace(SpriteData.RESOURCE_FRUIT, EnumCache.GetName(ResourceData.Type.Fruit)); - baseName = baseName.Replace(SpriteData.RESOURCE_GAME, EnumCache.GetName(ResourceData.Type.Game)); - baseName = baseName.Replace(SpriteData.RESOURCE_METAL, EnumCache.GetName(ResourceData.Type.Metal)); - baseName = baseName.Replace(SpriteData.RESOURCE_SPORES, EnumCache.GetName(ResourceData.Type.Spores)); - baseName = baseName.Replace(SpriteData.RESOURCE_STARFISH, EnumCache.GetName(ResourceData.Type.Starfish)); - baseName = baseName.Replace(SpriteData.RESOURCE_WHALE, EnumCache.GetName(ResourceData.Type.Whale)); - - baseName = baseName.Replace(SpriteData.TILE_FIELD, EnumCache.GetName(TerrainData.Type.Field)); - baseName = baseName.Replace(SpriteData.TILE_FOREST, EnumCache.GetName(TerrainData.Type.Forest)); - baseName = baseName.Replace(SpriteData.TILE_ICE, EnumCache.GetName(TerrainData.Type.Ice)); - baseName = baseName.Replace(SpriteData.TILE_MOUNTAIN, EnumCache.GetName(TerrainData.Type.Mountain)); - baseName = baseName.Replace(SpriteData.TILE_OCEAN, EnumCache.GetName(TerrainData.Type.Ocean)); - baseName = baseName.Replace(SpriteData.TILE_UNKNOWN, EnumCache.GetName(TerrainData.Type.Field)); - baseName = baseName.Replace(SpriteData.TILE_WATER, EnumCache.GetName(TerrainData.Type.Water)); - baseName = baseName.Replace(SpriteData.TILE_WETLAND, EnumCache.GetName(TerrainData.Type.Field) + "_flooded"); - - baseName = baseName.Replace("UI_", ""); - return baseName; - } } diff --git a/src/WavDecoder.cs b/src/WavDecoder.cs new file mode 100644 index 0000000..736ca52 --- /dev/null +++ b/src/WavDecoder.cs @@ -0,0 +1,157 @@ +using System.Buffers.Binary; + +namespace PolyMod; + +/// +/// Decodes RIFF WAVE files into the interleaved float samples Unity wants. +/// +public static class WavDecoder +{ + /// + /// The decoded contents of a WAVE file. + /// + /// Interleaved samples in the -1 to 1 range, channel by channel. + /// Number of channels the samples are interleaved across. + /// Sample rate in hertz. + public record WavData(float[] samples, int channels, int frequency) + { + /// Number of sample frames, which is the length Unity asks for. + public int lengthSamples => samples.Length / channels; + } + + private const ushort FORMAT_PCM = 1; + private const ushort FORMAT_FLOAT = 3; + private const ushort FORMAT_EXTENSIBLE = 0xFFFE; + + private const int HEADER_LENGTH = 12; + private const int CHUNK_HEADER_LENGTH = 8; + private const int FORMAT_CHUNK_LENGTH = 16; + private const int EXTENSIBLE_CHUNK_LENGTH = 40; + + /// + /// Decodes a WAVE file held in memory. + /// + /// The whole file, starting at its RIFF header. + /// The samples plus the channel count and sample rate needed to play them. + /// The file is not a WAVE file this can decode. + public static WavData Decode(byte[] data) + { + ReadOnlySpan file = data; + if (file.Length < HEADER_LENGTH || ReadId(file, 0) != "RIFF" || ReadId(file, 8) != "WAVE") + throw new InvalidDataException("Not a RIFF WAVE file."); + + ushort format = 0; + ushort channels = 0; + ushort bitsPerSample = 0; + int frequency = 0; + int dataOffset = -1; + int dataLength = 0; + + int offset = HEADER_LENGTH; + while (offset + CHUNK_HEADER_LENGTH <= file.Length) + { + string id = ReadId(file, offset); + uint declared = BinaryPrimitives.ReadUInt32LittleEndian(file.Slice(offset + 4, 4)); + int payload = offset + CHUNK_HEADER_LENGTH; + int size = (int)Math.Min(declared, (uint)(file.Length - payload)); + + if (id == "fmt " && size >= FORMAT_CHUNK_LENGTH) + { + format = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(payload, 2)); + channels = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(payload + 2, 2)); + frequency = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(payload + 4, 4)); + bitsPerSample = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(payload + 14, 2)); + if (format == FORMAT_EXTENSIBLE && size >= EXTENSIBLE_CHUNK_LENGTH) + format = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(payload + 24, 2)); + } + else if (id == "data") + { + dataOffset = payload; + dataLength = size; + } + + offset = payload + size + (size & 1); + } + + if (channels < 1) throw new InvalidDataException("WAVE file declares no channels."); + if (frequency < 1) throw new InvalidDataException("WAVE file declares no sample rate."); + if (bitsPerSample < 8 || bitsPerSample % 8 != 0) + throw new InvalidDataException($"Unsupported WAVE bit depth {bitsPerSample}."); + if (dataOffset < 0) throw new InvalidDataException("WAVE file has no data chunk."); + + int bytesPerSample = bitsPerSample / 8; + int count = dataLength / bytesPerSample; + count -= count % channels; + if (count < 1) throw new InvalidDataException("WAVE file holds no samples."); + + float[] samples = ReadSamples(file.Slice(dataOffset), format, bitsPerSample, count); + return new WavData(samples, channels, frequency); + } + + private static float[] ReadSamples(ReadOnlySpan payload, ushort format, ushort bitsPerSample, int count) + { + float[] samples = new float[count]; + int step = bitsPerSample / 8; + + if (format == FORMAT_PCM) + { + switch (bitsPerSample) + { + case 8: + for (int i = 0; i < count; i++) + samples[i] = (payload[i] - 128) / 128f; + return samples; + case 16: + for (int i = 0; i < count; i++) + samples[i] = BinaryPrimitives.ReadInt16LittleEndian(payload.Slice(i * step, 2)) / 32768f; + return samples; + case 24: + for (int i = 0; i < count; i++) + { + int at = i * step; + int value = payload[at] | (payload[at + 1] << 8) | ((sbyte)payload[at + 2] << 16); + samples[i] = value / 8388608f; + } + return samples; + case 32: + for (int i = 0; i < count; i++) + samples[i] = BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(i * step, 4)) / 2147483648f; + return samples; + } + } + else if (format == FORMAT_FLOAT) + { + switch (bitsPerSample) + { + case 32: + for (int i = 0; i < count; i++) + samples[i] = BitConverter.Int32BitsToSingle( + BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(i * step, 4)) + ); + return samples; + case 64: + for (int i = 0; i < count; i++) + samples[i] = (float)BitConverter.Int64BitsToDouble( + BinaryPrimitives.ReadInt64LittleEndian(payload.Slice(i * step, 8)) + ); + return samples; + } + } + + throw new InvalidDataException( + $"Unsupported WAVE format {format} at {bitsPerSample} bits per sample. " + + "Save the file as PCM or float WAVE." + ); + } + + private static string ReadId(ReadOnlySpan file, int offset) + { + return new string(new char[] + { + (char)file[offset], + (char)file[offset + 1], + (char)file[offset + 2], + (char)file[offset + 3], + }); + } +}