diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bca1bc2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,45 @@ +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{json,yml,yaml}] +indent_style = space +indent_size = 2 + +[*.cs] +indent_style = tab +tab_width = 4 +indent_size = 4 +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_style_var_when_type_is_apparent = true:suggestion +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +csharp_prefer_braces = when_multiline:suggestion +dotnet_diagnostic.IDE0005.severity = warning +dotnet_diagnostic.CS4014.severity = warning +dotnet_diagnostic.IDE0051.severity = none +dotnet_diagnostic.IDE0060.severity = none +dotnet_diagnostic.CA1707.severity = none +dotnet_diagnostic.CA1051.severity = none +dotnet_diagnostic.CA2211.severity = none +dotnet_diagnostic.CA1805.severity = suggestion +dotnet_diagnostic.CA1716.severity = none +dotnet_diagnostic.CA1708.severity = none +dotnet_diagnostic.CS1591.severity = none +dotnet_diagnostic.CS1573.severity = none + +[tests/**.cs] +dotnet_diagnostic.CA1861.severity = none 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/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..66f37af --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,14 @@ + + + + true + latest + Recommended + true + + + true + $(NoWarn);CS1591 + + + 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..1a97e23 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,29 +222,83 @@ "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∑ ₼ȱ₺ŋΔ!" }, - "polymod_wc_warning": { - "English": "{0} may not work correctly due to PolyMod. Do you wish to proceed?", - "Russian": "{0} может работать некорректно из-за PolyMod'а. Вы уверены что хотите продолжить?", - "Elyrion": "{0} maỹ ŋȱŦ ~ȱr§ #ȱrr∑#Ŧ∫ỹ Δ₺∑ Ŧȱ πȱ∫ỹmȱΔ. Δȱ ỹȱ₺ ~iȓţ Ŧȱ πrȱ#∑∑Δ?" + "polymod_wc_vanillaonly": { + "English": "{0} is only available in vanilla Polytopia. Disable PolyMod in the launcher, or start the game without the launcher.", + "Russian": "{0} доступен только в ванильной Polytopia. Отключите PolyMod в лаунчере или запустите игру без лаунчера." + }, + "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_mod_checksum": { + "English": "checksum {0}", + "Russian": "контрольная сумма {0}" + }, + "polymod_hub_checksum": { + "English": "Global checksum: {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_wc_proceed": { - "English": "PROCEED", - "Russian": "ПРОДОЛЖИТЬ", - "Elyrion": "πrȱ#∑∑Δ" + "polymod_hotreload_unavailable": { + "English": "Hot reload is not available yet.", + "Russian": "Горячая перезагрузка пока недоступна." } -} +} \ No newline at end of file diff --git a/src/Api/Config.cs b/src/Api/Config.cs index f253bf8..4cd3a19 100644 --- a/src/Api/Config.cs +++ b/src/Api/Config.cs @@ -8,113 +8,114 @@ namespace PolyMod.Api; /// public class Config where T : class { - private T? currentConfig; - private readonly string modName; - private readonly ConfigTypes configType; - private static readonly string ExposedConfigPath = Path.Combine(Plugin.BASE_PATH, "mods.json"); - private readonly string perModConfigPath; - private T? defaultConfig; - public Config(string modName, ConfigTypes configType) - { - this.modName = modName; - this.configType = configType; - perModConfigPath = Path.Combine(Plugin.MODS_PATH, $"{modName}.json"); - Load(); - } + private static readonly JsonSerializerOptions indentedJsonOptions = new() { WriteIndented = true }; + private T? currentConfig; + private readonly string modName; + private readonly ConfigTypes configType; + private static readonly string ExposedConfigPath = Path.Combine(Plugin.BASE_PATH, "mods.json"); + private readonly string perModConfigPath; + private T? defaultConfig; + public Config(string modName, ConfigTypes configType) + { + this.modName = modName; + this.configType = configType; + perModConfigPath = Path.Combine(Plugin.MODS_PATH, $"{modName}.json"); + Load(); + } - internal void Load() // can be called internally if config changes; gui config not implemented yet - { - switch (configType) - { - case ConfigTypes.PerMod: - { - if (!File.Exists(perModConfigPath)) - { - return; - } - var jsonText = File.ReadAllText(perModConfigPath); - currentConfig = JsonSerializer.Deserialize(jsonText); - break; - } - case ConfigTypes.Exposed: - { - if (!File.Exists(ExposedConfigPath)) - { - return; - } - var jsonText = File.ReadAllText(ExposedConfigPath); - currentConfig = JsonNode.Parse(jsonText)![modName]?.Deserialize(); - break; - } - default: - throw new ArgumentOutOfRangeException(); - } - } - /// - /// Sets the default if the config does not exist yet. Always call this before reading from the config. - /// - public void SetDefaultConfig(T defaultValue) - { - defaultConfig = defaultValue; - if (currentConfig is not null) return; - Write(defaultConfig); - SaveChanges(); - } + internal void Load() // can be called internally if config changes; gui config not implemented yet + { + switch (configType) + { + case ConfigTypes.PerMod: + { + if (!File.Exists(perModConfigPath)) + { + return; + } + var jsonText = File.ReadAllText(perModConfigPath); + currentConfig = JsonSerializer.Deserialize(jsonText); + break; + } + case ConfigTypes.Exposed: + { + if (!File.Exists(ExposedConfigPath)) + { + return; + } + var jsonText = File.ReadAllText(ExposedConfigPath); + currentConfig = JsonNode.Parse(jsonText)![modName]?.Deserialize(); + break; + } + default: + throw new ArgumentOutOfRangeException(); + } + } + /// + /// Sets the default if the config does not exist yet. Always call this before reading from the config. + /// + public void SetDefaultConfig(T defaultValue) + { + defaultConfig = defaultValue; + if (currentConfig is not null) return; + Write(defaultConfig); + SaveChanges(); + } - /// - /// Writes the **entire** config. Usage not recommended, use Edit() instead - /// - public void Write(T config) - { - currentConfig = config; - } - /// - /// Gets the config. Should only be called after setting a default. - /// - public T Get() - { - return currentConfig ?? throw new InvalidOperationException("Must set default before reading config."); - } - /// - /// Edits the config. Should only be called after setting a default. - /// - /// Call SaveChanges after editing - public void Edit(Action editor) - { - editor(currentConfig ?? throw new InvalidOperationException("Must set default before reading config.")); - } - /// - /// Gets part of the config. Should only be called after setting a default - /// - public TResult Get(Func getter) - { - return getter(currentConfig ?? throw new InvalidOperationException("Must set default before reading config.")); - } - /// - /// Writes the config to disk - /// - public void SaveChanges() - { - switch (configType) - { - case ConfigTypes.PerMod: - var perModJson = JsonSerializer.Serialize(currentConfig, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(perModConfigPath, perModJson); - break; - case ConfigTypes.Exposed: - var modsConfigText = File.ReadAllText(ExposedConfigPath); - var modsConfigJson = JsonNode.Parse(modsConfigText)!.AsObject(); - modsConfigJson[modName] = JsonSerializer.SerializeToNode(currentConfig!); - File.WriteAllText(ExposedConfigPath, modsConfigJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); - break; - default: - throw new ArgumentOutOfRangeException(); - } - } + /// + /// Writes the **entire** config. Usage not recommended, use Edit() instead + /// + public void Write(T config) + { + currentConfig = config; + } + /// + /// Gets the config. Should only be called after setting a default. + /// + public T Get() + { + return currentConfig ?? throw new InvalidOperationException("Must set default before reading config."); + } + /// + /// Edits the config. Should only be called after setting a default. + /// + /// Call SaveChanges after editing + public void Edit(Action editor) + { + editor(currentConfig ?? throw new InvalidOperationException("Must set default before reading config.")); + } + /// + /// Gets part of the config. Should only be called after setting a default + /// + public TResult Get(Func getter) + { + return getter(currentConfig ?? throw new InvalidOperationException("Must set default before reading config.")); + } + /// + /// Writes the config to disk + /// + public void SaveChanges() + { + switch (configType) + { + case ConfigTypes.PerMod: + var perModJson = JsonSerializer.Serialize(currentConfig, indentedJsonOptions); + File.WriteAllText(perModConfigPath, perModJson); + break; + case ConfigTypes.Exposed: + var modsConfigText = File.ReadAllText(ExposedConfigPath); + var modsConfigJson = JsonNode.Parse(modsConfigText)!.AsObject(); + modsConfigJson[modName] = JsonSerializer.SerializeToNode(currentConfig!); + File.WriteAllText(ExposedConfigPath, modsConfigJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } - public enum ConfigTypes - { - PerMod, - Exposed - } -} \ No newline at end of file + public enum ConfigTypes + { + PerMod, + Exposed + } +} diff --git a/src/Api/GLDConfig.cs b/src/Api/GLDConfig.cs index ecda511..8ff0e62 100644 --- a/src/Api/GLDConfig.cs +++ b/src/Api/GLDConfig.cs @@ -7,88 +7,88 @@ namespace PolyMod.Api; public class GldConfigTemplate { - private static readonly string ConfigPath = Path.Combine(Plugin.BASE_PATH, "mods.json"); + private static readonly string ConfigPath = Path.Combine(Plugin.BASE_PATH, "mods.json"); - private readonly string templateText; - private JsonObject currentConfig = new(); - private string modName; + private readonly string templateText; + private JsonObject currentConfig = new(); + private string modName; - public GldConfigTemplate(string templateText, string modName) - { - this.templateText = templateText; - this.modName = modName; - Load(); - } - private void Load() - { - if (File.Exists(ConfigPath)) - { - var json = File.ReadAllText(ConfigPath); - if (JsonNode.Parse(json) is JsonObject modsConfig - && modsConfig.TryGetPropertyValue(modName, out var modConfigNode) - && modConfigNode is JsonObject modConfig) - { - currentConfig = modConfig; - return; - } - } - currentConfig = new JsonObject(); - } + public GldConfigTemplate(string templateText, string modName) + { + this.templateText = templateText; + this.modName = modName; + Load(); + } + private void Load() + { + if (File.Exists(ConfigPath)) + { + var json = File.ReadAllText(ConfigPath); + if (JsonNode.Parse(json) is JsonObject modsConfig + && modsConfig.TryGetPropertyValue(modName, out var modConfigNode) + && modConfigNode is JsonObject modConfig) + { + currentConfig = modConfig; + return; + } + } + currentConfig = new JsonObject(); + } - public string? Render() - { - if (!templateText.Contains("{{")) return templateText; - var template = Template.Parse(templateText); - var context = new TemplateContext(); - var scriptObject = new ScriptObject(); + public string? Render() + { + if (!templateText.Contains("{{")) return templateText; + var template = Template.Parse(templateText); + var context = new TemplateContext(); + var scriptObject = new ScriptObject(); - bool changedConfig = false; - scriptObject.Import("config", - new Func((key, defaultValue) => - { - if (currentConfig.TryGetPropertyValue(key, out var token) && token != null) - { - return token.ToString(); - } + bool changedConfig = false; + scriptObject.Import("config", + new Func((key, defaultValue) => + { + if (currentConfig.TryGetPropertyValue(key, out var token) && token != null) + { + return token.ToString(); + } - changedConfig = true; - currentConfig[key] = defaultValue; + changedConfig = true; + currentConfig[key] = defaultValue; - return defaultValue; - }) - ); - context.PushGlobal(scriptObject); - string? result; - try - { - result = template.Render(context); - } - catch (Exception e) - { - Plugin.logger.LogError("error during parse of gld patch template: " + e.ToString()); - result = null; - } - if (changedConfig) - { - SaveChanges(); - } - return result; - } + return defaultValue; + }) + ); + context.PushGlobal(scriptObject); + string? result; + try + { + result = template.Render(context); + } + catch (Exception e) + { + Plugin.logger.LogError("error during parse of gld patch template: " + e.ToString()); + result = null; + } + if (changedConfig) + { + SaveChanges(); + } + return result; + } - public void SaveChanges() - { - JsonObject modsConfigJson; - if (File.Exists(ConfigPath)) - { - var modsConfigText = File.ReadAllText(ConfigPath); - modsConfigJson = (JsonNode.Parse(modsConfigText) as JsonObject) ?? new JsonObject(); - } - else - { - modsConfigJson = new JsonObject(); - } + public void SaveChanges() + { + JsonObject modsConfigJson; + if (File.Exists(ConfigPath)) + { + var modsConfigText = File.ReadAllText(ConfigPath); + modsConfigJson = (JsonNode.Parse(modsConfigText) as JsonObject) ?? new JsonObject(); + } + else + { + modsConfigJson = new JsonObject(); + } - modsConfigJson[modName] = currentConfig; - File.WriteAllText(ConfigPath, modsConfigJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); - } -} \ No newline at end of file + modsConfigJson[modName] = currentConfig; + File.WriteAllText(ConfigPath, modsConfigJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } +} diff --git a/src/Api/PolyScript.cs b/src/Api/PolyScript.cs index 09fd36e..6dd697e 100644 --- a/src/Api/PolyScript.cs +++ b/src/Api/PolyScript.cs @@ -5,50 +5,50 @@ namespace PolyMod.Api; public abstract class PolyScriptBase { - /// - /// Initializes the PolyScript with the given mod and logger. - /// - /// The mod instance. - /// The logger instance. - internal abstract void Initialize(Mod mod, ManualLogSource logger); - - /// > - /// Called when the mod is loaded. - /// - public abstract void Load(); - - /// - /// Called when the mod is unloaded. - /// - public abstract void Unload(); - - internal PolyScriptBase() - { - } + /// + /// Initializes the PolyScript with the given mod and logger. + /// + /// The mod instance. + /// The logger instance. + internal abstract void Initialize(Mod mod, ManualLogSource logger); + + /// > + /// Called when the mod is loaded. + /// + public abstract void Load(); + + /// + /// Called when the mod is unloaded. + /// + public abstract void Unload(); + + internal PolyScriptBase() + { + } } public abstract class PolyScript : PolyScriptBase where TConfig : class where TExposedConfig : class { - internal override void Initialize(Mod mod, ManualLogSource logger) - { - Mod = mod; - Config = new Config(mod.id, Config.ConfigTypes.PerMod); - ExposedConfig = new Config(mod.id, Config.ConfigTypes.Exposed); - Logger = logger; - } - - internal byte[]? GetFile(string fileName) - { - return Registry.mods[Mod.id].files.FirstOrDefault(f => f.name == fileName)?.bytes; - } - - public override void Unload() { } - - public Mod Mod { get; private set; } = null!; - protected Config Config { get; private set; } = null!; - protected Config ExposedConfig { get; private set; } = null!; - protected ManualLogSource Logger { get; private set; } = null!; + internal override void Initialize(Mod mod, ManualLogSource logger) + { + Mod = mod; + Config = new Config(mod.id, Config.ConfigTypes.PerMod); + ExposedConfig = new Config(mod.id, Config.ConfigTypes.Exposed); + Logger = logger; + } + + internal byte[]? GetFile(string fileName) + { + return Registry.mods[Mod.id].files.FirstOrDefault(f => f.name == fileName)?.bytes; + } + + public override void Unload() { } + + public Mod Mod { get; private set; } = null!; + protected Config Config { get; private set; } = null!; + protected Config ExposedConfig { get; private set; } = null!; + protected ManualLogSource Logger { get; private set; } = null!; } public abstract class PolyScript : PolyScript { -} \ 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/EnumCacheJson.cs b/src/Json/EnumCacheJson.cs index 5f561c6..b0545f8 100644 --- a/src/Json/EnumCacheJson.cs +++ b/src/Json/EnumCacheJson.cs @@ -11,70 +11,70 @@ namespace PolyMod.Json; /// The enum type being converted. public class EnumCacheJson : JsonConverter where T : struct, Enum { - /// - /// Determines whether the converter can handle the given type. - /// Supports both the enum type and . - /// - /// The type to check for compatibility. - /// True if the type can be converted; otherwise false. - public override bool CanConvert(Type typeToConvert) - { - return typeToConvert == typeof(T) || typeToConvert == typeof(List); - } - - /// - /// Reads a JSON string and converts it into the corresponding enum value - /// using the EnumCache lookup. - /// - /// The JSON reader. - /// The type to convert into. - /// Serialization options. - /// The enum value represented by the JSON string. - /// Thrown if the type is not supported. - public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (typeToConvert == typeof(T)) - { - return EnumCache.GetType(reader.GetString()); - } - - throw new NotSupportedException("EnumCacheJson does not support reading this type directly."); - } - - - /// - /// Writes the enum value as its cached string representation. - /// - /// The JSON writer. - /// The enum value to write. - /// Serialization options. - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) - { - writer.WriteStringValue(EnumCache.GetName(value)); - } - - /// - /// Reads a JSON property name and converts it into the corresponding enum value. - /// - /// The JSON reader. - /// The target type. - /// Serialization options. - /// The enum value represented by the property name. - public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - return Read(ref reader, typeToConvert, options); - } - - /// - /// Writes the enum value as a property name using its cached string representation. - /// - /// The JSON writer. - /// The enum value to write. - /// Serialization options. - public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options) - { - writer.WritePropertyName(EnumCache.GetName(value)); - } + /// + /// Determines whether the converter can handle the given type. + /// Supports both the enum type and . + /// + /// The type to check for compatibility. + /// True if the type can be converted; otherwise false. + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert == typeof(T) || typeToConvert == typeof(List); + } + + /// + /// Reads a JSON string and converts it into the corresponding enum value + /// using the EnumCache lookup. + /// + /// The JSON reader. + /// The type to convert into. + /// Serialization options. + /// The enum value represented by the JSON string. + /// Thrown if the type is not supported. + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (typeToConvert == typeof(T)) + { + return EnumCache.GetType(reader.GetString()); + } + + throw new NotSupportedException("EnumCacheJson does not support reading this type directly."); + } + + + /// + /// Writes the enum value as its cached string representation. + /// + /// The JSON writer. + /// The enum value to write. + /// Serialization options. + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(EnumCache.GetName(value)); + } + + /// + /// Reads a JSON property name and converts it into the corresponding enum value. + /// + /// The JSON reader. + /// The target type. + /// Serialization options. + /// The enum value represented by the property name. + public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return Read(ref reader, typeToConvert, options); + } + + /// + /// Writes the enum value as a property name using its cached string representation. + /// + /// The JSON writer. + /// The enum value to write. + /// Serialization options. + public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WritePropertyName(EnumCache.GetName(value)); + } } /// @@ -84,52 +84,52 @@ public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSer /// The enum type being converted. public class EnumCacheListJson : JsonConverter> where T : struct, Enum { - /// - /// Inner converter used to handle individual enum values. - /// - private readonly EnumCacheJson _inner = new(); - - /// - /// Reads a JSON array of enum string values and converts them into a list of enum values. - /// - /// The JSON reader. - /// The type to convert into. - /// Serialization options. - /// A list of enum values. - /// Thrown if the JSON is not an array. - public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var list = new List(); - - if (reader.TokenType != JsonTokenType.StartArray) - throw new JsonException("Expected StartArray token for enum list"); - - while (reader.Read()) - { - if (reader.TokenType == JsonTokenType.EndArray) - break; - - list.Add(_inner.Read(ref reader, typeof(T), options)); - } - - return list; - } - - /// - /// Writes a list of enum values as a JSON array of strings. - /// - /// The JSON writer. - /// The list of enum values to write. - /// Serialization options. - public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) - { - writer.WriteStartArray(); - - foreach (var item in value) - { - _inner.Write(writer, item, options); - } - - writer.WriteEndArray(); - } + /// + /// Inner converter used to handle individual enum values. + /// + private readonly EnumCacheJson _inner = new(); + + /// + /// Reads a JSON array of enum string values and converts them into a list of enum values. + /// + /// The JSON reader. + /// The type to convert into. + /// Serialization options. + /// A list of enum values. + /// Thrown if the JSON is not an array. + public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var list = new List(); + + if (reader.TokenType != JsonTokenType.StartArray) + throw new JsonException("Expected StartArray token for enum list"); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + break; + + list.Add(_inner.Read(ref reader, typeof(T), options)); + } + + return list; + } + + /// + /// Writes a list of enum values as a JSON array of strings. + /// + /// The JSON writer. + /// The list of enum values to write. + /// Serialization options. + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + + foreach (var item in value) + { + _inner.Write(writer, item, options); + } + + writer.WriteEndArray(); + } } diff --git a/src/Json/EnumNameJson.cs b/src/Json/EnumNameJson.cs new file mode 100644 index 0000000..cbf157b --- /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 TEnum : struct, Enum +{ + public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? enumName = reader.GetString(); + + if (enumName != null && Enum.TryParse(enumName, true, out TEnum parsedValue)) + { + return parsedValue; + } + + string validNames = string.Join(", ", Enum.GetNames()); + throw new JsonException($"Unknown {typeof(TEnum).Name} value '{enumName}'. Valid values: {validNames}"); + } + + public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/Json/JsonMerger.cs b/src/Json/JsonMerger.cs index 44eb212..a556202 100644 --- a/src/Json/JsonMerger.cs +++ b/src/Json/JsonMerger.cs @@ -8,107 +8,107 @@ namespace PolyMod.Json; /// public static class JsonMerger { - /// - /// Merges a patch JObject into an original JObject. - /// - /// The original JObject. - /// The patch JObject to merge. - /// The merged JObject. - public static JObject Merge(JObject original, JObject patch) - { - patch = MergeRecursive(original, patch); - original.Merge(patch, new() { MergeArrayHandling = MergeArrayHandling.Replace, MergeNullValueHandling = MergeNullValueHandling.Merge }); - return original; - } + /// + /// Merges a patch JObject into an original JObject. + /// + /// The original JObject. + /// The patch JObject to merge. + /// The merged JObject. + public static JObject Merge(JObject original, JObject patch) + { + patch = MergeRecursive(original, patch); + original.Merge(patch, new() { MergeArrayHandling = MergeArrayHandling.Replace, MergeNullValueHandling = MergeNullValueHandling.Merge }); + return original; + } - /// - /// Recursively applies custom merging logic to arrays. - /// - /// - /// - /// - private static JObject MergeRecursive(JObject original, JObject patch) - { - foreach (var property in patch.Properties().ToArray().ToList()) - { - if (property == null || property.Name == null) - continue; + /// + /// Recursively applies custom merging logic to arrays. + /// + /// + /// + /// + private static JObject MergeRecursive(JObject original, JObject patch) + { + foreach (var property in patch.Properties().ToArray().ToList()) + { + if (property == null || property.Name == null) + continue; - string propName = property.Name; - JToken? originalValue = original[propName]; - JToken patchValue = property.Value; + string propName = property.Name; + JToken? originalValue = original[propName]; + JToken patchValue = property.Value; - if (patchValue.Type == JTokenType.Array) - { - if (originalValue == null) - originalValue = new JArray(); + if (patchValue.Type == JTokenType.Array) + { + if (originalValue == null) + originalValue = new JArray(); - JArray originalArr = originalValue.Cast(); - JArray patchArr = patchValue.Cast(); - bool isSkins = propName.Equals("skins", StringComparison.OrdinalIgnoreCase); - JArray merged = MergeArrays(originalArr, patchArr, isSkins); - patch[propName] = merged; - } - else if (patchValue.Type == JTokenType.Object) - { - if (originalValue == null || originalValue.Type != JTokenType.Object) - originalValue = new JObject(); + JArray originalArr = originalValue.Cast(); + JArray patchArr = patchValue.Cast(); + bool isSkins = propName.Equals("skins", StringComparison.OrdinalIgnoreCase); + JArray merged = MergeArrays(originalArr, patchArr, isSkins); + patch[propName] = merged; + } + else if (patchValue.Type == JTokenType.Object) + { + if (originalValue == null || originalValue.Type != JTokenType.Object) + originalValue = new JObject(); - MergeRecursive(originalValue.Cast(), patchValue.Cast()); - } - } - return patch; - } + MergeRecursive(originalValue.Cast(), patchValue.Cast()); + } + } + return patch; + } - /// - /// Merges two JArrays, with special handling for adding and removing elements. - /// - /// The original JArray. - /// The patch JArray to merge. - /// A flag to indicate if the array is a 'skins' array, which has special merging logic. - /// The merged JArray. - private static JArray MergeArrays(JArray original, JArray patch, bool isSkins) - { - var result = new JArray(original); - var patchList = patch._values.ToArray().ToList(); + /// + /// Merges two JArrays, with special handling for adding and removing elements. + /// + /// The original JArray. + /// The patch JArray to merge. + /// A flag to indicate if the array is a 'skins' array, which has special merging logic. + /// The merged JArray. + private static JArray MergeArrays(JArray original, JArray patch, bool isSkins) + { + var result = new JArray(original); + var patchList = patch._values.ToArray().ToList(); - bool hasCustomValues = false; - foreach (var token in patchList) - { - if (token.Type != JTokenType.String) - { - result.Add(token); - continue; - } + bool hasCustomValues = false; + foreach (var token in patchList) + { + if (token.Type != JTokenType.String) + { + result.Add(token); + continue; + } - string str = token.ToString(); + string str = token.ToString(); - if (str.StartsWith("+")) - { - string value = str.Substring(1); - result.Add(value); - hasCustomValues = true; - } - else if (str.StartsWith("-")) - { - string value = str.Substring(1); - var toRemove = result._values.ToArray() - .Where(t => t.Type == JTokenType.String && t.ToString() == value) - .ToList(); - foreach (var rem in toRemove) - result.Remove(rem); - hasCustomValues = true; - } - else if (isSkins) - { - result.Add(str); - hasCustomValues = true; - } - } - if (!hasCustomValues) - { - result = new JArray(patch); - } - return result; - } + if (str.StartsWith('+')) + { + string value = str.Substring(1); + result.Add(value); + hasCustomValues = true; + } + else if (str.StartsWith('-')) + { + string value = str.Substring(1); + var toRemove = result._values.ToArray() + .Where(t => t.Type == JTokenType.String && t.ToString() == value) + .ToList(); + foreach (var rem in toRemove) + result.Remove(rem); + hasCustomValues = true; + } + else if (isSkins) + { + result.Add(str); + hasCustomValues = true; + } + } + if (!hasCustomValues) + { + result = new JArray(patch); + } + return result; + } } diff --git a/src/Json/Vector2Json.cs b/src/Json/Vector2Json.cs index 1957c4d..a36e5df 100644 --- a/src/Json/Vector2Json.cs +++ b/src/Json/Vector2Json.cs @@ -9,40 +9,40 @@ namespace PolyMod.Json; /// public class Vector2Json : JsonConverter { - /// - /// Reads and converts the JSON to a . - /// - /// The reader. - /// The type to convert. - /// An object that specifies serialization options to use. - /// The converted value. - public override Vector2 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - List values = new(); - if (reader.TokenType == JsonTokenType.StartArray) - { - while (reader.Read()) - { - if (reader.TokenType == JsonTokenType.EndArray) break; - if (reader.TokenType != JsonTokenType.Number) throw new JsonException(); - values.Add(reader.GetSingle()); - } - } - if (values.Count != 2) throw new JsonException(); - return new(values[0], values[1]); - } + /// + /// Reads and converts the JSON to a . + /// + /// The reader. + /// The type to convert. + /// An object that specifies serialization options to use. + /// The converted value. + public override Vector2 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + List values = new(); + if (reader.TokenType == JsonTokenType.StartArray) + { + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) break; + if (reader.TokenType != JsonTokenType.Number) throw new JsonException(); + values.Add(reader.GetSingle()); + } + } + if (values.Count != 2) throw new JsonException(); + return new(values[0], values[1]); + } - /// - /// Writes a specified value as JSON. - /// - /// The writer to write to. - /// The value to convert to JSON. - /// An object that specifies serialization options to use. - public override void Write(Utf8JsonWriter writer, Vector2 value, JsonSerializerOptions options) - { - writer.WriteStartArray(); - writer.WriteNumberValue(value.x); - writer.WriteNumberValue(value.y); - writer.WriteEndArray(); - } + /// + /// Writes a specified value as JSON. + /// + /// The writer to write to. + /// The value to convert to JSON. + /// An object that specifies serialization options to use. + public override void Write(Utf8JsonWriter writer, Vector2 value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + writer.WriteNumberValue(value.x); + writer.WriteNumberValue(value.y); + writer.WriteEndArray(); + } } diff --git a/src/Json/VersionJson.cs b/src/Json/VersionJson.cs index 21c6c81..47b9067 100644 --- a/src/Json/VersionJson.cs +++ b/src/Json/VersionJson.cs @@ -8,26 +8,26 @@ namespace PolyMod.Json; /// public class VersionJson : JsonConverter { - /// - /// Reads and converts the JSON to a . - /// - /// The reader. - /// The type to convert. - /// An object that specifies serialization options to use. - /// The converted value. - public override Version? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - return new(reader.GetString()!); - } + /// + /// Reads and converts the JSON to a . + /// + /// The reader. + /// The type to convert. + /// An object that specifies serialization options to use. + /// The converted value. + public override Version? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new(reader.GetString()!); + } - /// - /// Writes a specified value as JSON. - /// - /// The writer to write to. - /// The value to convert to JSON. - /// An object that specifies serialization options to use. - public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options) - { - writer.WriteStringValue(value.ToString()); - } + /// + /// Writes a specified value as JSON. + /// + /// The writer to write to. + /// The value to convert to JSON. + /// An object that specifies serialization options to use. + public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } } diff --git a/src/Loader.cs b/src/Loader.cs index 87d5a76..1c99191 100644 --- a/src/Loader.cs +++ b/src/Loader.cs @@ -1,3 +1,4 @@ +using System.Globalization; using BepInEx.Logging; using Cpp2IL.Core.Extensions; using Il2CppSystem.Linq; @@ -8,10 +9,8 @@ using Polytopia.Data; using PolytopiaBackendBase.Game; using System.Diagnostics; -using System.Globalization; using System.IO.Compression; using System.Reflection; -using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using UnityEngine; @@ -24,1096 +23,992 @@ 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); - 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}"); - } - } - } + private static readonly JsonSerializerOptions manifestJsonOptions = new() + { + Converters = { new VersionJson() }, + }; + + private static readonly JsonSerializerOptions spriteInfoJsonOptions = new() + { + Converters = { new Vector2Json() }, + }; + + private static readonly JsonSerializerOptions prefabJsonOptions = new() + { + Converters = { new Vector2Json() }, + PropertyNameCaseInsensitive = true, + }; + + 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(CultureInfo.InvariantCulture)); + } + } + 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.ToLowerInvariant(), 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, + manifestJsonOptions + ); + return; + } + files.Add(new(fullName, bytes)); + } + + internal static void LoadMods(Dictionary mods, out bool dependencyCycle) + { + dependencyCycle = !SortMods(Registry.mods); + if (dependencyCycle) return; + + foreach (var (id, mod) in Registry.mods) + { + if (mod.status != Mod.Status.Success) continue; + foreach (var file in mod.files) + { + 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 + ); + } + } + } + Compatibility.ComputeChecksums(Registry.mods); + } + + 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.TryGetValue(dependency.id, out Mod? dependencyMod)) + { + message = $"Dependency {dependency.id} not found"; + } + else + { + Version version = dependencyMod.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, + spriteInfoJsonOptions + ); + + 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).ToLowerInvariant(); + 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, prefabJsonOptions); + 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, prefabJsonOptions); + 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, prefabJsonOptions); + 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, prefabJsonOptions); + 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..fa629a0 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; @@ -10,73 +9,101 @@ namespace PolyMod.Managers; /// public static class Audio { - /// - /// Patches the music data to get custom nature audio clips. - /// - [HarmonyPrefix] - [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)); - if (audioClip != null) - { - __result = audioClip; - return false; - } - return true; - } + /// + /// Patches the music data to get custom nature audio clips. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(MusicData), nameof(MusicData.GetNatureAudioClip))] + private static bool MusicData_GetNatureAudioClip(ref AudioClip __result, TribeType type, SkinType skinType) + { + AudioClip? audioClip = Registry.GetAudioClip("nature", type, skinType); + if (audioClip != null) + { + __result = audioClip; + return false; + } + return true; + } - /// - /// Patches the music data to get custom music audio clips. - /// - [HarmonyPrefix] - [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)); - if (audioClip != null) - { - __result = audioClip; - return false; - } - return true; - } + /// + /// Patches the music data to get custom music audio clips. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(MusicData), nameof(MusicData.GetMusicAudioClip))] + private static bool MusicData_GetMusicAudioClip(ref AudioClip __result, TribeType type, SkinType skinType) + { + AudioClip? audioClip = Registry.GetAudioClip("music", type, skinType); + if (audioClip != null) + { + __result = audioClip; + return false; + } + return true; + } - /// - /// Patches the audio SFX data to get custom sound effect clips. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(AudioSFXData), nameof(AudioSFXData.GetClip))] - private static bool AudioSFXData_GetClip(ref AudioClip __result, SFXTypes id, SkinType skinType) - { - AudioClip? audioClip = Registry.GetAudioClip( - EnumCache.GetName(id), - EnumCache.GetName(skinType) - ); - if (audioClip != null) - { - __result = audioClip; - return false; - } - return true; - } + /// + /// Patches the audio SFX data to get custom sound effect clips. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(AudioSFXData), nameof(AudioSFXData.GetClip))] + private static bool AudioSFXData_GetClip(ref AudioClip __result, SFXTypes id, SkinType skinType) + { + AudioClip? audioClip = Registry.GetAudioClip( + EnumCache.GetName(id), + skin: skinType + ); + if (audioClip != null) + { + __result = audioClip; + return false; + } + return true; + } - /// - /// Builds an audio clip from raw byte data. - /// - /// The byte data of the audio file. - /// The created audio clip. - public static AudioClip BuildAudioClip(byte[] data) - { - // TODO: This is a placeholder. The actual implementation is tracked in issue #71. - return new AudioClip(new()); - } + 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"; - /// - /// Initializes the Audio manager by patching the necessary methods. - /// - internal static void Init() - { - Harmony.CreateAndPatchAll(typeof(Audio)); - } + private static bool clipCreationUnavailable = false; + + /// + /// Builds an audio clip from raw byte data. + /// + /// 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. + /// 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") + { + 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); + } + } + + /// + /// Initializes the Audio manager by patching the necessary methods. + /// + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(Audio)); + } } 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..8083f05 100644 --- a/src/Managers/Compatibility.cs +++ b/src/Managers/Compatibility.cs @@ -1,7 +1,6 @@ using HarmonyLib; -using System.Text; +using Il2CppInterop.Runtime; using UnityEngine; -using UnityEngine.EventSystems; namespace PolyMod.Managers; @@ -10,227 +9,360 @@ namespace PolyMod.Managers; /// internal static class Compatibility { - /// - /// The checksum of all loaded mods. - /// - internal static string checksum = string.Empty; - - /// - /// Whether the game settings should be reset due to a change in mods. - /// - internal static bool shouldResetSettings = false; - private static bool sawSignatureWarning; - - /// - /// Hashes the signatures of all loaded mods to create a checksum. - /// - /// A string builder containing the signatures to hash. - public static void HashSignatures(StringBuilder checksumString) - { - checksum = Util.Hash(checksumString); - } - - /// - /// Checks the signature of a saved game to ensure it is compatible with the current mods. - /// - /// True if the signatures match or if the check is ignored, false otherwise. - private static bool CheckSignatures(Action action, Il2CppSystem.Guid gameId) - { - if (sawSignatureWarning) - { - sawSignatureWarning = false; - return true; - } - - string stateChecksum = string.Empty; - try - { - Plugin.logger.LogInfo($"Getting checksum for state {gameId}"); - stateChecksum = File.ReadAllText(Path.Combine(Application.persistentDataPath, $"{gameId}.signatures")); - Plugin.logger.LogInfo($"Checksum found."); - } - catch - { - Plugin.logger.LogInfo($"Failed to get checksum."); - } - if (stateChecksum == string.Empty) - { - Plugin.logger.LogInfo($"State checksum is empty, ignoring."); - return true; - } - bool doChecksumsMatch = stateChecksum == checksum; - Plugin.logger.LogInfo($"State checksum: '{stateChecksum}', global checksum: '{checksum}', comparison result : {doChecksumsMatch}"); - if (Plugin.config.debug) - { - Plugin.logger.LogInfo($"Debug detected, ignoring."); - return true; - } - if (!doChecksumsMatch) - { - PopupManager.GetBasicPopupWithData(new( - Localization.Get("polymod.signature.mismatch"), - Localization.Get("polymod.signature.incompatible"), - new(new PopupBase.PopupButtonData[] { - new("OK") - }) - )).Show(); - - return false; - } - return true; - } - - /// - /// Performs compatibility checks when the start screen is shown. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnShow))] - private static void StartScreen_UI2_OnShow() - { - string lastChecksum = checksum; - try - { - lastChecksum = new(File.ReadAllText(Plugin.CHECKSUM_PATH)); - } - catch (FileNotFoundException) { } - - File.WriteAllText( - Plugin.CHECKSUM_PATH, - checksum - ); - if (lastChecksum != checksum) - { - shouldResetSettings = true; - } - - Version incompatibilityWarningLastVersion = new(PlayerPrefs.GetString( - Plugin.INCOMPATIBILITY_WARNING_LAST_VERSION_KEY, - Plugin.POLYTOPIA_VERSION.CutRevision().ToString() - )); - if (VersionManager.SemanticVersion.Cast().CutRevision() > incompatibilityWarningLastVersion) - { - PlayerPrefs.SetString( - Plugin.INCOMPATIBILITY_WARNING_LAST_VERSION_KEY, - VersionManager.SemanticVersion.Cast().CutRevision().ToString() - ); - PlayerPrefs.Save(); - PopupManager.GetBasicPopupWithData( - new( - Localization.Get("polymod.version.mismatch"), - Localization.Get("polymod.version.mismatch.description"), - new PopupBase.PopupButtonData[] { - new("buttons.stay", customColorStates: ColorConstants.redButtonColorStates), - new( - "buttons.exitgame", - PopupBase.PopupButtonData.States.None, - (Il2CppSystem.Action)Application.Quit, - closesPopup: false - ) - } - ) - ).Show(); - } - } - - /// - /// Checks the signature of a pass-and-play game before loading it. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(GameInfoPopup), nameof(GameInfoPopup.OnMainButtonClicked))] - private static bool GameInfoPopup_OnMainButtonClicked(GameInfoPopup __instance) - { - return CheckSignatures(__instance.OnMainButtonClicked, __instance.gameId); - } - - /// - /// Checks the signature of a single-player game before resuming it. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnResumeButtonLongPress))] - private static bool StartScreen_OnResumeButtonClick(StartScreen_UI2 __instance) - { - return CheckSignatures(__instance.OnResumeButtonLongPress, LocalSaveFileUtils.GetSaveFiles(PolytopiaBackendBase.Game.GameType.SinglePlayer)[0]); - } - - /// - /// Deletes the signature file of a pass-and-play game when it is deleted. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(GameInfoPopup), nameof(GameInfoPopup.DeletePaPGame))] - private static void GameInfoPopup_DeletePaPGame(GameInfoPopup __instance) - { - File.Delete(Path.Combine(Application.persistentDataPath, $"{__instance.gameId}.signatures")); - } - - /// - /// Deletes the signature file of all singleplayer games when they are deleted. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(LocalSaveFileUtils), nameof(LocalSaveFileUtils.DeleteAllSaveFilesOfType))] - private static void LocalSaveFileUtils_DeleteAllSaveFilesOfType(PolytopiaBackendBase.Game.GameType gameType, bool localOnly) - { - if (gameType == PolytopiaBackendBase.Game.GameType.SinglePlayer) - { - foreach (var gameId in LocalSaveFileUtils.GetSaveFiles(PolytopiaBackendBase.Game.GameType.SinglePlayer)) - { - File.Delete(Path.Combine(Application.persistentDataPath, $"{gameId}.signatures")); - } - } - } - - /// - /// Deletes the signature file of a game when the match ends. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(GameManager), nameof(GameManager.MatchEnded))] - private static void GameManager_MatchEnded(bool localPlayerIsWinner, ScoreDetails scoreDetails, byte winnerId) - { - File.Delete(Path.Combine(Application.persistentDataPath, $"{GameManager.Client.gameId}.signatures")); - } - - /// - /// Creates a signature file when a new game session is created. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(ClientBase), nameof(ClientBase.CreateSession), typeof(GameSettings), typeof(Il2CppSystem.Guid))] - private static void ClientBase_CreateSession(GameSettings settings, Il2CppSystem.Guid gameId) - { - File.WriteAllTextAsync( - Path.Combine(Application.persistentDataPath, $"{gameId}.signatures"), - checksum - ); - } - - /// - /// Resets game settings if necessary when the tribe selector screen is shown. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(TribeSelectorScreen), nameof(TribeSelectorScreen.Show))] - private static bool TribeSelectorScreen_Show(bool instant = false) - { - if (shouldResetSettings) - { - RestorePreliminaryGameSettings(); - shouldResetSettings = false; - } - return true; - } - - /// - /// Restores the preliminary game settings to their default values. - /// - internal static void RestorePreliminaryGameSettings() - { - GameManager.PreliminaryGameSettings.disabledTribes.Clear(); - GameManager.PreliminaryGameSettings.selectedSkins.Clear(); - GameManager.PreliminaryGameSettings.SaveToDisk(); - } - - /// - /// Initializes the Compatibility manager by patching the necessary methods. - /// - internal static void Init() - { - Harmony.CreateAndPatchAll(typeof(Compatibility)); - } + /// + /// The checksum of all loaded mods. + /// + internal static string checksum = string.Empty; + + /// + /// Whether the game settings should be reset due to a change in mods. + /// + internal static bool shouldResetSettings = false; + private static bool sawSignatureWarning; + private const string downloadLink = "https://polymod.dev/download/"; + + public static void ComputeChecksums(Dictionary mods) + { + foreach (var (_, mod) in mods) + { + mod.checksum = ComputeModChecksum(mod); + } + + List signatures = new(); + foreach (var (id, mod) in mods) + { + if (mod.status != Mod.Status.Success) continue; + if (mod.client || id == "polytopia") continue; + + signatures.Add($"{id}:{mod.checksum}"); + } + signatures.Sort(StringComparer.Ordinal); + + checksum = Util.Hash(string.Join("\n", signatures)); + } + + public static string ComputeModChecksum(Mod mod) + { + List entries = new(); + foreach (Mod.File file in mod.files) + { + string name = file.name.Replace('\\', '/'); + if (name.EndsWith('/')) continue; + + entries.Add($"{name}:{Util.HashBytes(file.bytes)}"); + } + entries.Sort(StringComparer.Ordinal); + + return Util.Hash($"{mod.id}:{mod.version}\n{string.Join("\n", entries)}"); + } + + /// + /// Gets the player prefs key holding the checksum a saved game was created with. + /// + /// The id of the saved game. + /// The player prefs key. + private static string StateChecksumKey(object gameId) + { + return Plugin.STATE_CHECKSUM_KEY_PREFIX + NormalizeGameId(gameId); + } + + /// + /// Normalizes a game id so that the same save always maps to one key. The game hands ids over + /// as a Guid in some places and as a string in others, and those do not always match in case + /// or in whether they are wrapped in braces. + /// + /// The id of the saved game, as a Guid or a string. + /// The normalized id. + internal static string NormalizeGameId(object gameId) + { + return gameId.ToString()!.Trim('{', '}').ToLowerInvariant(); + } + + /// + /// Gets the path of the signature file older versions wrote for a saved game. + /// + /// The id of the saved game. + /// The path of the legacy signature file. + private static string LegacyStateChecksumPath(object gameId) + { + return Path.Combine(Application.persistentDataPath, $"{NormalizeGameId(gameId)}.signatures"); + } + + /// + /// Stores the current mod checksum for a saved game. + /// + /// The id of the saved game. + internal static void SaveStateChecksum(object gameId) + { + PlayerPrefs.SetString(StateChecksumKey(gameId), checksum); + PlayerPrefs.Save(); + } + + /// + /// Reads the mod checksum a saved game was created with, migrating it out of the + /// signature file older versions used. + /// + /// The id of the saved game. + /// The stored checksum, or an empty string when there is none. + internal static string ReadStateChecksum(object gameId) + { + string key = StateChecksumKey(gameId); + if (PlayerPrefs.HasKey(key)) + return PlayerPrefs.GetString(key); + + string legacyPath = LegacyStateChecksumPath(gameId); + if (!File.Exists(legacyPath)) + return string.Empty; + + string legacyChecksum = File.ReadAllText(legacyPath); + PlayerPrefs.SetString(key, legacyChecksum); + PlayerPrefs.Save(); + File.Delete(legacyPath); + return legacyChecksum; + } + + internal static void DeleteStateChecksum(object gameId) + { + PlayerPrefs.DeleteKey(StateChecksumKey(gameId)); + PlayerPrefs.Save(); + + string legacyPath = LegacyStateChecksumPath(gameId); + if (File.Exists(legacyPath)) + File.Delete(legacyPath); + } + + /// + /// Checks the signature of a saved game to ensure it is compatible with the current mods. + /// + /// True if the signatures match or if the check is ignored, false otherwise. + private static bool CheckSignatures(Action action, Il2CppSystem.Guid gameId) + { + if (sawSignatureWarning) + { + sawSignatureWarning = false; + return true; + } + + string stateChecksum = string.Empty; + try + { + Plugin.logger.LogInfo($"Getting checksum for state {gameId}"); + stateChecksum = ReadStateChecksum(gameId); + Plugin.logger.LogInfo($"Checksum found."); + } + catch + { + Plugin.logger.LogInfo($"Failed to get checksum."); + } + if (stateChecksum == string.Empty) + { + Plugin.logger.LogInfo($"State checksum is empty, ignoring."); + return true; + } + bool doChecksumsMatch = stateChecksum == checksum; + Plugin.logger.LogInfo($"State checksum: '{stateChecksum}', global checksum: '{checksum}', comparison result : {doChecksumsMatch}"); + if (Plugin.config.debug) + { + Plugin.logger.LogInfo($"Debug detected, ignoring."); + return true; + } + if (!doChecksumsMatch) + { + PopupManager.GetBasicPopupWithData(new( + Localization.Get("polymod.signature.mismatch"), + Localization.Get("polymod.signature.incompatible"), + new(new PopupBase.PopupButtonData[] { + new("OK") + }) + )).Show(); + + return false; + } + return true; + } + + /// + /// Performs compatibility checks when the start screen is shown. + /// + [HarmonyPostfix] + [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 + { + lastChecksum = new(File.ReadAllText(Plugin.CHECKSUM_PATH)); + } + catch (FileNotFoundException) { } + + File.WriteAllText( + Plugin.CHECKSUM_PATH, + checksum + ); + if (lastChecksum != checksum) + { + shouldResetSettings = true; + } + + Version incompatibilityWarningLastVersion = new(PlayerPrefs.GetString( + Plugin.INCOMPATIBILITY_WARNING_LAST_VERSION_KEY, + Plugin.POLYTOPIA_VERSION.CutRevision().ToString() + )); + if (VersionManager.SemanticVersion.Cast().CutRevision() > incompatibilityWarningLastVersion) + { + PlayerPrefs.SetString( + Plugin.INCOMPATIBILITY_WARNING_LAST_VERSION_KEY, + VersionManager.SemanticVersion.Cast().CutRevision().ToString() + ); + PlayerPrefs.Save(); + PopupManager.GetBasicPopupWithData( + new( + Localization.Get("polymod.version.mismatch"), + Localization.Get("polymod.version.mismatch.description"), + new PopupBase.PopupButtonData[] { + new("buttons.stay", customColorStates: ColorConstants.redButtonColorStates), + new( + "buttons.exitgame", + PopupBase.PopupButtonData.States.None, + (Il2CppSystem.Action)Application.Quit, + closesPopup: false + ) + } + ) + ).Show(); + } + } + + /// + /// Checks the signature of a pass-and-play game before loading it. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(GameInfoPopup), nameof(GameInfoPopup.OnMainButtonClicked))] + private static bool GameInfoPopup_OnMainButtonClicked(GameInfoPopup __instance) + { + return CheckSignatures(__instance.OnMainButtonClicked, __instance.gameId); + } + + /// + /// Checks the signature of a single-player game before resuming it. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnResumeButtonLongPress))] + private static bool StartScreen_OnResumeButtonClick(StartScreen_UI2 __instance) + { + return CheckSignatures(__instance.OnResumeButtonLongPress, LocalSaveFileUtils.GetSaveFiles(PolytopiaBackendBase.Game.GameType.SinglePlayer)[0]); + } + + /// + /// Deletes the stored checksum of a pass-and-play game when it is deleted. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(GameInfoPopup), nameof(GameInfoPopup.DeletePaPGame))] + private static void GameInfoPopup_DeletePaPGame(GameInfoPopup __instance) + { + DeleteStateChecksum(__instance.gameId); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(LocalSaveFileUtils), nameof(LocalSaveFileUtils.DeleteSaveFile))] + private static void LocalSaveFileUtils_DeleteSaveFile(PolytopiaBackendBase.Game.GameType gameType, string gameId, bool localOnly) + { + DeleteStateChecksum(gameId); + } + + /// + /// Deletes the stored checksums of all saved games of a type when they are deleted. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(LocalSaveFileUtils), nameof(LocalSaveFileUtils.DeleteAllSaveFilesOfType))] + private static void LocalSaveFileUtils_DeleteAllSaveFilesOfType(PolytopiaBackendBase.Game.GameType gameType, bool localOnly) + { + foreach (var gameId in LocalSaveFileUtils.GetSaveFiles(gameType)) + { + DeleteStateChecksum(gameId); + } + } + + /// + /// Deletes the stored checksum of a game when the match ends. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(GameManager), nameof(GameManager.MatchEnded))] + private static void GameManager_MatchEnded(bool localPlayerIsWinner, ScoreDetails scoreDetails, byte winnerId) + { + DeleteStateChecksum(GameManager.Client.gameId); + } + + /// + /// Stores the current mod checksum when a new game session is created. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(ClientBase), nameof(ClientBase.CreateSession), typeof(GameSettings), typeof(Il2CppSystem.Guid))] + private static void ClientBase_CreateSession(GameSettings settings, Il2CppSystem.Guid gameId) + { + SaveStateChecksum(gameId); + } + + /// + /// Resets game settings if necessary when the tribe selector screen is shown. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(TribeSelectorScreen), nameof(TribeSelectorScreen.Show))] + private static bool TribeSelectorScreen_Show(bool instant = false) + { + if (shouldResetSettings) + { + RestorePreliminaryGameSettings(); + shouldResetSettings = false; + } + return true; + } + + /// + /// Restores the preliminary game settings to their default values. + /// + internal static void RestorePreliminaryGameSettings() + { + GameManager.PreliminaryGameSettings.disabledTribes.Clear(); + GameManager.PreliminaryGameSettings.selectedSkins.Clear(); + GameManager.PreliminaryGameSettings.SaveToDisk(); + } + + /// + /// Initializes the Compatibility manager by patching the necessary methods. + /// + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(Compatibility)); + } } diff --git a/src/Managers/Hub.cs b/src/Managers/Hub.cs index 5afdaf9..e28648e 100644 --- a/src/Managers/Hub.cs +++ b/src/Managers/Hub.cs @@ -1,443 +1,413 @@ -using System.Text.Json; +using System.Globalization; 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 int CHECKSUM_LENGTH = 12; + 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++; + } + + string summary = Localization.Get( + "polymod.hub.summary", + Args(loaded.ToString(CultureInfo.InvariantCulture), Registry.mods.Count.ToString(CultureInfo.InvariantCulture)) + ); + summary += " " + Localization.Get( + "polymod.hub.checksum", + Args(ShortChecksum(Compatibility.checksum)) + ); + + List blocks = new() { Muted(summary) }; + foreach (Mod mod in Registry.mods.Values) + { + blocks.Add(BuildModEntry(mod)); + } + return string.Join("\n\n", blocks); + } + + /// + /// Shortens a checksum to the leading characters shown in the hub. + /// + /// The full checksum. + /// The shortened checksum. + private static string ShortChecksum(string checksum) + { + if (string.IsNullOrEmpty(checksum)) return "-"; + + return checksum.Length <= CHECKSUM_LENGTH ? checksum : checksum.Substring(0, CHECKSUM_LENGTH); + } + + 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}"); + } + if (!string.IsNullOrEmpty(mod.checksum)) + { + string modChecksum = Localization.Get( + "polymod.hub.mod.checksum", + Args(ShortChecksum(mod.checksum)) + ); + meta += " " + Muted($"{modChecksum}"); + } + + 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/Loc.cs b/src/Managers/Loc.cs index b3b7d17..239b4be 100644 --- a/src/Managers/Loc.cs +++ b/src/Managers/Loc.cs @@ -1,3 +1,4 @@ +using System.Globalization; using HarmonyLib; using I2.Loc; using Il2CppInterop.Runtime.InteropTypes.Arrays; @@ -13,163 +14,163 @@ namespace PolyMod.Managers; /// public static class Loc { - internal static Dictionary> languagesToAdd = new(); - internal static Dictionary buildingsInfoOverrides = new(); - - /// - /// Patches the localization getter to handle custom enum values. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(Localization), nameof(Localization.Get), typeof(string), typeof(Il2CppReferenceArray))] - private static bool Localization_Get(ref string key, Il2CppReferenceArray args) - { - List keys = key.Split('.').ToList(); - int? idx = null; - string? name = null; - - // Find any custom enum indices in the localization key - foreach (string item in keys) - { - if (int.TryParse(item, out int parsedIdx)) - { - if (parsedIdx >= Plugin.AUTOIDX_STARTS_FROM) - { - idx = parsedIdx; - } - } - } - - // If a custom enum index is found, try to resolve its name - if (idx != null) - { - foreach (var typeMapping in Loader.typeMappings.Values) - { - if (!typeMapping.shouldCreateCache) - continue; - - MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(typeMapping.type).GetMethod("TryGetName"); - if (methodInfo != null) - { - object?[] parameters = { idx, null }; - object? methodInvokeResult = methodInfo.Invoke(null, parameters); - if (methodInvokeResult != null) - { - if ((bool)methodInvokeResult) - { - name = (string?)parameters[1]; - } - } - } - } - - // If the name was resolved, replace the index with the name in the key - if (name != null && idx != null) - { - int index = keys.IndexOf(idx.ToString()!); - keys[index] = name; - key = string.Join(".", keys); - } - } - return true; - } - - [HarmonyPrefix] - [HarmonyPatch(typeof(Localization), nameof(Localization.Init))] - private static bool Localization_Init() - { - if (Localization.initialized) - return true; - - if (LocalizationManager.Sources.Count == 0) - LocalizationManager.UpdateSources(); - - LanguageSourceData source = LocalizationManager.Sources[0]; - - // Elyrion has no language code. I am almost completely sure it is a bug, by looking at the code. - LanguageData elyrionLanguage = source.GetLanguageData("Elyrion"); - elyrionLanguage.Code = Localization.LANG_CODE_ELYRION; - - foreach (var languageCode in languagesToAdd.Keys) - { - Dictionary terms = languagesToAdd[languageCode]; - - int languageIndex = source.GetLanguageIndexFromCode(languageCode); - string languageName = terms["language"]; - if (languageIndex == -1) - { - source.AddLanguage(languageName, languageCode); - languageIndex = source.GetLanguageIndex(languageName); - } - - foreach (var kvp in terms) - { - TermData term = source.GetTermData(kvp.Key); - if (term == null) - { - source.AddTerm(kvp.Key); - term = source.GetTermData(kvp.Key); - } - - term.Languages[languageIndex] = kvp.Value; - } - - LocalizationManager.UpdateSources(); - - Plugin.logger.LogInfo($"{languageCode} language terms added."); - } - return true; - } - - [HarmonyPrefix] - [HarmonyPatch(typeof(BuildingUtils), nameof(BuildingUtils.GetInfo))] - private static bool BuildingUtils_GetInfo(ref string __result, SkinType skinOfCurrentLocalPlayer, - ImprovementData improvementData, ImprovementState improvementState, PlayerState owner, TileData tileData) - { - TribeType tribe = TribeType.None; - if (owner != null) - tribe = owner.tribe; - - string key = EnumCache.GetName(improvementData.type); - if (buildingsInfoOverrides.ContainsKey(key)) - { - __result = Localization.GetSkinned(skinOfCurrentLocalPlayer, tribe, buildingsInfoOverrides[key]); - return false; - } - - return true; - } - - /// - /// Builds and loads localization data from a dictionary. - /// - /// A dictionary containing the localization data. - internal static void BuildAndLoadLocalization(Dictionary> localization) - { - foreach (var (key, data) in localization) - { - string name = ReplaceDashesWithDots(key); - if (name.StartsWith("tribeskins")) name = "TribeSkins/" + name; - TermData term = LocalizationManager.Sources[0].AddTerm(name); - List strings = new(); - foreach (string language in LocalizationManager.GetAllLanguages(false)) - { - strings.Add(data.GetOrDefault(language, data.GetOrDefault("English", term.Term))!); - } - term.Languages = new Il2CppStringArray(strings.ToArray()); - } - } - - internal static string ReplaceDashesWithDots(string key) - { - string name = key.Replace("_", "."); - return name.Replace("..", "_"); - } - - /// - /// Initializes the Loc manager by patching the necessary methods. - /// - internal static void Init() - { - Harmony.CreateAndPatchAll(typeof(Loc)); - } + internal static Dictionary> languagesToAdd = new(); + internal static Dictionary buildingsInfoOverrides = new(); + + /// + /// Patches the localization getter to handle custom enum values. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(Localization), nameof(Localization.Get), typeof(string), typeof(Il2CppReferenceArray))] + private static bool Localization_Get(ref string key, Il2CppReferenceArray args) + { + List keys = key.Split('.').ToList(); + int? idx = null; + string? name = null; + + // Find any custom enum indices in the localization key + foreach (string item in keys) + { + if (int.TryParse(item, out int parsedIdx)) + { + if (parsedIdx >= Plugin.AUTOIDX_STARTS_FROM) + { + idx = parsedIdx; + } + } + } + + // If a custom enum index is found, try to resolve its name + if (idx != null) + { + foreach (var typeMapping in Loader.typeMappings.Values) + { + if (!typeMapping.shouldCreateCache) + continue; + + MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(typeMapping.type).GetMethod("TryGetName"); + if (methodInfo != null) + { + object?[] parameters = { idx, null }; + object? methodInvokeResult = methodInfo.Invoke(null, parameters); + if (methodInvokeResult != null) + { + if ((bool)methodInvokeResult) + { + name = (string?)parameters[1]; + } + } + } + } + + // If the name was resolved, replace the index with the name in the key + if (name != null && idx != null) + { + int index = keys.IndexOf(idx.Value.ToString(CultureInfo.InvariantCulture)); + keys[index] = name; + key = string.Join(".", keys); + } + } + return true; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(Localization), nameof(Localization.Init))] + private static bool Localization_Init() + { + if (Localization.initialized) + return true; + + if (LocalizationManager.Sources.Count == 0) + LocalizationManager.UpdateSources(); + + LanguageSourceData source = LocalizationManager.Sources[0]; + + // Elyrion has no language code. I am almost completely sure it is a bug, by looking at the code. + LanguageData elyrionLanguage = source.GetLanguageData("Elyrion"); + elyrionLanguage.Code = Localization.LANG_CODE_ELYRION; + + foreach (var languageCode in languagesToAdd.Keys) + { + Dictionary terms = languagesToAdd[languageCode]; + + int languageIndex = source.GetLanguageIndexFromCode(languageCode); + string languageName = terms["language"]; + if (languageIndex == -1) + { + source.AddLanguage(languageName, languageCode); + languageIndex = source.GetLanguageIndex(languageName); + } + + foreach (var kvp in terms) + { + TermData term = source.GetTermData(kvp.Key); + if (term == null) + { + source.AddTerm(kvp.Key); + term = source.GetTermData(kvp.Key); + } + + term.Languages[languageIndex] = kvp.Value; + } + + LocalizationManager.UpdateSources(); + + Plugin.logger.LogInfo($"{languageCode} language terms added."); + } + return true; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(BuildingUtils), nameof(BuildingUtils.GetInfo))] + private static bool BuildingUtils_GetInfo(ref string __result, SkinType skinOfCurrentLocalPlayer, + ImprovementData improvementData, ImprovementState improvementState, PlayerState owner, TileData tileData) + { + TribeType tribe = TribeType.None; + if (owner != null) + tribe = owner.tribe; + + string key = EnumCache.GetName(improvementData.type); + if (buildingsInfoOverrides.TryGetValue(key, out string? buildingInfoOverride)) + { + __result = Localization.GetSkinned(skinOfCurrentLocalPlayer, tribe, buildingInfoOverride); + return false; + } + + return true; + } + + /// + /// Builds and loads localization data from a dictionary. + /// + /// A dictionary containing the localization data. + internal static void BuildAndLoadLocalization(Dictionary> localization) + { + foreach (var (key, data) in localization) + { + string name = ReplaceDashesWithDots(key); + if (name.StartsWith("tribeskins", StringComparison.Ordinal)) name = "TribeSkins/" + name; + TermData term = LocalizationManager.Sources[0].AddTerm(name); + List strings = new(); + foreach (string language in LocalizationManager.GetAllLanguages(false)) + { + strings.Add(data.GetOrDefault(language, data.GetOrDefault("English", term.Term))!); + } + term.Languages = new Il2CppStringArray(strings.ToArray()); + } + } + + internal static string ReplaceDashesWithDots(string key) + { + string name = key.Replace("_", "."); + return name.Replace("..", "_"); + } + + /// + /// Initializes the Loc manager by patching the necessary methods. + /// + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(Loc)); + } } diff --git a/src/Managers/Main.cs b/src/Managers/Main.cs index f271e77..a2483ad 100644 --- a/src/Managers/Main.cs +++ b/src/Managers/Main.cs @@ -17,432 +17,482 @@ 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"); - } + private static readonly string[] polytopiaAuthors = { "Midjiwan AB" }; + + 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).ToLowerInvariant(); + 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()), + polytopiaAuthors, + 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(); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(UnityEngine.Application), nameof(UnityEngine.Application.persistentDataPath), MethodType.Getter)] + private static void Application_persistentDataPath_get(ref string __result) + { + __result = Plugin.PERSISTENT_DATA_PATH; + } + + /// + /// Loads all mod content. + /// + /// 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..a226835 --- /dev/null +++ b/src/Managers/Prefabs.cs @@ -0,0 +1,653 @@ +using HarmonyLib; +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)); + } +} diff --git a/src/Managers/TribePreviews.cs b/src/Managers/TribePreviews.cs new file mode 100644 index 0000000..f545572 --- /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.TryGetValue(saveStateData.name, out PreviewInfo? existingPreview)) + { + previewSaveStateDatas[saveStateData.name] = new(i, saveStateData, existingPreview.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)); + } +} diff --git a/src/Managers/Visual.cs b/src/Managers/Visual.cs index 6f68ce6..fcb6450 100644 --- a/src/Managers/Visual.cs +++ b/src/Managers/Visual.cs @@ -1,14 +1,10 @@ -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,938 +14,537 @@ 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 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 - } - - /// 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; - } - } - - /// 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); - - #region General - - /// A placeholder patch for the TechItem.SetupComplete method. - [HarmonyPostfix] - [HarmonyPatch(typeof(TechItem), nameof(TechItem.SetupComplete))] - 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) - { - if (spriteAtlas != null) - { - 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; - } - } - } - } - - /// 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); - - foreach (var visualPart in skinVisuals.visualParts) - { - if (visualPart.visualPart != null) - UpdateVisualPart(visualPart, $"{visualPart.visualPart.name}_{unitTypeName}", style); - } - } - - [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 - )) - { - __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); - } - } - } - - /// 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; - } - } - - /// 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) - { - string propertyName = terrain.ToLower(); - terrain = "field"; - - PropertyInfo? rendererProperty = tile.GetType().GetProperty(propertyName + "Renderer", - BindingFlags.Public | BindingFlags.Instance); - - 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); - } - } - } - } - - Sprite? sprite = Registry.GetSprite(terrain + flood, Util.GetStyle(tribe, skinType)); - if (sprite != null) - { - __instance.spriteRenderer.Sprite = sprite; - } - } - - /// Adds custom flood tile effect when tile is flooded for custom aqua skins. - [HarmonyPostfix] - [HarmonyPatch(typeof(TileData), nameof(TileData.Flood))] - private static void TileData_Flood(TileData __instance, PlayerState playerState) - { - if (customFloodingSkins.ContainsValue(playerState.skinType)) - { - TileData.EffectType effectType = customFloodingSkins.FirstOrDefault(x => x.Value == playerState.skinType).Key; - __instance.AddEffect(effectType); - } - } - - /// Removes custom flood effect loc from tile tip header. - [HarmonyPostfix] - [HarmonyPatch(typeof(TileTipGenerator), nameof(TileTipGenerator.GetTileTipHeader))] - public static void GetTileTipHeader(ref string __result, TileData tileData, SkinType skin, - PlayerState playerState, GameState gameState, GameLogicData gameLogicData) - { - foreach (var effect in tileData.effects) - { - if (customFloodingSkins.ContainsKey(effect)) - { - string localizedEffect = Localization.GetSkinned(skin, playerState.tribe, $"tile.effect.{effect.GetName()}"); - __result = __result.Replace($", {localizedEffect}", string.Empty); - } - } - } - - /// 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); - } - } - - [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; - } - } - } - - #endregion - #region Popups - - /// Updates the width of a basic popup if a custom width is set. - [HarmonyPostfix] - [HarmonyPatch(typeof(PopupBase), nameof(PopupBase.RefreshHeightWhenHeightIsKnownAndDontUseCoroutines))] - private static void PopupBase_RefreshHeightWhenHeightIsKnownAndDontUseCoroutines(PopupBase __instance) - { - 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; - } - } - - /// Removes a popup's custom width when it is hidden. - [HarmonyPostfix] - [HarmonyPatch(typeof(PopupBase), nameof(PopupBase.Hide))] - 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) - { - if (seenWarningWCPopup) - return true; - - BasicPopup popup = PopupManager.GetBasicPopup(); - popup.Header = Localization.Get("polymod.hub"); - popup.Description = Localization.Get("polymod.wc.warning", new Il2CppSystem.Object[] { Localization.Get("weeklychallenge", new Il2CppSystem.Object[] { }) }); - - void WCProceed() - { - seenWarningWCPopup = true; - __instance.OnWeeklyChallengeClicked(); - } - List popupButtons = new() - { - new("buttons.back"), - new( - "polymod.wc.proceed", - PopupBase.PopupButtonData.States.None, - callback: DelegateSupport.ConvertDelegate(WCProceed), - customColorStates: ColorConstants.redButtonColorStates - ) - }; - popup.buttonData = popupButtons.ToArray(); - popup.Show(); - 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(); - } - - // [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; - // } - - #endregion - - internal static BasicPopupLegacy GetBasicPopupLegacy() - { - WhatsNewPopup whatsNewPopup = PopupManager.GetWhatsNewPopup(); - BasicPopupLegacy original = PopupManager.instance.popupPrefabs[BASIC_POPUP_LEGACY_ID].Cast(); - BasicPopupLegacy basicPopupLegacy = UnityEngine.Object.Instantiate(original, PopupManager.instance.transform); - basicPopupLegacy.buttonContainer = GameObject.Instantiate(whatsNewPopup.buttonContainer, basicPopupLegacy.transform); - basicPopupLegacy.popupId = "basicPopupLegacy"; - basicPopupLegacy.popupManager = PopupManager.instance; - basicPopupLegacy.Init(); - basicPopupLegacy.identifier = null; - basicPopupLegacy.rectTransform.SetAsLastSibling(); - 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; - } - - Sprite? outlineSprite = Registry.GetSprite($"{name}_outline", style) ?? Registry.GetSprite($"{visualPart.visualPart.name}_outline", style); - - 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; - } - } - - /// 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); - } - 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 - ); - } + /// 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(); + + 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; + + /// 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 readonly Color WEEKLY_CHALLENGE_DISABLED_COLOR = new(0.42f, 0.42f, 0.42f, 1f); + + private static Dictionary? cachedSpriteDataReverse = null; + private static Dictionary cachedReversedNames = new(); + + #region General + + /// A placeholder patch for the TechItem.SetupComplete method. + [HarmonyPostfix] + [HarmonyPatch(typeof(TechItem), nameof(TechItem.SetupComplete))] + private static void TechItem_SetupComplete() + { + } + + /// 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()) + { + string result = SpriteData.TerrainToString(terrainType); + if (terrainType == Polytopia.Data.TerrainData.Type.Wetland) + { + cachedSpriteDataReverse.TryAdd(result, EnumCache.GetName(Polytopia.Data.TerrainData.Type.Field) + "_flooded"); + continue; + } + + if (result == SpriteData.TILE_UNKNOWN) + continue; + + cachedSpriteDataReverse.TryAdd(result, EnumCache.GetName(terrainType)); + } + + foreach (ResourceData.Type resourceType in Enum.GetValues()) + { + string result = SpriteData.ResourceToString(resourceType); + if (result == SpriteData.IMPROVEMENT_PLACEHOLDER) + continue; + + cachedSpriteDataReverse.TryAdd(result, EnumCache.GetName(resourceType)); + } + + foreach (ImprovementData.Type improvementType in Enum.GetValues()) + { + string result = SpriteData.ImprovementToString(improvementType); + if (result == SpriteData.IMPROVEMENT_PLACEHOLDER) + continue; + + cachedSpriteDataReverse.TryAdd(result, EnumCache.GetName(improvementType)); + } + } + internal static string FormatSpriteName(string baseName) + { + if (cachedSpriteDataReverse == null) + CacheSpriteNames(); + + if (cachedReversedNames.TryGetValue(baseName, out string? cachedReversedName)) + return cachedReversedName; + + 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); + } + + #endregion + #region Level + + [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] + [HarmonyPatch(typeof(TileData), nameof(TileData.Flood))] + private static void TileData_Flood(TileData __instance, PlayerState playerState) + { + if (customFloodingSkins.ContainsValue(playerState.skinType)) + { + TileData.EffectType effectType = customFloodingSkins.FirstOrDefault(x => x.Value == playerState.skinType).Key; + __instance.AddEffect(effectType); + } + } + + /// Removes custom flood effect loc from tile tip header. + [HarmonyPostfix] + [HarmonyPatch(typeof(TileTipGenerator), nameof(TileTipGenerator.GetTileTipHeader))] + public static void GetTileTipHeader(ref string __result, TileData tileData, SkinType skin, + PlayerState playerState, GameState gameState, GameLogicData gameLogicData) + { + foreach (var effect in tileData.effects) + { + if (customFloodingSkins.ContainsKey(effect)) + { + string localizedEffect = Localization.GetSkinned(skin, playerState.tribe, $"tile.effect.{effect.GetName()}"); + __result = __result.Replace($", {localizedEffect}", string.Empty); + } + } + } + + /// 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; + + 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 + + /// Updates the width of a basic popup if a custom width is set. + [HarmonyPostfix] + [HarmonyPatch(typeof(PopupBase), nameof(PopupBase.RefreshHeightWhenHeightIsKnownAndDontUseCoroutines))] + private static void PopupBase_RefreshHeightWhenHeightIsKnownAndDontUseCoroutines(PopupBase __instance) + { + UpdateWidth(__instance); + } + + 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] + [HarmonyPatch(typeof(PopupBase), nameof(PopupBase.Hide))] + private static void PopupBase_Hide(PopupBase __instance) + { + basicPopupWidths.Remove(__instance.GetInstanceID()); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnShow))] + private static void StartScreen_UI2_OnShow() + { + CreateInnerOriginSprites(); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnWeeklyChallengeClicked))] + private static bool StartScreen_OnWeeklyChallengeClicked() + { + BasicPopup popup = PopupManager.GetBasicPopup(); + popup.Header = Localization.Get("polymod.hub"); + popup.Description = Localization.Get( + "polymod.wc.vanillaonly", + new Il2CppSystem.Object[] { Localization.Get("weeklychallenge", Array.Empty()) } + ); + popup.buttonData = new PopupBase.PopupButtonData[] { new("buttons.ok") }; + popup.Show(); + return false; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RefreshWeeklyChallengeButton))] + private static void StartScreen_RefreshWeeklyChallengeButton(StartScreen_UI2 __instance) + { + WeeklyChallengesButton weeklyChallengeButton = __instance.weeklyChallengeButton; + if (weeklyChallengeButton == null) + return; + + weeklyChallengeButton.SetColors( + WEEKLY_CHALLENGE_DISABLED_COLOR, + WEEKLY_CHALLENGE_DISABLED_COLOR, + WEEKLY_CHALLENGE_DISABLED_COLOR + ); + } private const string MIGHTYLEADER = "mightyleader"; @@ -957,14 +552,445 @@ public static Sprite BuildSpriteWithTexture(Texture2D texture, Vector2? pivot = [HarmonyPatch(typeof(PopupContentBuilder), nameof(PopupContentBuilder.GetMightyLeaderTopImage))] public static void PopupContentBuilder_GetMightyLeaderTopImage(ref Sprite __result, PlayerState player) { - Sprite? sprite = Registry.GetSprite(MIGHTYLEADER, Util.GetStyle(player.tribe, player.GetVisualSkin())); + Sprite? sprite = Registry.GetSprite(MIGHTYLEADER, player.tribe, player.GetVisualSkin()); if(sprite != null) __result = sprite; } - /// Initializes the Visual manager by patching the necessary methods. - internal static void Init() - { - Harmony.CreateAndPatchAll(typeof(Visual)); - } + /// 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(); + } + + /// 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 + + internal static BasicPopupLegacy GetBasicPopupLegacy() + { + WhatsNewPopup whatsNewPopup = PopupManager.GetWhatsNewPopup(); + BasicPopupLegacy original = PopupManager.instance.popupPrefabs[BASIC_POPUP_LEGACY_ID].Cast(); + BasicPopupLegacy basicPopupLegacy = UnityEngine.Object.Instantiate(original, PopupManager.instance.transform); + basicPopupLegacy.buttonContainer = GameObject.Instantiate(whatsNewPopup.buttonContainer, basicPopupLegacy.transform); + basicPopupLegacy.popupId = "basicPopupLegacy"; + basicPopupLegacy.popupManager = PopupManager.instance; + basicPopupLegacy.Init(); + basicPopupLegacy.identifier = null; + basicPopupLegacy.rectTransform.SetAsLastSibling(); + return basicPopupLegacy; + } + + 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; + + int centerX = pixelX + borderSizeInPixels; + int centerY = pixelY + borderSizeInPixels; + + for (int offsetY = -borderSizeInPixels; offsetY <= borderSizeInPixels; offsetY++) + { + for (int offsetX = -borderSizeInPixels; offsetX <= borderSizeInPixels; offsetX++) + { + if (offsetX * offsetX + offsetY * offsetY > borderSquared) continue; + + filledMask[(centerY + offsetY) * outputWidthInPixels + (centerX + offsetX)] = true; + } + } + } + } + + 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/Mod.cs b/src/Mod.cs index 5c5de5b..684d035 100644 --- a/src/Mod.cs +++ b/src/Mod.cs @@ -5,114 +5,119 @@ namespace PolyMod; /// public class Mod { - /// - /// Represents a dependency of a mod. - /// - /// The unique identifier of the dependency. - /// The minimum compatible version of the dependency, or null for no minimum. - /// The maximum compatible version of the dependency, or null for no maximum. - /// Whether the dependency is required for the mod to function. - public record Dependency(string id, Version? min, Version? max, bool required = true); + /// + /// Represents a dependency of a mod. + /// + /// The unique identifier of the dependency. + /// The minimum compatible version of the dependency, or null for no minimum. + /// The maximum compatible version of the dependency, or null for no maximum. + /// Whether the dependency is required for the mod to function. + public record Dependency(string id, Version? min, Version? max, bool required = true); - /// - /// Represents the manifest of a mod, containing metadata. - /// - /// The unique identifier of the mod. - /// The name of the mod. - /// A description of the mod. - /// The version of the mod. - /// The authors of the mod. - /// The dependencies of the mod. - /// Whether the mod is client-side only. - public record Manifest(string id, string? name, string? description, Version version, string[] authors, Dependency[]? dependencies, bool client = false); + /// + /// Represents the manifest of a mod, containing metadata. + /// + /// The unique identifier of the mod. + /// The name of the mod. + /// A description of the mod. + /// The version of the mod. + /// The authors of the mod. + /// The dependencies of the mod. + /// Whether the mod is client-side only. + public record Manifest(string id, string? name, string? description, Version version, string[] authors, Dependency[]? dependencies, bool client = false); - /// - /// Represents a file included in a mod. - /// - /// The name of the file. - /// The content of the file as a byte array. - public record File(string name, byte[] bytes); + /// + /// Represents a file included in a mod. + /// + /// The name of the file. + /// The content of the file as a byte array. + public record File(string name, byte[] bytes); - /// - /// Represents the loading status of a mod. - /// - public enum Status - { - /// - /// The mod was loaded successfully. - /// - Success, - /// - /// An error occurred while loading the mod. - /// - Error, - /// - /// The mod's dependencies were not satisfied. - /// - DependenciesUnsatisfied, - } + /// + /// Represents the loading status of a mod. + /// + public enum Status + { + /// + /// The mod was loaded successfully. + /// + Success, + /// + /// An error occurred while loading the mod. + /// + Error, + /// + /// The mod's dependencies were not satisfied. + /// + DependenciesUnsatisfied, + } - /// - /// The unique identifier of the mod. - /// - public string id; + /// + /// The unique identifier of the mod. + /// + public string id; - /// - /// The name of the mod. - /// - public string? name; + /// + /// The name of the mod. + /// + public string? name; - /// - /// A description of the mod. - /// - public string? description; + /// + /// A description of the mod. + /// + public string? description; - /// - /// The version of the mod. - /// - public Version version; + /// + /// The version of the mod. + /// + public Version version; - /// - /// The authors of the mod. - /// - public string[] authors; + /// + /// The authors of the mod. + /// + public string[] authors; - /// - /// The dependencies of the mod. - /// - public Dependency[]? dependencies; + /// + /// The dependencies of the mod. + /// + public Dependency[]? dependencies; - /// - /// Whether the mod is client-side only. - /// - public bool client; + /// + /// Whether the mod is client-side only. + /// + public bool client; - /// - /// The loading status of the mod. - /// - public Status status; + /// + /// The loading status of the mod. + /// + public Status status; - /// - /// The files included in the mod. - /// - public List files; + /// + /// The files included in the mod. + /// + public List files; - /// - /// Initializes a new instance of the class. - /// - /// The manifest of the mod. - /// The loading status of the mod. - /// The files included in the mod. - public Mod(Manifest manifest, Status status, List files) - { - id = manifest.id; - name = manifest.name ?? manifest.id; - description = manifest.description; - version = manifest.version; - authors = manifest.authors; - dependencies = manifest.dependencies; - client = manifest.client; - this.status = status; - this.files = files; - } + /// + /// The checksum of the mod, derived from its id, version and file contents. + /// + public string checksum = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// The manifest of the mod. + /// The loading status of the mod. + /// The files included in the mod. + public Mod(Manifest manifest, Status status, List files) + { + id = manifest.id; + name = manifest.name ?? manifest.id; + description = manifest.description; + version = manifest.version; + authors = manifest.authors; + dependencies = manifest.dependencies; + client = manifest.client; + this.status = status; + this.files = files; + } } diff --git a/src/NullableFix.cs b/src/NullableFix.cs index adc6cdf..6048dd2 100644 --- a/src/NullableFix.cs +++ b/src/NullableFix.cs @@ -1,49 +1,49 @@ #nullable disable - +// verify whether it is needed at all bruv, i tested one time quickly and it easily compiled namespace System.Runtime.CompilerServices { - /// - /// A polyfill for the Nullable attribute, which is used by the compiler for nullable reference type analysis. - /// - [System.AttributeUsage(System.AttributeTargets.All, AllowMultiple = true)] - public sealed class NullableAttribute : System.Attribute - { - /// - /// Initializes a new instance of the class. - /// - /// The nullable flags. - public NullableAttribute(byte b) { } + /// + /// A polyfill for the Nullable attribute, which is used by the compiler for nullable reference type analysis. + /// + [System.AttributeUsage(System.AttributeTargets.All, AllowMultiple = true)] + public sealed class NullableAttribute : System.Attribute + { + /// + /// Initializes a new instance of the class. + /// + /// The nullable flags. + public NullableAttribute(byte b) { } - /// - /// Initializes a new instance of the class. - /// - /// The nullable flags. - public NullableAttribute(byte[] b) { } - } + /// + /// Initializes a new instance of the class. + /// + /// The nullable flags. + public NullableAttribute(byte[] b) { } + } - /// - /// A polyfill for the NullableContext attribute, which is used by the compiler for nullable reference type analysis. - /// - [System.AttributeUsage(System.AttributeTargets.All, AllowMultiple = false)] - public sealed class NullableContextAttribute : System.Attribute - { - /// - /// Initializes a new instance of the class. - /// - /// The nullable context flags. - public NullableContextAttribute(byte b) { } - } + /// + /// A polyfill for the NullableContext attribute, which is used by the compiler for nullable reference type analysis. + /// + [System.AttributeUsage(System.AttributeTargets.All, AllowMultiple = false)] + public sealed class NullableContextAttribute : System.Attribute + { + /// + /// Initializes a new instance of the class. + /// + /// The nullable context flags. + public NullableContextAttribute(byte b) { } + } - /// - /// A polyfill for the NullablePublicOnly attribute, which is used by the compiler for nullable reference type analysis. - /// - [System.AttributeUsage(System.AttributeTargets.Module)] - public sealed class NullablePublicOnlyAttribute : System.Attribute - { - /// - /// Initializes a new instance of the class. - /// - /// Whether to apply nullable analysis to public members only. - public NullablePublicOnlyAttribute(bool b) { } - } + /// + /// A polyfill for the NullablePublicOnly attribute, which is used by the compiler for nullable reference type analysis. + /// + [System.AttributeUsage(System.AttributeTargets.Module)] + public sealed class NullablePublicOnlyAttribute : System.Attribute + { + /// + /// Initializes a new instance of the class. + /// + /// Whether to apply nullable analysis to public members only. + public NullablePublicOnlyAttribute(bool b) { } + } } diff --git a/src/Plugin.cs b/src/Plugin.cs index c273eb4..74f7813 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,158 +14,214 @@ 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 - ); - - /// - /// The starting index for automatically assigned IDs. - /// - internal const int AUTOIDX_STARTS_FROM = 1000; - - /// - /// The key used to store the last version for which the incompatibility warning was shown. - /// - 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, ".."); - - /// - /// The path to the mods directory. - /// - public static readonly string MODS_PATH = Path.Combine(BASE_PATH, "Mods"); - - /// - /// The path to the directory where game data is dumped. - /// - public static readonly string DUMPED_DATA_PATH = Path.Combine(BASE_PATH, "DumpedData"); - - /// - /// The path to the directory where localization is dumped. - /// - public static readonly string LOGIC_DUMP_PATH = Path.Combine(DUMPED_DATA_PATH, "Logic"); - - /// - /// The path to the directory where gameLogicData and avatarData are dumped. - /// - 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 checksum file. - /// - internal static readonly string CHECKSUM_PATH - = Path.Combine(BASE_PATH, "CHECKSUM"); - - /// - /// The link to the PolyMod Discord server. - /// - internal static readonly string DISCORD_LINK = "https://discord.gg/eWPdhWtfVy"; - - /// - /// A list of log messages to ignore. - /// - internal static readonly List LOG_MESSAGES_IGNORE = new() - { - "Failed to find atlas", - "Could not find sprite", - "Couldn't find prefab for type", - "MARKET: id:", - "Missing name for value", - }; + 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. + /// + internal const int AUTOIDX_STARTS_FROM = 1000; + + /// + /// The key used to store the last version for which the incompatibility warning was shown. + /// + internal const string INCOMPATIBILITY_WARNING_LAST_VERSION_KEY + = "INCOMPATIBILITY_WARNING_LAST_VERSION"; + + /// + /// The prefix of the player prefs keys holding the mod checksum a saved game was created with. + /// + internal const string STATE_CHECKSUM_KEY_PREFIX = "POLYMOD_STATE_CHECKSUM_"; + + 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. + /// + public static readonly string MODS_PATH = Path.Combine(BASE_PATH, "Mods"); + + /// + /// The path to the directory where game data is dumped. + /// + public static readonly string DUMPED_DATA_PATH = Path.Combine(BASE_PATH, "DumpedData"); + + /// + /// The path to the directory where localization is dumped. + /// + public static readonly string LOGIC_DUMP_PATH = Path.Combine(DUMPED_DATA_PATH, "Logic"); + + /// + /// The path to the directory where gameLogicData and avatarData are dumped. + /// + public static readonly string LOCALIZATION_DUMP_PATH = Path.Combine(DUMPED_DATA_PATH, "Localization"); + + /// + /// 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. + /// + internal static readonly string CHECKSUM_PATH + = Path.Combine(BASE_PATH, "CHECKSUM"); + + internal static readonly string PERSISTENT_DATA_PATH = Path.Combine(BASE_PATH, "PersistentData"); + + /// + /// The link to the PolyMod Discord server. + /// + internal static readonly string DISCORD_LINK = "https://discord.gg/eWPdhWtfVy"; + + /// + /// A list of log messages to ignore. + /// + internal static readonly List LOG_MESSAGES_IGNORE = new() + { + "Failed to find atlas", + "Could not find sprite", + "Couldn't find prefab for type", + "MARKET: id:", + "Missing name for value", + }; #pragma warning disable CS8618 - /// - /// The PolyMod configuration. - /// - internal static PolyConfig config; - - /// - /// The logger instance for PolyMod. - /// - internal static ManualLogSource logger; + /// + /// The PolyMod configuration. + /// + internal static PolyConfig config; + + /// + /// The logger instance for PolyMod. + /// + internal static ManualLogSource logger; #pragma warning restore CS8618 - /// - /// The entry point for the plugin. - /// - public override void Load() - { - try - { - config = JsonSerializer.Deserialize(File.ReadAllText(CONFIG_PATH))!; - } - catch - { - config = new(); - } - WriteConfig(); - UpdateConsole(); - logger = Log; - ConfigFile.CoreConfig[new("Logging.Disk", "WriteUnityLog")].BoxedValue = true; - - AutoUpdate.Init(); - - Compatibility.Init(); - - Audio.Init(); - Loc.Init(); - Visual.Init(); - Hub.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}" - )!; - } - - /// - /// Writes the configuration to disk. - /// - 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(); - } - } + /// + /// The entry point for the plugin. + /// + public override void Load() + { + try + { + config = JsonSerializer.Deserialize(File.ReadAllText(CONFIG_PATH))!; + } + catch + { + config = new(); + } + WriteConfig(); + UpdateConsole(); + logger = Log; + ConfigFile.CoreConfig[new("Logging.Disk", "WriteUnityLog")].BoxedValue = true; + + CaptureLaunchData(); + + if (Disabled) + return; + + Compatibility.Init(); + + 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. + /// + 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 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 dd635e6..bfd2886 100644 --- a/src/PolyMod.csproj +++ b/src/PolyMod.csproj @@ -5,7 +5,7 @@ enable IL2CPP PolyMod - 1.2.19 + 2.0.0-pre 2.18.0.16751 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..a0eee86 --- /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; +} diff --git a/src/Prefabs/PrefabsInfo.cs b/src/Prefabs/PrefabsInfo.cs new file mode 100644 index 0000000..5da59c4 --- /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(); +} diff --git a/src/Prefabs/SkinnedKey.cs b/src/Prefabs/SkinnedKey.cs new file mode 100644 index 0000000..53cfa52 --- /dev/null +++ b/src/Prefabs/SkinnedKey.cs @@ -0,0 +1,56 @@ +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? obj) + { + return obj is SkinnedKey otherKey && Equals(otherKey); + } + + public override int GetHashCode() + { + return HashCode.Combine((int)unitType, (int)tribeType, (int)skinType); + } + + public static bool operator ==(SkinnedKey left, SkinnedKey right) + { + return left.Equals(right); + } + + public static bool operator !=(SkinnedKey left, SkinnedKey right) + { + return !left.Equals(right); + } +} diff --git a/src/Prefabs/VisualPartInfo.cs b/src/Prefabs/VisualPartInfo.cs new file mode 100644 index 0000000..329b3ef --- /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; + } +} diff --git a/src/Registry.cs b/src/Registry.cs index 4468130..8944632 100644 --- a/src/Registry.cs +++ b/src/Registry.cs @@ -1,3 +1,4 @@ +using System.Globalization; using LibCpp2IL; using PolyMod.Managers; using PolytopiaBackendBase.Game; @@ -11,115 +12,136 @@ namespace PolyMod; /// public static class Registry { - /// - /// The next available index for automatically assigned IDs. - /// - public static int autoidx = Plugin.AUTOIDX_STARTS_FROM; - - /// - /// A dictionary of all loaded sprites, keyed by their name. - /// - public static Dictionary sprites = new(); - - /// - /// 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 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 list of custom tribes. - /// - public static List customTribes = new(); - - /// - /// A list of custom skin information. - /// - public static List skinInfo = new(); - - /// - /// The next available index for climate styles. - /// - public static int climateAutoidx = (int)Enum.GetValues(typeof(TribeType)).Cast().Last(); - - /// - /// The next available index for game modes. - /// - 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; - } + /// + /// The next available index for automatically assigned IDs. + /// + public static int autoidx = Plugin.AUTOIDX_STARTS_FROM; + + /// + /// A dictionary of all loaded sprites, keyed by their name. + /// + public static Dictionary sprites = 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 sprite information, keyed by sprite name. + /// + public static Dictionary spriteInfos = new(); + + /// + /// A dictionary of loaded asset bundles, keyed by their name. + /// + public static Dictionary assetBundles = new(); + + /// + /// A list of custom tribes. + /// + public static List customTribes = new(); + + /// + /// A list of custom skin information. + /// + public static List skinInfo = new(); + + /// + /// The next available index for climate styles. + /// + public static int climateAutoidx = (int)Enum.GetValues().Last(); + + /// + /// The next available index for game modes. + /// + public static int gameModesAutoidx = Enum.GetValues().Length; + + 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(CultureInfo.InvariantCulture); + 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.ToLowerInvariant(); + tribeText = tribeText.ToLowerInvariant(); + skinText = skinText.ToLowerInvariant(); + + 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 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.ToLowerInvariant(); + tribeText = tribeText.ToLowerInvariant(); + skinText = skinText.ToLowerInvariant(); + + 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..cae4707 100644 --- a/src/Util.cs +++ b/src/Util.cs @@ -1,10 +1,9 @@ +using System.Globalization; using System.Security.Cryptography; using System.Text; using Il2CppInterop.Runtime; using Il2CppInterop.Runtime.Injection; using Newtonsoft.Json.Linq; -using Polytopia.Data; -using PolytopiaBackendBase.Common; namespace PolyMod; @@ -13,201 +12,141 @@ namespace PolyMod; /// internal static class Util { - /// - /// Wraps a managed type for use in the Il2Cpp runtime. - /// - /// The type to wrap. - /// The Il2Cpp representation of the type. - internal static Il2CppSystem.Type WrapType() where T : class - { - if (!ClassInjector.IsTypeRegisteredInIl2Cpp()) - ClassInjector.RegisterTypeInIl2Cpp(); - return Il2CppType.From(typeof(T)); - } - - /// - /// Computes the SHA256 hash of an object's string representation. - /// - /// The object to hash. - /// The hexadecimal string representation of the hash. - internal static string Hash(object data) - { - return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(data.ToString()!))); - } - - /// - /// Gets the name of a JToken from its path. - /// - /// The JToken. - /// The part of the path to retrieve, from the end. - /// The name of the JToken. - internal static string GetJTokenName(JToken token, int n = 1) - { - return token.Path.Split('.')[^n]; - } - - /// - /// Converts an Il2CppSystem.Version to a System.Version. - /// - /// The Il2CppSystem.Version to convert. - /// The equivalent System.Version. - internal static Version Cast(this Il2CppSystem.Version self) - { - return new(self.ToString()); - } - - /// - /// Synchronously unwraps the result of a Task. - /// - /// The result type of the Task. - /// The Task to unwrap. - /// The result of the Task. - internal static T UnwrapAsync(this Task self) - { - return self.GetAwaiter().GetResult(); - } - - /// - /// Removes the revision component from a Version. - /// - /// The Version to modify. - /// A new Version with the revision set to -1. - internal static Version CutRevision(this Version self) - { - return new(self.Major, self.Minor, self.Build); - } - - /// - /// Compares two version strings to see if the first is older or equal to the second. - /// Pre-release tags follow semantic-version rules: the part after the first dash is - /// split on dots, purely numeric identifiers compare numerically and sort before - /// alphanumeric ones, and a longer identifier list is newer. A version with a - /// pre-release tag is always older than the same version without one. - /// - /// The first version string. - /// The second version string. - /// True if version1 is older or equal to version2, false otherwise. - internal static bool IsVersionOlderOrEqual(this string version1, string version2) - { - Version core1 = ParseVersion(version1, out string? pre1); - Version core2 = ParseVersion(version2, out string? pre2); - - if (core1 != core2) - return core1 < core2; - - return ComparePreRelease(pre1, pre2) <= 0; - } - - private static Version ParseVersion(string version, out string? preRelease) - { - int dash = version.IndexOf('-'); - if (dash < 0) - { - preRelease = null; - return new Version(version); - } - preRelease = version[(dash + 1)..]; - if (preRelease.Length == 0) - preRelease = null; - return new Version(version[..dash]); - } - - private static int ComparePreRelease(string? left, string? right) - { - if (left == null && right == null) return 0; - if (left == null) return 1; - if (right == null) return -1; - - string[] leftIds = left.Split('.'); - string[] rightIds = right.Split('.'); - int shared = Math.Min(leftIds.Length, rightIds.Length); - for (int i = 0; i < shared; i++) - { - int comparison = CompareIdentifier(leftIds[i], rightIds[i]); - if (comparison != 0) - return comparison; - } - return leftIds.Length.CompareTo(rightIds.Length); - } - - private static int CompareIdentifier(string left, string right) - { - bool leftNumeric = left.Length > 0 && left.All(char.IsDigit); - bool rightNumeric = right.Length > 0 && right.All(char.IsDigit); - if (leftNumeric && rightNumeric) - return long.Parse(left).CompareTo(long.Parse(right)); - if (leftNumeric) return -1; - 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; - } + /// + /// Wraps a managed type for use in the Il2Cpp runtime. + /// + /// The type to wrap. + /// The Il2Cpp representation of the type. + internal static Il2CppSystem.Type WrapType() where T : class + { + if (!ClassInjector.IsTypeRegisteredInIl2Cpp()) + ClassInjector.RegisterTypeInIl2Cpp(); + return Il2CppType.From(typeof(T)); + } + + /// + /// Computes the SHA256 hash of an object's string representation. + /// + /// The object to hash. + /// The hexadecimal string representation of the hash. + internal static string Hash(object data) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(data.ToString()!))); + } + + /// + /// Computes the SHA256 hash of raw bytes. + /// + /// The bytes to hash. + /// The hexadecimal string representation of the hash. + internal static string HashBytes(byte[] data) + { + return Convert.ToHexString(SHA256.HashData(data)); + } + + /// + /// Gets the name of a JToken from its path. + /// + /// The JToken. + /// The part of the path to retrieve, from the end. + /// The name of the JToken. + internal static string GetJTokenName(JToken token, int n = 1) + { + return token.Path.Split('.')[^n]; + } + + /// + /// Converts an Il2CppSystem.Version to a System.Version. + /// + /// The Il2CppSystem.Version to convert. + /// The equivalent System.Version. + internal static Version Cast(this Il2CppSystem.Version self) + { + return new(self.ToString()); + } + + /// + /// Synchronously unwraps the result of a Task. + /// + /// The result type of the Task. + /// The Task to unwrap. + /// The result of the Task. + internal static T UnwrapAsync(this Task self) + { + return self.GetAwaiter().GetResult(); + } + + /// + /// Removes the revision component from a Version. + /// + /// The Version to modify. + /// A new Version with the revision set to -1. + internal static Version CutRevision(this Version self) + { + return new(self.Major, self.Minor, self.Build); + } + + /// + /// Compares two version strings to see if the first is older or equal to the second. + /// Pre-release tags follow semantic-version rules: the part after the first dash is + /// split on dots, purely numeric identifiers compare numerically and sort before + /// alphanumeric ones, and a longer identifier list is newer. A version with a + /// pre-release tag is always older than the same version without one. + /// + /// The first version string. + /// The second version string. + /// True if version1 is older or equal to version2, false otherwise. + internal static bool IsVersionOlderOrEqual(this string version1, string version2) + { + Version core1 = ParseVersion(version1, out string? pre1); + Version core2 = ParseVersion(version2, out string? pre2); + + if (core1 != core2) + return core1 < core2; + + return ComparePreRelease(pre1, pre2) <= 0; + } + + private static Version ParseVersion(string version, out string? preRelease) + { + int dash = version.IndexOf('-'); + if (dash < 0) + { + preRelease = null; + return new Version(version); + } + preRelease = version[(dash + 1)..]; + if (preRelease.Length == 0) + preRelease = null; + return new Version(version[..dash]); + } + + private static int ComparePreRelease(string? left, string? right) + { + if (left == null && right == null) return 0; + if (left == null) return 1; + if (right == null) return -1; + + string[] leftIds = left.Split('.'); + string[] rightIds = right.Split('.'); + int shared = Math.Min(leftIds.Length, rightIds.Length); + for (int i = 0; i < shared; i++) + { + int comparison = CompareIdentifier(leftIds[i], rightIds[i]); + if (comparison != 0) + return comparison; + } + return leftIds.Length.CompareTo(rightIds.Length); + } + + private static int CompareIdentifier(string left, string right) + { + bool leftNumeric = left.Length > 0 && left.All(char.IsDigit); + bool rightNumeric = right.Length > 0 && right.All(char.IsDigit); + if (leftNumeric && rightNumeric) + return long.Parse(left, CultureInfo.InvariantCulture).CompareTo(long.Parse(right, CultureInfo.InvariantCulture)); + if (leftNumeric) return -1; + if (rightNumeric) return 1; + return string.Compare(left, right, StringComparison.Ordinal); + } } 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], + }); + } +} diff --git a/tests/CheckDependenciesTests.cs b/tests/CheckDependenciesTests.cs index b297606..95276d0 100644 --- a/tests/CheckDependenciesTests.cs +++ b/tests/CheckDependenciesTests.cs @@ -4,56 +4,56 @@ namespace Tests; public class CheckDependenciesTests { - [Fact] - public void MissingRequiredDependencyMarksModUnsatisfied() - { - var mod = ModFactory.MakeMod("b", dependencies: new Mod.Dependency("ghost", null, null)); - Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a"), mod)); - Assert.Equal(Mod.Status.DependenciesUnsatisfied, mod.status); - } + [Fact] + public void MissingRequiredDependencyMarksModUnsatisfied() + { + var mod = ModFactory.MakeMod("b", dependencies: new Mod.Dependency("ghost", null, null)); + Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a"), mod)); + Assert.Equal(Mod.Status.DependenciesUnsatisfied, mod.status); + } - [Fact] - public void VersionBelowMinimumMarksModUnsatisfied() - { - var dep = new Mod.Dependency("a", min: new Version(2, 0, 0), max: null); - var mod = ModFactory.MakeMod("b", dependencies: dep); - Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a"), mod)); - Assert.Equal(Mod.Status.DependenciesUnsatisfied, mod.status); - } + [Fact] + public void VersionBelowMinimumMarksModUnsatisfied() + { + var dep = new Mod.Dependency("a", min: new Version(2, 0, 0), max: null); + var mod = ModFactory.MakeMod("b", dependencies: dep); + Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a"), mod)); + Assert.Equal(Mod.Status.DependenciesUnsatisfied, mod.status); + } - [Fact] - public void VersionAboveMaximumMarksModUnsatisfied() - { - var dep = new Mod.Dependency("a", min: null, max: new Version(1, 0, 0)); - var mod = ModFactory.MakeMod("b", dependencies: dep); - Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a", version: new Version(2, 0, 0)), mod)); - Assert.Equal(Mod.Status.DependenciesUnsatisfied, mod.status); - } + [Fact] + public void VersionAboveMaximumMarksModUnsatisfied() + { + var dep = new Mod.Dependency("a", min: null, max: new Version(1, 0, 0)); + var mod = ModFactory.MakeMod("b", dependencies: dep); + Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a", version: new Version(2, 0, 0)), mod)); + Assert.Equal(Mod.Status.DependenciesUnsatisfied, mod.status); + } - [Fact] - public void VersionEqualToBoundsIsAllowed() - { - var dep = new Mod.Dependency("a", min: new Version(2, 0, 0), max: new Version(2, 0, 0)); - var mod = ModFactory.MakeMod("b", dependencies: dep); - Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a", version: new Version(2, 0, 0)), mod)); - Assert.Equal(Mod.Status.Success, mod.status); - } + [Fact] + public void VersionEqualToBoundsIsAllowed() + { + var dep = new Mod.Dependency("a", min: new Version(2, 0, 0), max: new Version(2, 0, 0)); + var mod = ModFactory.MakeMod("b", dependencies: dep); + Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a", version: new Version(2, 0, 0)), mod)); + Assert.Equal(Mod.Status.Success, mod.status); + } - [Fact] - public void SatisfiedDependencyKeepsModSuccessful() - { - var dep = new Mod.Dependency("a", min: new Version(1, 0, 0), max: new Version(2, 0, 0)); - var mod = ModFactory.MakeMod("b", dependencies: dep); - Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a"), mod)); - Assert.Equal(Mod.Status.Success, mod.status); - } + [Fact] + public void SatisfiedDependencyKeepsModSuccessful() + { + var dep = new Mod.Dependency("a", min: new Version(1, 0, 0), max: new Version(2, 0, 0)); + var mod = ModFactory.MakeMod("b", dependencies: dep); + Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a"), mod)); + Assert.Equal(Mod.Status.Success, mod.status); + } - [Fact] - public void MissingOptionalDependencyOnlyWarns() - { - var dep = new Mod.Dependency("ghost", null, null, required: false); - var mod = ModFactory.MakeMod("b", dependencies: dep); - Loader.CheckDependencies(ModFactory.Mods(mod)); - Assert.Equal(Mod.Status.Success, mod.status); - } + [Fact] + public void MissingOptionalDependencyOnlyWarns() + { + var dep = new Mod.Dependency("ghost", null, null, required: false); + var mod = ModFactory.MakeMod("b", dependencies: dep); + Loader.CheckDependencies(ModFactory.Mods(mod)); + Assert.Equal(Mod.Status.Success, mod.status); + } } diff --git a/tests/CompatibilityTests.cs b/tests/CompatibilityTests.cs new file mode 100644 index 0000000..30ffe8e --- /dev/null +++ b/tests/CompatibilityTests.cs @@ -0,0 +1,134 @@ +using System.Text; +using PolyMod; +using PolyMod.Managers; + +namespace Tests; + +public class CompatibilityTests +{ + private static Mod MakeModWithFiles(string id, Version version, params (string Name, string Content)[] files) + { + List modFiles = new(); + foreach ((string name, string content) in files) + modFiles.Add(new Mod.File(name, Encoding.UTF8.GetBytes(content))); + + Mod.Manifest manifest = new(id, null, null, version, new[] { "author" }, null); + return new Mod(manifest, Mod.Status.Success, modFiles); + } + + // The launcher computes this same value from the mod's .polymod archive. If this literal has to + // change, PolyModLauncher's ModChecksum must change with it or multiplayer checks will disagree. + [Fact] + public void ModChecksumMatchesTheLauncher() + { + Mod mod = MakeModWithFiles( + "testmod", + new Version(1, 2, 3), + ("sprites.json", "{\"a\":1}"), + ("nested/thing.txt", "hello world"), + ("code.dll", "not really a dll")); + + Assert.Equal( + "34A44D3156F856C4A6CACB16379B529E797230E7F1DB9A32E1E3682D416405AC", + Compatibility.ComputeModChecksum(mod)); + } + + [Fact] + public void ModChecksumIgnoresFileOrder() + { + Mod ordered = MakeModWithFiles( + "testmod", + new Version(1, 0, 0), + ("a.txt", "first"), + ("b.txt", "second")); + Mod reversed = MakeModWithFiles( + "testmod", + new Version(1, 0, 0), + ("b.txt", "second"), + ("a.txt", "first")); + + Assert.Equal( + Compatibility.ComputeModChecksum(ordered), + Compatibility.ComputeModChecksum(reversed)); + } + + [Fact] + public void ModChecksumChangesWithFileContents() + { + Mod original = MakeModWithFiles("testmod", new Version(1, 0, 0), ("a.txt", "first")); + Mod edited = MakeModWithFiles("testmod", new Version(1, 0, 0), ("a.txt", "firsT")); + + Assert.NotEqual( + Compatibility.ComputeModChecksum(original), + Compatibility.ComputeModChecksum(edited)); + } + + [Fact] + public void ModChecksumChangesWithVersion() + { + Mod original = MakeModWithFiles("testmod", new Version(1, 0, 0), ("a.txt", "first")); + Mod bumped = MakeModWithFiles("testmod", new Version(1, 0, 1), ("a.txt", "first")); + + Assert.NotEqual( + Compatibility.ComputeModChecksum(original), + Compatibility.ComputeModChecksum(bumped)); + } + + [Fact] + public void GlobalChecksumIgnoresClientOnlyMods() + { + Mod shared = MakeModWithFiles("sharedmod", new Version(1, 0, 0), ("a.txt", "first")); + + Dictionary withoutClient = new() { { shared.id, shared } }; + Compatibility.ComputeChecksums(withoutClient); + string before = Compatibility.checksum; + + Mod clientOnly = MakeModWithFiles("clientmod", new Version(1, 0, 0), ("b.txt", "second")); + clientOnly.client = true; + + Dictionary withClient = new() + { + { shared.id, shared }, + { clientOnly.id, clientOnly }, + }; + Compatibility.ComputeChecksums(withClient); + + Assert.Equal(before, Compatibility.checksum); + } + + // The game deletes saves through APIs that hand the id over as a Guid in some places and as a + // string in others. If those produced different keys the stored checksum would never be purged. + [Fact] + public void GameIdNormalizesAcrossTheFormsTheGameUses() + { + Guid gameId = Guid.Parse("3F2504E0-4F89-11D3-9A0C-0305E82C3301"); + string expected = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; + + Assert.Equal(expected, Compatibility.NormalizeGameId(gameId)); + Assert.Equal(expected, Compatibility.NormalizeGameId(gameId.ToString())); + Assert.Equal(expected, Compatibility.NormalizeGameId(gameId.ToString().ToUpperInvariant())); + Assert.Equal(expected, Compatibility.NormalizeGameId(gameId.ToString("B"))); + } + + [Fact] + public void GlobalChecksumIgnoresModOrder() + { + Mod first = MakeModWithFiles("alpha", new Version(1, 0, 0), ("a.txt", "first")); + Mod second = MakeModWithFiles("beta", new Version(1, 0, 0), ("b.txt", "second")); + + Compatibility.ComputeChecksums(new Dictionary + { + { first.id, first }, + { second.id, second }, + }); + string ordered = Compatibility.checksum; + + Compatibility.ComputeChecksums(new Dictionary + { + { second.id, second }, + { first.id, first }, + }); + + Assert.Equal(ordered, Compatibility.checksum); + } +} diff --git a/tests/LoaderTests.cs b/tests/LoaderTests.cs index f7aae24..e056664 100644 --- a/tests/LoaderTests.cs +++ b/tests/LoaderTests.cs @@ -4,57 +4,57 @@ namespace Tests; public class LoaderTests { - private static string[] Ids(Dictionary mods) => - mods.Select(kv => kv.Key).ToArray(); + private static string[] Ids(Dictionary mods) => + mods.Select(kv => kv.Key).ToArray(); - [Fact] - public void DependencyLoadsBeforeDependent() - { - var mods = ModFactory.Mods( - ModFactory.MakeMod("b", dependencies: new Mod.Dependency("a", null, null)), - ModFactory.MakeMod("a")); - Assert.True(Loader.SortMods(mods)); - Assert.Equal(new[] { "a", "b" }, Ids(mods)); - } + [Fact] + public void DependencyLoadsBeforeDependent() + { + var mods = ModFactory.Mods( + ModFactory.MakeMod("b", dependencies: new Mod.Dependency("a", null, null)), + ModFactory.MakeMod("a")); + Assert.True(Loader.SortMods(mods)); + Assert.Equal(new[] { "a", "b" }, Ids(mods)); + } - [Fact] - public void TransitiveChainSortsDeepestFirst() - { - var mods = ModFactory.Mods( - ModFactory.MakeMod("c", dependencies: new Mod.Dependency("b", null, null)), - ModFactory.MakeMod("b", dependencies: new Mod.Dependency("a", null, null)), - ModFactory.MakeMod("a")); - Assert.True(Loader.SortMods(mods)); - Assert.Equal(new[] { "a", "b", "c" }, Ids(mods)); - } + [Fact] + public void TransitiveChainSortsDeepestFirst() + { + var mods = ModFactory.Mods( + ModFactory.MakeMod("c", dependencies: new Mod.Dependency("b", null, null)), + ModFactory.MakeMod("b", dependencies: new Mod.Dependency("a", null, null)), + ModFactory.MakeMod("a")); + Assert.True(Loader.SortMods(mods)); + Assert.Equal(new[] { "a", "b", "c" }, Ids(mods)); + } - [Fact] - public void DependencyCycleReturnsFalseAndLeavesDictionaryUntouched() - { - var mods = ModFactory.Mods( - ModFactory.MakeMod("a", dependencies: new Mod.Dependency("b", null, null)), - ModFactory.MakeMod("b", dependencies: new Mod.Dependency("a", null, null))); - Assert.False(Loader.SortMods(mods)); - Assert.Equal(new[] { "a", "b" }, Ids(mods)); - } + [Fact] + public void DependencyCycleReturnsFalseAndLeavesDictionaryUntouched() + { + var mods = ModFactory.Mods( + ModFactory.MakeMod("a", dependencies: new Mod.Dependency("b", null, null)), + ModFactory.MakeMod("b", dependencies: new Mod.Dependency("a", null, null))); + Assert.False(Loader.SortMods(mods)); + Assert.Equal(new[] { "a", "b" }, Ids(mods)); + } - [Fact] - public void DependencyOnMissingModDoesNotBlockSorting() - { - var mods = ModFactory.Mods( - ModFactory.MakeMod("b", dependencies: new Mod.Dependency("ghost", null, null)), - ModFactory.MakeMod("a")); - Assert.True(Loader.SortMods(mods)); - Assert.Equal(2, mods.Count); - } + [Fact] + public void DependencyOnMissingModDoesNotBlockSorting() + { + var mods = ModFactory.Mods( + ModFactory.MakeMod("b", dependencies: new Mod.Dependency("ghost", null, null)), + ModFactory.MakeMod("a")); + Assert.True(Loader.SortMods(mods)); + Assert.Equal(2, mods.Count); + } - [Fact] - public void FailedModsSinkToEnd() - { - var mods = ModFactory.Mods( - ModFactory.MakeMod("bad", Mod.Status.Error), - ModFactory.MakeMod("good")); - Assert.True(Loader.SortMods(mods)); - Assert.Equal(new[] { "good", "bad" }, Ids(mods)); - } + [Fact] + public void FailedModsSinkToEnd() + { + var mods = ModFactory.Mods( + ModFactory.MakeMod("bad", Mod.Status.Error), + ModFactory.MakeMod("good")); + Assert.True(Loader.SortMods(mods)); + Assert.Equal(new[] { "good", "bad" }, Ids(mods)); + } } diff --git a/tests/ModFactory.cs b/tests/ModFactory.cs index 1a78280..2e2c9a1 100644 --- a/tests/ModFactory.cs +++ b/tests/ModFactory.cs @@ -4,14 +4,14 @@ namespace Tests; internal static class ModFactory { - internal static Mod MakeMod( - string id, - Mod.Status status = Mod.Status.Success, - Version? version = null, - params Mod.Dependency[] dependencies) => - new(new Mod.Manifest(id, null, null, version ?? new Version(1, 0, 0), new[] { "author" }, dependencies), - status, new()); + internal static Mod MakeMod( + string id, + Mod.Status status = Mod.Status.Success, + Version? version = null, + params Mod.Dependency[] dependencies) => + new(new Mod.Manifest(id, null, null, version ?? new Version(1, 0, 0), new[] { "author" }, dependencies), + status, new()); - internal static Dictionary Mods(params Mod[] mods) => - mods.ToDictionary(mod => mod.id); + internal static Dictionary Mods(params Mod[] mods) => + mods.ToDictionary(mod => mod.id); } diff --git a/tests/TestSetup.cs b/tests/TestSetup.cs index 87f54f1..4e8e3b2 100644 --- a/tests/TestSetup.cs +++ b/tests/TestSetup.cs @@ -7,24 +7,24 @@ namespace Tests; internal static class TestSetup { - [ModuleInitializer] - internal static void Init() - { - var root = AppContext.BaseDirectory; - var prop = typeof(BepInEx.Paths).GetProperty( - nameof(BepInEx.Paths.BepInExRootPath), - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); - try - { - prop?.SetValue(null, root); - } - catch (ArgumentException) - { - var field = typeof(BepInEx.Paths) - .GetFields(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static) - .FirstOrDefault(f => f.Name.Contains("BepInExRootPath", StringComparison.Ordinal)); - field?.SetValue(null, root); - } - Plugin.logger ??= new ManualLogSource("tests"); - } + [ModuleInitializer] + internal static void Init() + { + var root = AppContext.BaseDirectory; + var prop = typeof(BepInEx.Paths).GetProperty( + nameof(BepInEx.Paths.BepInExRootPath), + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + try + { + prop?.SetValue(null, root); + } + catch (ArgumentException) + { + var field = typeof(BepInEx.Paths) + .GetFields(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(f => f.Name.Contains("BepInExRootPath", StringComparison.Ordinal)); + field?.SetValue(null, root); + } + Plugin.logger ??= new ManualLogSource("tests"); + } } diff --git a/tests/UtilTests.cs b/tests/UtilTests.cs index 2bb4799..7b42ad1 100644 --- a/tests/UtilTests.cs +++ b/tests/UtilTests.cs @@ -4,31 +4,31 @@ namespace Tests; public class UtilTests { - [Theory] - [InlineData("1.0.0", "2.0.0", true)] // older core - [InlineData("2.0.0", "1.0.0", false)] // newer core - [InlineData("2.0.0", "2.0.0", true)] // equal - [InlineData("2.0", "2.0.0", true)] // shorter version sorts first - [InlineData("2.0.0.1", "2.0.0", false)] // revision makes it newer - [InlineData("2.0.0", "2.0.0-beta", false)] // release is newer than its pre-release - [InlineData("2.0.0-beta", "2.0.0", true)] - [InlineData("2.0.0-alpha", "2.0.0-beta", true)] // alphanumeric identifiers compare lexically - [InlineData("2.0.0-rc.9", "2.0.0-rc.10", true)] // numeric identifiers compare numerically - [InlineData("2.0.0-rc.10", "2.0.0-rc.9", false)] - [InlineData("2.0.0-9", "2.0.0-10", true)] - [InlineData("2.0.0-1", "2.0.0-alpha", true)] // numeric sorts before alphanumeric - [InlineData("2.0.0-pre", "2.0.0-pre.1", true)] // more identifiers mean a newer pre-release - [InlineData("2.0.0-pre.1", "2.0.0-pre", false)] - [InlineData("2.0.0-alpha-1", "2.0.0-alpha-2", true)] // everything after the first dash is the tag - [InlineData("2.0.0-alpha-2", "2.0.0-alpha-1", false)] - public void VersionComparisons(string version, string other, bool expected) => - Assert.Equal(expected, version.IsVersionOlderOrEqual(other)); + [Theory] + [InlineData("1.0.0", "2.0.0", true)] // older core + [InlineData("2.0.0", "1.0.0", false)] // newer core + [InlineData("2.0.0", "2.0.0", true)] // equal + [InlineData("2.0", "2.0.0", true)] // shorter version sorts first + [InlineData("2.0.0.1", "2.0.0", false)] // revision makes it newer + [InlineData("2.0.0", "2.0.0-beta", false)] // release is newer than its pre-release + [InlineData("2.0.0-beta", "2.0.0", true)] + [InlineData("2.0.0-alpha", "2.0.0-beta", true)] // alphanumeric identifiers compare lexically + [InlineData("2.0.0-rc.9", "2.0.0-rc.10", true)] // numeric identifiers compare numerically + [InlineData("2.0.0-rc.10", "2.0.0-rc.9", false)] + [InlineData("2.0.0-9", "2.0.0-10", true)] + [InlineData("2.0.0-1", "2.0.0-alpha", true)] // numeric sorts before alphanumeric + [InlineData("2.0.0-pre", "2.0.0-pre.1", true)] // more identifiers mean a newer pre-release + [InlineData("2.0.0-pre.1", "2.0.0-pre", false)] + [InlineData("2.0.0-alpha-1", "2.0.0-alpha-2", true)] // everything after the first dash is the tag + [InlineData("2.0.0-alpha-2", "2.0.0-alpha-1", false)] + public void VersionComparisons(string version, string other, bool expected) => + Assert.Equal(expected, version.IsVersionOlderOrEqual(other)); - [Fact] - public void HashProducesUppercaseSha256Hex() - { - Assert.Equal( - "9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08", - Util.Hash("test")); - } + [Fact] + public void HashProducesUppercaseSha256Hex() + { + Assert.Equal( + "9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08", + Util.Hash("test")); + } } diff --git a/tests/VersionJsonTests.cs b/tests/VersionJsonTests.cs index f85fe15..d080b15 100644 --- a/tests/VersionJsonTests.cs +++ b/tests/VersionJsonTests.cs @@ -5,13 +5,13 @@ namespace Tests; public class VersionJsonTests { - private static readonly JsonSerializerOptions options = new() { Converters = { new VersionJson() } }; + private static readonly JsonSerializerOptions options = new() { Converters = { new VersionJson() } }; - [Fact] - public void VersionRoundTripsThroughString() - { - var json = JsonSerializer.Serialize(new Version(1, 2, 3), options); - Assert.Equal("\"1.2.3\"", json); - Assert.Equal(new Version(1, 2, 3), JsonSerializer.Deserialize(json, options)); - } + [Fact] + public void VersionRoundTripsThroughString() + { + var json = JsonSerializer.Serialize(new Version(1, 2, 3), options); + Assert.Equal("\"1.2.3\"", json); + Assert.Equal(new Version(1, 2, 3), JsonSerializer.Deserialize(json, options)); + } }