diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml
deleted file mode 100644
index f75f773..0000000
--- a/.github/workflows/installer.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-name: Installer
-on:
- push:
- paths:
- - 'installer/**'
- pull_request:
- paths:
- - 'installer/**'
-jobs:
- job:
- defaults:
- run:
- working-directory: ./installer
- strategy:
- fail-fast: false
- matrix:
- os: [windows-latest, ubuntu-22.04, macos-13]
- name: Build on ${{ matrix.os }}
- runs-on: ${{ matrix.os }}
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-python@v5
- with:
- python-version: '3.11'
- - name: Install requirements
- run: pip install -r requirements.txt
- - name: Build
- run: python build.py
- - uses: actions/upload-artifact@v4
- with:
- name: ${{ matrix.os }}
- path: installer/dist/
- merge:
- name: Merge artifacts
- runs-on: ubuntu-latest
- needs: job
- steps:
- - uses: actions/upload-artifact/merge@v4
- with:
- name: artifacts
- delete-merged: true
- separate-directories: true
-
diff --git a/README.md b/README.md
index 2772d50..408cd22 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
| | x86 | ARM |
|:-------:|:---:|:---:|
| Windows | ✅ | ❌ |
-| Linux | ⏺️ | ❌ |
-| MacOS | ⏺️ | ❌ |
+| Linux | ✅ | ❌ |
+| MacOS | ❌ | ❌ |
| Android | ➖ | 🚧 |
| IOS | ➖ | ❌ |
diff --git a/installer/build.py b/installer/build.py
deleted file mode 100644
index 8b802ae..0000000
--- a/installer/build.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import os
-import PyInstaller.__main__
-
-ROOT = os.path.abspath(os.getcwd())
-
-PyInstaller.__main__.run(
- [
- "--noconfirm",
- "--onefile",
- "--windowed",
- "--icon",
- ROOT + "/icon.ico",
- "--name",
- "PolyMod",
- "--add-data",
- ROOT + "/icon.ico:.",
- ROOT + "/main.py",
- ]
-)
diff --git a/installer/icon.ico b/installer/icon.ico
deleted file mode 100644
index f52643a..0000000
Binary files a/installer/icon.ico and /dev/null differ
diff --git a/installer/main.py b/installer/main.py
deleted file mode 100644
index e1fad1b..0000000
--- a/installer/main.py
+++ /dev/null
@@ -1,154 +0,0 @@
-import os
-import io
-import sys
-import shutil
-import zipfile
-import requests
-import threading
-import subprocess
-import customtkinter
-import CTkMessagebox as messagebox
-
-OS = {
- "linux": "linux",
- "linux2": "linux",
- "win32": "win",
- "darwin": "macos",
-}[sys.platform]
-BEPINEX = "https://polymod.dev/data/bepinex.txt"
-POLYMOD = "https://api.github.com/repos/PolyModdingTeam/PolyMod/releases"
-
-
-def resource_path(path):
- try:
- base_path = sys._MEIPASS
- except Exception:
- base_path = os.path.abspath(".")
- return os.path.join(base_path, path)
-
-
-def to_zip(request: requests.Response):
- return zipfile.ZipFile(io.BytesIO(request.content))
-
-
-def browse():
- global path_entry
- path_entry.delete(0, customtkinter.END)
- path_entry.insert(0, customtkinter.filedialog.askdirectory())
-
-
-def prepare(target):
- global progress_bar
- path = path_entry.get()
- try:
- if "Polytopia_Data" not in os.listdir(path):
- raise FileNotFoundError
- except FileNotFoundError:
- messagebox.CTkMessagebox(
- title="Error",
- message="The folder does not exist or is not valid!",
- icon="cancel",
- width=100,
- height=50
- )
- return
- path_entry.configure(state=customtkinter.DISABLED)
- browse_button.configure(state=customtkinter.DISABLED)
- prerelease_checkbox.destroy()
- install_button.destroy()
- uninstall_button.destroy()
- progress_bar = customtkinter.CTkProgressBar(app, determinate_speed=50 / 2)
- progress_bar.grid(column=0, row=1, columnspan=2, padx=5, pady=5)
- progress_bar.set(0)
- threading.Thread(target=target, daemon=True, args=(path, )).start()
-
-
-def install(path):
- to_zip(
- requests.get(
- requests.get(BEPINEX).text.strip().replace("{os}", OS)
- )
- ).extractall(path)
- progress_bar.step()
-
- for release in requests.get(POLYMOD).json():
- if release["prerelease"] and not prerelease_checkbox.get(): continue
- latest = release
- break
- open(path + "/BepInEx/plugins/PolyMod.dll", "wb").write(
- requests.get(latest["assets"][0]["browser_download_url"]).content
- )
- progress_bar.step()
-
- customtkinter.CTkButton(app, text="Launch", command=lambda: launch(path)).grid(
- column=0, row=2, columnspan=2, padx=5, pady=5
- )
-
-
-def uninstall(path):
- dirs = [
- "BepInEx",
- "dotnet",
- ]
- files = [
- ".doorstop_version",
- "changelog.txt",
- "doorstop_config.ini",
- "winhttp.dll", # windows
- "libdoorstop.so", # linux
- "libdoorstop.dylib", # mac
- "run_bepinex.sh", # linux + mac
- ]
- for dir in dirs:
- shutil.rmtree(path + "/" + dir, True)
- progress_bar.step()
- for file in files:
- try:
- os.remove(path + "/" + file)
- except FileNotFoundError:
- ...
- progress_bar.step()
- customtkinter.CTkButton(app, text="Quit", command=quit).grid(
- column=0, row=2, columnspan=2, padx=5, pady=5
- )
-
-
-def launch(path):
- if sys.platform != "win32":
- subprocess.check_call(f"chmod +x {path}/run_bepinex.sh", shell=True)
- subprocess.check_call(f"{path}/run_bepinex.sh {path}/Polytopia.*", shell=True)
- subprocess.check_call(f"xdg-open https://docs.bepinex.dev/articles/advanced/steam_interop.html", shell=True)
- else:
- subprocess.check_call(f"start steam://rungameid/874390", shell=True)
- quit()
-
-
-def quit():
- app.destroy()
- sys.exit()
-
-
-app = customtkinter.CTk()
-app.title("PolyMod")
-if OS != "linux":
- app.iconbitmap(default=resource_path("icon.ico"))
-app.resizable(False, False)
-
-path_entry = customtkinter.CTkEntry(
- app, placeholder_text="Game path", width=228)
-browse_button = customtkinter.CTkButton(
- app, text="Browse", command=browse, width=1)
-prerelease_checkbox = customtkinter.CTkCheckBox(
- app, text="Prerelease", width=1)
-install_button = customtkinter.CTkButton(
- app, text="Install", command=lambda: prepare(install))
-uninstall_button = customtkinter.CTkButton(
- app, text="Uninstall", command=lambda: prepare(uninstall))
-
-path_entry.grid(column=0, row=0, padx=5, pady=5)
-browse_button.grid(column=1, row=0, padx=(0, 5), pady=5)
-prerelease_checkbox.grid(column=0, row=1, columnspan=2, padx=5, pady=5)
-install_button.grid(column=0, row=2, columnspan=2, padx=5, pady=5)
-uninstall_button.grid(column=0, row=3, columnspan=2, padx=5, pady=5)
-
-app.mainloop()
diff --git a/installer/requirements.txt b/installer/requirements.txt
deleted file mode 100644
index c396099..0000000
--- a/installer/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-requests
-pyinstaller
-customtkinter
-CTkMessagebox
\ No newline at end of file
diff --git a/resources/localization.json b/resources/localization.json
index 3b19f1e..455c1ef 100644
--- a/resources/localization.json
+++ b/resources/localization.json
@@ -60,9 +60,13 @@
"German (Germany)": "DATENDUMP"
},
"polymod_hub_dumped": {
- "English": "Dump completed successfully!",
- "Russian": "Дамп завершен успешно!",
- "German (Germany)": "Dump erfolgreich beendet!"
+ "English": "Dump completed successfully!\n\n{0}",
+ "Russian": "Дамп завершен успешно!\n\n{0}",
+ "German (Germany)": "Dump erfolgreich beendet!\n\n{0}"
+ },
+ "polymod_hub_openfolder": {
+ "English": "OPEN FOLDER",
+ "Russian": "ОТКРЫТЬ ПАПКУ"
},
"polymod_cycle": {
"English": "Dependency cycle detected",
@@ -218,18 +222,14 @@
"Russian": "{0} задан на {1}!",
"Elyrion": "{0} iȓ ȓ∑Ŧ Ŧȱ {1}!"
},
- "polymod_hub_spriteinfo_update": {
- "English": "UPDATE SPRITES",
- "Russian": "ОБНОВИТЬ СПРАЙТЫ",
- "Elyrion": "₺πΔaŦ∑ ȓπriŦ∑ȓ"
+ "polymod_hub_hotreload": {
+ "English": "HOT RELOAD"
},
- "polymod_spriteinfo_updated": {
- "English": "Sprite info for mod {0} updated!",
- "Russian": "Данные спрайтов обновлены для мода {0}!",
- "Elyrion": "ȓπriŦ∑ iŋ₼ȱ ₼ȱr mȱΔ {0} ₺πΔaŦ∑Δ!"
+ "polymod_hotreload_success": {
+ "English": "Hot reload completed for the mod {0}!"
},
- "polymod_spriteinfo_notupdated": {
- "English": "No sprite infos were found!",
+ "polymod_hotreload_failure": {
+ "English": "No mods for hot reload!",
"Russian": "Данные спрайтов не найдены!",
"Elyrion": "ŋȱ ȓπriŦ∑ iŋ₼ȱȓ ~∑r∑ ₼ȱ₺ŋΔ!"
},
@@ -242,5 +242,61 @@
"English": "PROCEED",
"Russian": "ПРОДОЛЖИТЬ",
"Elyrion": "πrȱ#∑∑Δ"
+ },
+ "polymod_title_invalid": {
+ "English": "Invalid Launch",
+ "Russian": "Неверный Запуск"
+ },
+ "polymod_description_invalid": {
+ "English": "PolyMod launch was invalid. Please, open the game via the PolyMod Launcher or proceed to our site to install it :)",
+ "Russian": "PolyMod запущен неккоректно. Пожалуйста, откройте игру через PolyMod Launcher или перейдите на наш сайт и установите его :)"
+ },
+ "buttons_download_launcher": {
+ "English": "OUR SITE",
+ "Russian": "НАШ САЙТ"
+ },
+ "polymod_hub_summary": {
+ "English": "{0} of {1} mods loaded.",
+ "Russian": "Загружено модов: {0} из {1}."
+ },
+ "polymod_hub_mods_none": {
+ "English": "No mods are installed yet. Put mod archives into the Mods folder next to the game and restart it.",
+ "Russian": "Моды пока не установлены. Положите архивы модов в папку Mods рядом с игрой и перезапустите её."
+ },
+ "polymod_hub_mod_authors": {
+ "English": "by {0}",
+ "Russian": "от {0}"
+ },
+ "polymod_hub_config_debug": {
+ "English": "DEBUG",
+ "Russian": "ОТЛАДКА"
+ },
+ "polymod_hub_config_coordinates": {
+ "English": "COORDINATES",
+ "Russian": "КООРДИНАТЫ"
+ },
+ "polymod_debug_description": {
+ "English": "Opens the developer console and writes verbose logs.",
+ "Russian": "Открывает консоль разработчика и включает подробные логи."
+ },
+ "polymod_coordinates": {
+ "English": "Coordinates",
+ "Russian": "Координаты"
+ },
+ "polymod_coordinates_description": {
+ "English": "Shows the coordinates of every tile on the map.",
+ "Russian": "Показывает координаты каждой клетки на карте."
+ },
+ "polymod_on": {
+ "English": "ON",
+ "Russian": "ВКЛ"
+ },
+ "polymod_off": {
+ "English": "OFF",
+ "Russian": "ВЫКЛ"
+ },
+ "polymod_hotreload_unavailable": {
+ "English": "Hot reload is not available yet.",
+ "Russian": "Горячая перезагрузка пока недоступна."
}
-}
+}
\ No newline at end of file
diff --git a/src/Dumper.cs b/src/Dumper.cs
new file mode 100644
index 0000000..0bd3c20
--- /dev/null
+++ b/src/Dumper.cs
@@ -0,0 +1,291 @@
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using Il2CppSystem.Linq;
+using PolyMod.Json;
+using PolytopiaBackendBase.Common;
+using UnityEngine;
+
+namespace PolyMod.Managers;
+
+///
+/// Writes the game's own data (logic, localization, sprites, tribe previews and prefabs) to disk
+/// so that mod authors can use it as a reference.
+///
+internal static class Dumper
+{
+ private static readonly JsonSerializerOptions INDENTED = new()
+ {
+ WriteIndented = true,
+ };
+
+ private static readonly JsonSerializerOptions INDENTED_WITH_VECTORS = new()
+ {
+ WriteIndented = true,
+ Converters = { new Vector2Json() },
+ };
+
+ private static readonly JsonSerializerOptions LOCALIZATION = new()
+ {
+ WriteIndented = true,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ internal static string RootPath => Path.GetFullPath(Plugin.DUMPED_DATA_PATH);
+
+ internal static void DumpAll()
+ {
+ Directory.CreateDirectory(Plugin.DUMPED_DATA_PATH);
+ DumpLogic();
+ DumpLocalization();
+ DumpSprites();
+ DumpTribePreviews();
+ DumpPrefabs();
+ }
+
+ ///
+ /// Dumps every version of the game logic and avatar data.
+ ///
+ internal static void DumpLogic()
+ {
+ try
+ {
+ Directory.CreateDirectory(Plugin.LOGIC_DUMP_PATH);
+ for (int version = 1; version <= VersionManager.GameLogicDataVersion; version++)
+ {
+ File.WriteAllText(
+ Path.Combine(Plugin.LOGIC_DUMP_PATH, $"GameLogicData{version}.json"),
+ PolytopiaDataManager.provider.LoadGameLogicData(version)
+ );
+ }
+ for (int version = 1; version <= VersionManager.AvatarDataVersion; version++)
+ {
+ File.WriteAllText(
+ Path.Combine(Plugin.LOGIC_DUMP_PATH, $"AvatarData{version}.json"),
+ PolytopiaDataManager.provider.LoadAvatarData(version)
+ );
+ }
+ }
+ catch (Exception exception)
+ {
+ Plugin.logger.LogError($"Dumping logic failed: {exception}");
+ }
+ }
+
+ internal static void DumpLocalization()
+ {
+ try
+ {
+ Directory.CreateDirectory(Plugin.LOCALIZATION_DUMP_PATH);
+ var source = I2.Loc.LocalizationManager.Sources[0];
+
+ foreach (var language in source.GetLanguages())
+ {
+ int languageIndex = source.GetLanguageIndex(language);
+ Dictionary translations = new();
+ foreach (var term in source.mTerms)
+ {
+ string translation = term.GetTranslation(languageIndex);
+ if (!string.IsNullOrEmpty(translation))
+ translations[term.Term] = translation;
+ }
+ File.WriteAllText(
+ Path.Combine(
+ Plugin.LOCALIZATION_DUMP_PATH,
+ $"language_{source.mLanguages[languageIndex].Code}.json"
+ ),
+ JsonSerializer.Serialize(translations, LOCALIZATION)
+ );
+ }
+
+ foreach (var category in source.GetCategories())
+ {
+ File.WriteAllText(
+ Path.Combine(Plugin.LOCALIZATION_DUMP_PATH, $"localization_{category}.csv"),
+ source.Export_CSV(category)
+ );
+ }
+ }
+ catch (Exception exception)
+ {
+ Plugin.logger.LogError($"Dumping localization failed: {exception}");
+ }
+ }
+
+ internal static void DumpSprites()
+ {
+ try
+ {
+ Directory.CreateDirectory(Plugin.SPRITES_DUMP_PATH);
+ SpriteAtlasManager spriteAtlasManager = GameManager.GetSpriteAtlasManager();
+
+ for (int i = 0; i < AtlasData.raw.Count; i++)
+ {
+ if (spriteAtlasManager.loadedAtlases[i] == null) continue;
+
+ string[] row = AtlasData.raw[i];
+ string atlasName = row[0];
+ string directory = Path.Combine(Plugin.SPRITES_DUMP_PATH, atlasName);
+ Directory.CreateDirectory(directory);
+
+ Dictionary atlasSpriteInfo = new();
+ for (int j = 1; j < row.Length; j++)
+ {
+ string spriteName = row[j];
+ Sprite sprite = spriteAtlasManager.GetSprite(spriteName, atlasName);
+ if (sprite == null) continue;
+
+ (byte[] bytes, Visual.SpriteInfo spriteInfo) = Visual.SpriteToPng(sprite);
+ File.WriteAllBytes(Path.Combine(directory, $"{spriteName}.png"), bytes);
+ atlasSpriteInfo[spriteName] = spriteInfo;
+ }
+ File.WriteAllText(
+ Path.Combine(directory, $"sprites_{atlasName}.json"),
+ JsonSerializer.Serialize(atlasSpriteInfo, INDENTED_WITH_VECTORS)
+ );
+ }
+ }
+ catch (Exception exception)
+ {
+ Plugin.logger.LogError($"Dumping sprites failed: {exception}");
+ }
+ }
+
+ internal static void DumpTribePreviews()
+ {
+ try
+ {
+ foreach (TribeType type in Enum.GetValues())
+ {
+ SaveStateData saveStateData = TribePreviewRegistry.GetTribePreviewData(type, SkinType.Default); // TODO: skins
+ if (saveStateData == null) continue;
+
+ if (!DiskSerializationHelpers.FromLZ4CompressedByteArray(
+ saveStateData.byteArray, out ClientSerializationWrapper wrapper, out int _))
+ {
+ Plugin.logger.LogWarning($"Dump skipped preview of {type}: save state could not be read");
+ continue;
+ }
+
+ List previewTiles = new();
+ foreach (var tileData in wrapper.GetCurrentGameState().Map.Tiles)
+ {
+ previewTiles.Add(new TribePreviews.PreviewTile
+ {
+ x = tileData.coordinates.x,
+ y = tileData.coordinates.y,
+ terrainType = tileData.terrain,
+ resourceType = tileData.resource != null ? tileData.resource.type : null,
+ unitType = tileData.unit != null ? tileData.unit.type : null,
+ improvementType = tileData.improvement != null ? tileData.improvement.type : null,
+ tileEffects = tileData.effects.ToArray().ToList(),
+ });
+ }
+
+ File.WriteAllText(
+ Path.Combine(Plugin.DUMPED_DATA_PATH, $"preview_{type}.json"),
+ JsonSerializer.Serialize(previewTiles, INDENTED)
+ );
+ }
+ }
+ catch (Exception exception)
+ {
+ Plugin.logger.LogError($"Dumping tribe previews failed: {exception}");
+ }
+ }
+
+ internal static void DumpPrefabs()
+ {
+ try
+ {
+ Directory.CreateDirectory(Plugin.PREFABS_DUMP_PATH);
+ PrefabsInfo prefabsInfo = new();
+
+ foreach (var entry in Prefabs.unitsNew)
+ {
+ List? visualParts = CollectVisualParts(entry.Value, entry.Key.unitType);
+ if (visualParts == null) continue;
+
+ prefabsInfo.units.Add(new UnitPrefabInfo
+ {
+ headPositionMarker = entry.Value.headPositionMarker.gameObject.name,
+ skinnedKey = entry.Key,
+ visualParts = visualParts,
+ });
+ }
+
+ foreach (var entry in PrefabManager.improvements)
+ {
+ List? visualParts = CollectVisualParts(entry.Value, entry.Key);
+ if (visualParts == null) continue;
+
+ prefabsInfo.improvements.Add(new ImprovementPrefabInfo
+ {
+ improvementType = entry.Key,
+ visualParts = visualParts,
+ });
+ }
+
+ foreach (var entry in PrefabManager.resources)
+ {
+ List? visualParts = CollectVisualParts(entry.Value, entry.Key);
+ if (visualParts == null) continue;
+
+ prefabsInfo.resources.Add(new ResourcePrefabInfo
+ {
+ resourceType = entry.Key,
+ visualParts = visualParts,
+ });
+ }
+
+ // TODO
+ // foreach (var entry in PrefabManager.weapons)
+ // {
+ // WeaponGFX weaponGFX = entry.Value;
+ // prefabsInfo.weapons.Add(new WeaponPrefabInfo
+ // {
+ // weaponEnum = entry.Key,
+ // spriteName = weaponGFX.defaultSprite.name,
+ // pathType = weaponGFX.pathType,
+ // pathMode = weaponGFX.pathMode,
+ // pathResolution = weaponGFX.pathResolution,
+ // pathEase = weaponGFX.pathEase,
+ // lookAheadOffset = weaponGFX.lookAheadOffset,
+ // handleAngleOffset = weaponGFX.handleAngleOffset,
+ // sfxType = SFXTypes.None, //TODO
+ // weaponPuffInfo = null, //TODO
+ // weaponRangedInfo = null //TODO
+ // });
+ // }
+
+ File.WriteAllText(
+ Path.Combine(Plugin.PREFABS_DUMP_PATH, "prefabs.json"),
+ JsonSerializer.Serialize(prefabsInfo, INDENTED_WITH_VECTORS)
+ );
+ }
+ catch (Exception exception)
+ {
+ Plugin.logger.LogError($"Dumping prefabs failed: {exception}");
+ }
+ }
+
+ private static List? CollectVisualParts(Component prefab, object key)
+ {
+ SkinVisualsReference skinVisualsReference = prefab.GetComponent();
+ if (skinVisualsReference == null || skinVisualsReference.visualParts == null)
+ {
+ Plugin.logger.LogWarning($"Dump skipped {key}: no SkinVisualsReference");
+ return null;
+ }
+
+ List visualParts = new();
+ foreach (var visualPart in skinVisualsReference.visualParts)
+ {
+ if (visualPart == null || visualPart.visualPart == null) continue;
+
+ VisualPartInfo visualPartInfo = new();
+ visualPartInfo.CopyFromVisualPart(visualPart);
+ visualParts.Add(visualPartInfo);
+ }
+ return visualParts;
+ }
+}
diff --git a/src/Json/EnumNameJson.cs b/src/Json/EnumNameJson.cs
new file mode 100644
index 0000000..953ec68
--- /dev/null
+++ b/src/Json/EnumNameJson.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PolyMod.Json;
+
+///
+/// Converts fixed (non-extensible) enums to and from their plain string names.
+/// Use this for enums that are not registered in EnumCache.
+///
+public class EnumNameJson : JsonConverter where EnumType : struct, Enum
+{
+ public override EnumType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ string? enumName = reader.GetString();
+
+ if (enumName != null && Enum.TryParse(enumName, true, out EnumType parsedValue))
+ {
+ return parsedValue;
+ }
+
+ string validNames = string.Join(", ", Enum.GetNames(typeof(EnumType)));
+ throw new JsonException($"Unknown {typeof(EnumType).Name} value '{enumName}'. Valid values: {validNames}");
+ }
+
+ public override void Write(Utf8JsonWriter writer, EnumType value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToString());
+ }
+}
\ No newline at end of file
diff --git a/src/Loader.cs b/src/Loader.cs
index 87d5a76..218357a 100644
--- a/src/Loader.cs
+++ b/src/Loader.cs
@@ -8,7 +8,6 @@
using Polytopia.Data;
using PolytopiaBackendBase.Game;
using System.Diagnostics;
-using System.Globalization;
using System.IO.Compression;
using System.Reflection;
using System.Text;
@@ -24,1096 +23,1005 @@ namespace PolyMod;
///
public static class Loader
{
- internal record TypeMapping(Type type, bool shouldCreateCache = true);
-
- ///
- /// Mappings from JSON data types to their corresponding C# types.
- ///
- internal static Dictionary typeMappings = new()
- {
- { "tribeData", new TypeMapping(typeof(TribeType)) },
- { "techData", new TypeMapping(typeof(TechData.Type)) },
- { "unitData", new TypeMapping(typeof(UnitData.Type)) },
- { "improvementData", new TypeMapping(typeof(ImprovementData.Type)) },
- { "terrainData", new TypeMapping(typeof(Polytopia.Data.TerrainData.Type)) },
- { "resourceData", new TypeMapping(typeof(ResourceData.Type)) },
- { "taskData", new TypeMapping(typeof(TaskData.Type)) },
- { "tribeAbility", new TypeMapping(typeof(TribeAbility.Type)) },
- { "unitAbility", new TypeMapping(typeof(UnitAbility.Type)) },
- { "improvementAbility", new TypeMapping(typeof(ImprovementAbility.Type)) },
- { "playerAbility", new TypeMapping(typeof(PlayerAbility.Type)) },
- { "weaponData", new TypeMapping(typeof(UnitData.WeaponEnum)) },
- { "skinData", new TypeMapping(typeof(SkinData), false) }
- };
-
- ///
- /// List of custom game modes to be added.
- ///
- internal static List gamemodes = new();
-
- ///
- /// Handlers for processing specific data types during mod loading.
- ///
- internal static readonly Dictionary>> typeHandlers = new()
- {
- [typeof(TribeType)] = new List>() {
- new((token, duringEnumCacheCreation) =>
- {
- if (duringEnumCacheCreation)
- {
- Registry.customTribes.Add((TribeType)(int)token["idx"]);
- token["style"] = Registry.climateAutoidx;
- token["climate"] = Registry.climateAutoidx;
- Registry.climateAutoidx++;
- }
- else
- {
- if (token["skins"] != null)
- {
- JArray skins = token["skins"].Cast();
- List skinValues = skins._values.ToArray().ToList();
- foreach (var skin in skinValues)
- {
- string skinId = skin.ToString();
- if (!Enum.TryParse(skinId, ignoreCase: true, out _))
- {
- SkinType skinValue = (SkinType)Registry.autoidx;
- EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue);
- EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue);
- Registry.skinInfo.Add(new Visual.SkinInfo(Registry.autoidx, skinId, null));
- Plugin.logger.LogInfo("Created mapping for skinType with id " + skinId + " and index " + Registry.autoidx);
- Registry.autoidx++;
- if(token["tribeAbilities"] != null)
- {
- JArray tribeAbilities = token["tribeAbilities"].Cast();
- List tribeAbilitiesValues = tribeAbilities._values.ToArray().ToList();
-
- foreach(JToken ability in tribeAbilitiesValues)
- {
- string abilityValue = ability.ToString();
-
- if (Enum.TryParse(
- abilityValue, ignoreCase: true,
- out TribeAbility.Type tribeAbilityType
- ) && tribeAbilityType == TribeAbility.Type.Flooded)
- {
- string tileEffectId = skinId.ToLowerInvariant() + "_flood";
- EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx);
- EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx);
+ internal record TypeMapping(Type type, bool shouldCreateCache = true);
+
+ ///
+ /// Mappings from JSON data types to their corresponding C# types.
+ ///
+ internal static Dictionary typeMappings = new()
+ {
+ { "tribeData", new TypeMapping(typeof(TribeType)) },
+ { "techData", new TypeMapping(typeof(TechData.Type)) },
+ { "unitData", new TypeMapping(typeof(UnitData.Type)) },
+ { "improvementData", new TypeMapping(typeof(ImprovementData.Type)) },
+ { "terrainData", new TypeMapping(typeof(Polytopia.Data.TerrainData.Type)) },
+ { "resourceData", new TypeMapping(typeof(ResourceData.Type)) },
+ { "taskData", new TypeMapping(typeof(TaskData.Type)) },
+ { "tribeAbility", new TypeMapping(typeof(TribeAbility.Type)) },
+ { "unitAbility", new TypeMapping(typeof(UnitAbility.Type)) },
+ { "improvementAbility", new TypeMapping(typeof(ImprovementAbility.Type)) },
+ { "playerAbility", new TypeMapping(typeof(PlayerAbility.Type)) },
+ { "weaponData", new TypeMapping(typeof(UnitData.WeaponEnum)) },
+ { "skinData", new TypeMapping(typeof(SkinData), false) }
+ };
+
+ ///
+ /// List of custom game modes to be added.
+ ///
+ internal static List gamemodes = new();
+
+ ///
+ /// Handlers for processing specific data types during mod loading.
+ ///
+ internal static readonly Dictionary>> typeHandlers = new()
+ {
+ [typeof(TribeType)] = new List>() {
+ new((token, duringEnumCacheCreation) =>
+ {
+ if (duringEnumCacheCreation)
+ {
+ Registry.customTribes.Add((TribeType)(int)token["idx"]);
+ token["style"] = Registry.climateAutoidx;
+ token["climate"] = Registry.climateAutoidx;
+ Registry.climateAutoidx++;
+ }
+ else
+ {
+ if (token["skins"] != null)
+ {
+ JArray skins = token["skins"].Cast();
+ List skinValues = skins._values.ToArray().ToList();
+ foreach (var skin in skinValues)
+ {
+ string skinId = skin.ToString();
+ if (!Enum.TryParse(skinId, ignoreCase: true, out _))
+ {
+ SkinType skinValue = (SkinType)Registry.autoidx;
+ EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue);
+ EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue);
+ Registry.skinInfo.Add(new Visual.SkinInfo(Registry.autoidx, skinId, null));
+ Plugin.logger.LogInfo("Created mapping for skinType with id " + skinId + " and index " + Registry.autoidx);
+ Registry.autoidx++;
+ if(token["tribeAbilities"] != null)
+ {
+ JArray tribeAbilities = token["tribeAbilities"].Cast();
+ List tribeAbilitiesValues = tribeAbilities._values.ToArray().ToList();
+
+ foreach(JToken ability in tribeAbilitiesValues)
+ {
+ string abilityValue = ability.ToString();
+
+ if (Enum.TryParse(
+ abilityValue, ignoreCase: true,
+ out TribeAbility.Type tribeAbilityType
+ ) && tribeAbilityType == TribeAbility.Type.Flooded)
+ {
+ string tileEffectId = skinId.ToLowerInvariant() + "_flood";
+ EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx);
+ EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx);
Visual.customFloodingSkins.Add((TileData.EffectType)Registry.autoidx, skinValue);
- Plugin.logger.LogInfo("Created mapping for tileEffect with id " + tileEffectId + " and index " + Registry.autoidx);
- Registry.autoidx++;
- }
- }
- }
- }
- }
- Il2CppSystem.Collections.Generic.List modifiedSkins = skins._values;
- foreach (var skin in Registry.skinInfo)
- {
- if (modifiedSkins.Contains(skin.id))
- {
- modifiedSkins.Remove(skin.id);
- modifiedSkins.Add(skin.idx.ToString());
- }
- }
- JArray newSkins = new JArray();
- foreach (var item in modifiedSkins)
- {
- newSkins.Add(item);
- }
- token["skins"] = newSkins;
- }
- if (token["preview"] != null)
- {
- Visual.PreviewTile[] preview = JsonSerializer.Deserialize(token["preview"].ToString())!;
- Registry.tribePreviews[Util.GetJTokenName(token)] = preview;
- }
- }
- })
- },
- [typeof(UnitData.Type)] = new List>() {
- new((token, duringEnumCacheCreation) =>
- {
- if(duringEnumCacheCreation)
- {
- if (token["prefab"] == null)
- {
- Registry.prefabNames.Add((int)token["idx"], EnumCache.GetName(UnitData.Type.Scout).ToLowerInvariant());
- }
- }
- else
- {
- if (token["prefab"] != null)
- {
- Registry.prefabNames.Add((int)token["idx"], token["prefab"].ToString().ToLowerInvariant());
- }
- if (token["embarksTo"] != null)
- {
- string unitId = Util.GetJTokenName(token);
- string embarkUnitId = token["embarksTo"].ToString();
- Main.embarkNames[unitId] = embarkUnitId;
- }
- if (token["weapon"] != null)
- {
- string weaponString = token["weapon"].ToString();
- if (EnumCache.TryGetType(weaponString, out UnitData.WeaponEnum type))
- {
- token["weapon"] = (int)type;
- }
- }
- }
- })
- },
-
- [typeof(ImprovementData.Type)] = new List>() {
- new((token, duringEnumCacheCreation) =>
- {
- if (duringEnumCacheCreation)
- {
- ImprovementData.Type improvementPrefabType = ImprovementData.Type.CustomsHouse;
- if (token["prefab"] != null)
- {
- string prefabId = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(token["prefab"]!.ToString());
- if (Enum.TryParse(prefabId, out ImprovementData.Type parsedType))
- improvementPrefabType = parsedType;
- }
- if(token["idx"] != null)
- PrefabManager.improvements.TryAdd((ImprovementData.Type)(int)token["idx"], PrefabManager.improvements[improvementPrefabType]);
- }
- else
- {
- string improvementId = Util.GetJTokenName(token);
- if (token["attractsResource"] != null)
- {
- string attractsId = token["attractsResource"].ToString();
- Main.attractsResourceNames[improvementId] = attractsId;
- }
- if (token["attractsToTerrain"] != null)
- {
- string attractsId = token["attractsToTerrain"].ToString();
- Main.attractsTerrainNames[improvementId] = attractsId;
- }
- if(token["infoOverride"] != null)
- {
- Loc.buildingsInfoOverrides[improvementId] = Loc.ReplaceDashesWithDots(token["infoOverride"].ToString());
- }
- }
- })
- },
-
- [typeof(ResourceData.Type)] = new List>() {
- new((token, duringEnumCacheCreation) =>
- {
- if (duringEnumCacheCreation)
- {
- ResourceData.Type resourcePrefabType = ResourceData.Type.Game;
- if (token["prefab"] != null)
- {
- string prefabId = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(token["prefab"]!.ToString());
- if (Enum.TryParse(prefabId, out ResourceData.Type parsedType))
- resourcePrefabType = parsedType;
- }
- if(token["idx"] != null)
- PrefabManager.resources.TryAdd((ResourceData.Type)(int)token["idx"], PrefabManager.resources[resourcePrefabType]);
- }
- })
- },
-
- [typeof(SkinData)] = new List>() {
- new((token, duringEnumCacheCreation) =>
- {
- var prop = token.Parent.Cast();
- prop.Replace(new JProperty(prop.Name.ToLower(), prop.Value));
- })
- },
- };
-
- ///
- /// Represents information for a custom game mode button.
- ///
- /// The index of the game mode.
- /// The action to perform when the button is clicked.
- /// The index of the button in the UI.
- /// The sprite for the button.
- /// The name of the sprite for the button.
- public record GameModeButtonsInformation(int gameModeIndex, UIButtonBase.ButtonAction action, int? buttonIndex, Sprite? sprite, string? spriteName);
-
- ///
- /// Adds a new game mode button.
- ///
- /// The unique identifier for the game mode.
- /// The action to perform when the button is clicked.
- /// The boolean which decides if GameMode should appear in the menu.
- /// The sprite for the button.
- /// The name of the sprite for the button.
- public static void AddGameMode(string id, UIButtonBase.ButtonAction action, bool shouldShowInMenu = true, Sprite? sprite = null, string? spriteName = null)
- {
- EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx);
- EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx);
- if (shouldShowInMenu)
- gamemodes.Add(new GameModeButtonsInformation(Registry.gameModesAutoidx, action, null, sprite, spriteName));
- Registry.gameModesAutoidx++;
- }
-
- ///
- /// Adds a new data type for patching.
- ///
- /// The identifier for the data type in JSON.
- /// "The C# type corresponding to the identifier.
- public static void AddPatchDataType(string typeId, Type type)
- {
- if (!typeMappings.ContainsKey(typeId))
- typeMappings.Add(typeId, new TypeMapping(type));
- }
-
- public static void AddPatchDataType(string typeId, Type type, bool shouldCreateCache)
- {
- if (!typeMappings.ContainsKey(typeId))
- typeMappings.Add(typeId, new TypeMapping(type, shouldCreateCache));
- }
-
- public static void AddTypeHandler(Type type, Action handler)
- {
- string typeString = type.ToString();
- if (!typeMappings.ContainsValue(new TypeMapping(type, true)) && !typeMappings.ContainsValue(new TypeMapping(type, false)))
- {
- Plugin.logger.LogWarning($"Tried adding TypeHandler for type: {typeString} with missing TypeMapping. Please, add TypeMapping first.");
- return;
- }
- if (!typeHandlers.ContainsKey(type))
- typeHandlers[type] = new();
-
- typeHandlers[type].Add(handler);
-
- Plugin.logger.LogInfo($"Added TypeHandler for type: {typeString}.");
- }
-
- ///
- /// Loads all mods from the mods directory.
- ///
- /// A dictionary to populate with the loaded mods.
- internal static void RegisterMods(Dictionary mods)
- {
- Directory.CreateDirectory(Plugin.MODS_PATH);
- string[] modContainers = Directory.GetDirectories(Plugin.MODS_PATH)
- .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.polymod"))
- .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.zip"))
- .ToArray();
- foreach (var modContainer in modContainers)
- {
- Mod.Manifest? manifest = null;
- List files = new();
-
- // Load mod from directory or zip archive
- if (Directory.Exists(modContainer))
- {
- foreach (var file in Directory.GetFiles(modContainer, "*", SearchOption.AllDirectories))
- {
- ProcessModFile(
- Path.GetRelativePath(modContainer, file),
- File.ReadAllBytes(file),
- files,
- ref manifest
- );
- }
- }
- else
- {
- foreach (var entry in new ZipArchive(File.OpenRead(modContainer)).Entries)
- {
- ProcessModFile(
- entry.FullName,
- entry.ReadBytes(),
- files,
- ref manifest
- );
- }
- }
- #region ValidateManifest()
- if (manifest == null)
- {
- Plugin.logger.LogError($"Mod manifest not found in {modContainer}");
- continue;
- }
- if (manifest.id == null)
- {
- Plugin.logger.LogError($"Mod id not found in {modContainer}");
- continue;
- }
- if (!Regex.IsMatch(manifest.id, @"^(?!polytopia$)[a-z_]+$"))
- {
- Plugin.logger.LogError($"Mod id {manifest.id} is invalid in {modContainer}");
- continue;
- }
- if (manifest.version == null)
- {
- Plugin.logger.LogError($"Mod version not found in {modContainer}");
- continue;
- }
- if (manifest.authors == null || manifest.authors.Length == 0)
- {
- Plugin.logger.LogError($"Mod authors not found in {modContainer}");
- continue;
- }
- if (mods.ContainsKey(manifest.id))
- {
- Plugin.logger.LogError($"Mod {manifest.id} already exists");
- continue;
- }
- #endregion
- mods.Add(manifest.id, new(
- manifest,
- Mod.Status.Success,
- files
- ));
- Plugin.logger.LogInfo($"Registered mod {manifest.id}");
- }
-
- CheckDependencies(mods);
- }
-
- private static void ProcessModFile(string fullName, byte[] bytes, List files, ref Mod.Manifest? manifest)
- {
- if (fullName == "manifest.json")
- {
- manifest = JsonSerializer.Deserialize(
- bytes,
- new JsonSerializerOptions()
- {
- Converters = { new VersionJson() },
- }
- );
- return;
- }
- files.Add(new(fullName, bytes));
- }
-
- internal static void LoadMods(Dictionary mods, out bool dependencyCycle)
- {
- dependencyCycle = !SortMods(Registry.mods);
- if (dependencyCycle) return;
-
- StringBuilder checksumString = new();
- foreach (var (id, mod) in Registry.mods)
- {
- if (mod.status != Mod.Status.Success) continue;
- foreach (var file in mod.files)
- {
- checksumString.Append(JsonSerializer.Serialize(file));
- if (Path.GetExtension(file.name) == ".dll")
- {
- LoadAssemblyFile(mod, file);
- }
- Match spritesMatch = Regex.Match(Path.GetFileName(file.name), @"^sprites(?:_(.*))?\.json$");
- if (spritesMatch.Success)
- {
- LoadSpriteInfoFile(mod, file);
- }
- Match languageMatch = Regex.Match(Path.GetFileName(file.name), @"^language(?:_(.*))?\.json$");
- if (languageMatch.Success)
- {
- string languageName = languageMatch.Groups[1].Value;
- LoadLanguageFile(
- mod,
- file,
- languageName
- );
- }
- }
- if (!mod.client && id != "polytopia")
- {
- checksumString.Append(id);
- checksumString.Append(mod.version.ToString());
- }
- }
- Compatibility.HashSignatures(checksumString);
-
- }
- internal static void CheckDependencies(Dictionary mods)
- {
- foreach (var (id, mod) in mods)
- {
- foreach (var dependency in mod.dependencies ?? Array.Empty())
- {
- string? message = null;
- if (!mods.ContainsKey(dependency.id))
- {
- message = $"Dependency {dependency.id} not found";
- }
- else
- {
- Version version = mods[dependency.id].version;
- if (
- (dependency.min != null && version < dependency.min)
- ||
- (dependency.max != null && version > dependency.max)
- )
- {
- message = $"Need dependency {dependency.id} version {dependency.min} - {dependency.max} found {version}";
- }
- }
- if (message != null)
- {
- if (dependency.required)
- {
- Plugin.logger.LogError(message);
- mod.status = Mod.Status.DependenciesUnsatisfied;
- }
- else
- {
- Plugin.logger.LogWarning(message);
- }
- }
- }
- }
- }
-
- ///
- /// Sorts mods based on their dependencies using a topological sort.
- ///
- /// The dictionary of mods to sort.
- /// True if the mods could be sorted (no circular dependencies), false otherwise.
- internal static bool SortMods(Dictionary mods)
- {
- Stopwatch s = new();
- Dictionary> graph = new();
- Dictionary inDegree = new();
- Dictionary successfulMods = new();
- Dictionary unsuccessfulMods = new();
- foreach (var (id, mod) in mods)
- {
- if (mod.status == Mod.Status.Success) successfulMods.Add(id, mod);
- else unsuccessfulMods.Add(id, mod);
- }
- foreach (var (id, _) in successfulMods)
- {
- graph[id] = new();
- inDegree[id] = 0;
- }
- foreach (var (id, mod) in successfulMods)
- {
- foreach (var dependency in mod.dependencies ?? Array.Empty())
- {
- if (!graph.ContainsKey(dependency.id)) continue;
- graph[dependency.id].Add(id);
- inDegree[id]++;
- }
- }
- Queue queue = new();
- foreach (var (id, _) in successfulMods)
- {
- if (inDegree[id] == 0)
- {
- queue.Enqueue(id);
- }
- }
- Dictionary sorted = new();
- while (queue.Count > 0)
- {
- var id = queue.Dequeue();
- var mod = successfulMods[id];
- sorted.Add(id, mod);
- foreach (var neighbor in graph[id])
- {
- inDegree[neighbor]--;
- if (inDegree[neighbor] == 0)
- {
- queue.Enqueue(neighbor);
- }
- }
- }
- if (sorted.Count != successfulMods.Count)
- {
- return false;
- }
- mods.Clear();
- mods.AddRange(sorted);
- mods.AddRange(unsuccessfulMods);
-
- return true;
- }
-
- ///
- /// Loads an assembly file from a mod.
- ///
- /// The mod the assembly belongs to.
- /// The assembly file to load.
- public static void LoadAssemblyFile(Mod mod, Mod.File file)
- {
- try
- {
- Assembly assembly = Assembly.Load(file.bytes);
- if (assembly
- .GetTypes()
- .FirstOrDefault(t => t.IsSubclassOf(typeof(Api.PolyScriptBase)))
- is { } modType)
- {
- var modInstance = (Api.PolyScriptBase)Activator.CreateInstance(modType)!;
- modInstance.Initialize(mod, BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}"));
- modInstance.Load();
- return;
- }
- foreach (Type type in assembly.GetTypes())
- {
- MethodInfo? loadWithLogger = type.GetMethod("Load", new Type[] { typeof(ManualLogSource) });
- if (loadWithLogger != null)
- {
- loadWithLogger.Invoke(null, new object[]
- {
- BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}")
- });
- Plugin.logger.LogInfo($"Invoked Load method with logger from {type.FullName} from {mod.id} mod");
- }
- MethodInfo? load = type.GetMethod("Load", Array.Empty());
- if (load != null)
- {
- load.Invoke(null, null);
- Plugin.logger.LogInfo($"Invoked Load method from {type.FullName} from {mod.id} mod");
- }
- }
- }
- catch (TargetInvocationException exception)
- {
- if (exception.InnerException != null)
- {
- Plugin.logger.LogError($"Error on loading assembly from {mod.id} mod: {exception.InnerException.Message}");
- mod.status = Mod.Status.Error;
- }
- }
- }
-
- ///
- /// Loads a localization file from a mod.
- ///
- /// The mod the localization file belongs to.
- /// The localization file to load.
- public static void LoadLocalizationFile(Mod mod, Mod.File file)
- {
- try
- {
- Loc.BuildAndLoadLocalization(JsonSerializer
- .Deserialize>>(file.bytes)!);
- Plugin.logger.LogInfo($"Registered localization from {mod.id} mod");
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Error on loading locatization from {mod.id} mod: {e.StackTrace}");
- }
- }
-
- public static void LoadLanguageFile(Mod mod, Mod.File file, string languageName)
- {
- try
- {
- var dict = JsonSerializer.Deserialize>(file.bytes);
- if (dict != null)
- {
- Loc.languagesToAdd.Add(languageName, dict);
- Plugin.logger.LogInfo($"Registered language from {mod.id} mod");
- }
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Error on loading language file from {mod.id} mod: {e.StackTrace}");
- }
- }
-
- ///
- /// Loads a sprite file from a mod.
- ///
- /// The mod the sprite file belongs to.
- /// The sprite file to load.
- public static void LoadSpriteFile(Mod mod, Mod.File file)
- {
- string name = Path.GetFileNameWithoutExtension(file.name);
- Vector2 pivot = name.Split("_")[0] switch
- {
- "field" => new(0.5f, 0.0f),
- "mountain" => new(0.5f, -0.375f),
- _ => new(0.5f, 0.5f),
- };
- float pixelsPerUnit = 2112f;
- if (Registry.spriteInfos.ContainsKey(name))
- {
- Visual.SpriteInfo spriteData = Registry.spriteInfos[name];
- pivot = spriteData.pivot ?? pivot;
- pixelsPerUnit = spriteData.pixelsPerUnit ?? pixelsPerUnit;
- }
- Sprite sprite = Visual.BuildSprite(file.bytes, pivot, pixelsPerUnit);
- GameManager.GetSpriteAtlasManager().cachedSprites.TryAdd("Heads", new());
- GameManager.GetSpriteAtlasManager().cachedSprites["Heads"].Add(name, sprite);
- Registry.sprites.Add(name, sprite);
- }
-
- ///
- /// Updates a sprite with new information.
- ///
- /// The name of the sprite to update.
- public static void UpdateSprite(string name)
- {
- if (Registry.spriteInfos.ContainsKey(name) && Registry.sprites.ContainsKey(name))
- {
- Visual.SpriteInfo spriteData = Registry.spriteInfos[name];
- Sprite sprite = Visual.BuildSpriteWithTexture(
- Registry.sprites[name].texture,
- spriteData.pivot,
- spriteData.pixelsPerUnit
- );
- GameManager.GetSpriteAtlasManager().cachedSprites["Heads"][name] = sprite;
- Registry.sprites[name] = sprite;
- }
- }
-
- ///
- /// Loads a sprite info file from a mod.
- ///
- /// The mod the sprite info file belongs to.
- /// The sprite info file to load.
- /// A dictionary of sprite information, or null if an error occurred.
- public static Dictionary? LoadSpriteInfoFile(Mod mod, Mod.File file)
- {
- try
- {
- var deserialized = JsonSerializer.Deserialize>(
- file.bytes,
- new JsonSerializerOptions()
- {
- Converters = { new Vector2Json() },
- }
- );
-
- if (deserialized != null)
- {
- foreach (var kvp in deserialized)
- {
- Registry.spriteInfos[kvp.Key] = kvp.Value;
- }
- }
-
- Plugin.logger.LogInfo($"Registered sprite data from {mod.id} mod");
- return deserialized;
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Error on loading sprite data from {mod.id} mod: {e.StackTrace}");
- return null;
- }
- }
-
- ///
- /// Loads an audio file from a mod.
- ///
- /// The mod the audio file belongs to.
- /// The audio file to load.
- public static void LoadAudioFile(Mod mod, Mod.File file)
- {
- // AudioSource audioSource = new GameObject().AddComponent();
- // GameObject.DontDestroyOnLoad(audioSource);
- // audioSource.clip = Managers.Audio.BuildAudioClip(file.bytes);
- // Registry.audioClips.Add(Path.GetFileNameWithoutExtension(file.name), audioSource);
- // TODO: issue #71
- }
-
- ///
- /// Loads a prefab info file from a mod and creates a new unit prefab.
- ///
- /// The mod the prefab info file belongs to.
- /// The prefab info file to load.
- public static void LoadPrefabInfoFile(Mod mod, Mod.File file)
- {
- try
- {
- var prefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions
- {
- Converters = { new Vector2Json() },
- PropertyNameCaseInsensitive = true,
- });
- if (prefab == null || prefab.type != Visual.PrefabType.Unit || prefab.visualParts.Count == 0)
- return;
-
- if (Registry.unitPrefabs.Keys.Any(pref => pref.name == prefab.name))
- {
- Plugin.logger.LogInfo($"Prefab {prefab.name} already exists, skipping.");
- }
- var baseUnit = PrefabManager.GetPrefab(UnitData.Type.Warrior, TribeType.Imperius, SkinType.Default);
- if (baseUnit == null)
- return;
-
- var unitInstance = GameObject.Instantiate(baseUnit);
- if (unitInstance == null)
- return;
-
- var spriteContainer = unitInstance.transform.GetChild(0);
- var material = ClearExistingPartsAndExtractMaterial(spriteContainer);
-
- var visualParts = ApplyVisualParts(prefab, spriteContainer, material);
-
- Transform? headPositionMarker = null;
- foreach (var vp in visualParts)
- {
- if (vp.visualPart.gameObject.name == prefab.headPositionMarker)
- {
- headPositionMarker = vp.visualPart.gameObject.transform;
- break;
- }
- }
-
- unitInstance.headPositionMarker = headPositionMarker ?? visualParts[0].visualPart.transform;
-
- var svr = unitInstance.GetComponent();
- svr.visualParts = visualParts.ToArray();
-
- GameObject.DontDestroyOnLoad(unitInstance.gameObject);
- Registry.unitPrefabs.Add(prefab, unitInstance.GetComponent());
-
- Plugin.logger.LogInfo($"Registered prefab {prefab.name} info from {mod.id} mod.");
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Error on loading prefab {Path.GetFileNameWithoutExtension(file.name)} info from {mod.id} mod: {e.StackTrace}.");
- }
- }
-
- ///
- /// Clears existing visual parts from a prefab and extracts the material.
- ///
- /// The transform containing the sprite parts.
- /// The material of the original sprite parts.
- private static Material? ClearExistingPartsAndExtractMaterial(Transform spriteContainer)
- {
- Material? material = null;
- for (int i = 0; i < spriteContainer.childCount; i++)
- {
- var child = spriteContainer.GetChild(i);
- if (child.gameObject.name == "Head")
- {
- var renderer = child.GetComponent();
- if (renderer != null)
- material = renderer.material;
- }
- GameObject.Destroy(child.gameObject);
- }
- return material;
- }
-
- ///
- /// Applies new visual parts to a prefab.
- ///
- /// A list of visual part information.
- /// The transform to add the parts to.
- /// The material to use for the parts.
- /// A list of the created visual parts.
- private static List ApplyVisualParts(
- Visual.PrefabInfo prefab,
- Transform spriteContainer,
- Material? material)
- {
- if (prefab.visualParts.Count == 0)
- return new List();
-
- List parts = new();
-
- foreach (var info in prefab.visualParts)
- {
- parts.Add(CreateVisualPart(info, spriteContainer, material));
- }
-
- return parts;
- }
-
- ///
- /// Creates a single visual part for a prefab.
- ///
- /// The information for the visual part.
- /// The parent transform for the part.
- /// The material to use for the part.
- /// The created visual part.
- private static SkinVisualsReference.VisualPart CreateVisualPart(
- Visual.VisualPartInfo info,
- Transform parent,
- Material? material)
- {
- var visualPartObj = new GameObject(info.gameObjectName);
- visualPartObj.transform.SetParent(parent);
- visualPartObj.transform.position = info.coordinates;
- visualPartObj.transform.localScale = info.scale;
- visualPartObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation);
-
- var outlineObj = new GameObject("Outline");
- outlineObj.transform.SetParent(visualPartObj.transform);
- outlineObj.transform.position = info.coordinates;
- outlineObj.transform.localScale = info.scale;
- outlineObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation);
-
- var visualPart = new SkinVisualsReference.VisualPart
- {
- DefaultSpriteName = info.baseName,
- visualPart = visualPartObj,
- outline = outlineObj,
- tintable = info.tintable
- };
-
- var renderer = visualPartObj.AddComponent();
- renderer.material = material;
- renderer.sortingLayerName = "Units";
- renderer.sortingOrder = info.tintable ? 0 : 1;
-
- visualPart.renderer = new SkinVisualsReference.RendererUnion { spriteRenderer = renderer };
-
- var outlineRenderer = outlineObj.AddComponent();
- outlineRenderer.material = material;
- outlineRenderer.sortingLayerName = "Units";
- outlineRenderer.sortingOrder = -1;
-
- visualPart.outlineRenderer = new SkinVisualsReference.RendererUnion { spriteRenderer = outlineRenderer };
-
- return visualPart;
- }
-
- ///
- /// Loads and applies a game logic data patch from a mod.
- ///
- /// The mod the patch belongs to.
- /// The original game logic data.
- /// The patch to apply.
- public static void LoadGameLogicDataPatch(Mod mod, JObject gld, JObject patch)
- {
- try
- {
- // Merge the patch into the game logic data
- gld = JsonMerger.Merge(gld, patch);
- Plugin.logger.LogInfo($"Registered patch from {mod.id} mod");
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Error on loading patch from {mod.id} mod: {e.StackTrace}");
- mod.status = Mod.Status.Error;
- }
- }
-
- ///
- /// Loads an asset bundle from a mod.
- ///
- /// The mod the asset bundle belongs to.
- /// The asset bundle file to load.
- public static void LoadAssetBundle(Mod mod, Mod.File file)
- {
- Registry.assetBundles.Add(
- Path.GetFileNameWithoutExtension(file.name),
- AssetBundle.LoadFromMemory(file.bytes)
- );
- }
-
- ///
- /// Processes the merged game logic data after all mods have been loaded and patched.
- ///
- /// The game logic data object to populate.
- /// The root JObject of the merged game logic data.
- internal static void ProcessGameLogicData(GameLogicData gameLogicData, JObject rootObject)
- {
- try
- {
- CreateMappings(rootObject);
- ProcessCustomTribes();
- ProcessPrefabs();
- ProcessEmbarkOverrides();
- ProcessAttractOverrides();
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Error on processing modified game logic data : {e.StackTrace}");
- }
- }
-
- internal static void ProcessCustomTribes()
- {
- foreach (var tribe in Registry.customTribes)
- {
- if (!GameLogicData.legacyTribeTypesOrder.Contains(tribe))
- {
- GameLogicData.legacyTribeTypesOrder.Add(tribe);
- }
- }
- }
-
- ///
- /// Creates EnumCache mappings for custom enum values and invokes type handlers.
- ///
- ///
- internal static void CreateMappings(JObject rootObject)
- {
- foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray())
- {
- JObject? token = jtoken.TryCast();
- if (token == null)
- continue;
-
- string dataType = Util.GetJTokenName(token, 2);
- if (!typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping))
- continue;
-
- if (token["idx"] == null || !typeMapping.shouldCreateCache)
- continue;
-
- Type targetType = typeMapping.type;
- if (!targetType.IsEnum)
- {
- Plugin.logger.LogWarning($"Type {targetType.FullName} is not an enum, skipping!");
- continue;
- }
-
- string id = Util.GetJTokenName(token);
-
- if ((int)token["idx"] == -1)
- {
- token["idx"] = Registry.autoidx;
- Registry.autoidx++;
- }
- else if (Plugin.config.allowUnsafeIndexes)
- {
- Array values = Enum.GetValues(targetType);
-
- var maxValue = values.Cast().Max();
-
- if (maxValue >= (int)token["idx"])
- {
- continue;
- }
- }
- else
- {
- continue;
- }
-
- MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(targetType).GetMethod("AddMapping");
- if (methodInfo == null)
- {
- Plugin.logger.LogWarning($"Missing AddMapping method for {targetType.FullName}");
- continue;
- }
-
- methodInfo.Invoke(null, new object[] { id, (int)token["idx"] });
- methodInfo.Invoke(null, new object[] { id, (int)token["idx"] });
-
- if (typeHandlers.TryGetValue(targetType, out var handlers))
- {
- foreach (var handler in handlers)
- {
- handler(token, true);
- }
- }
- Plugin.logger.LogInfo("Created mapping for " + targetType.ToString() + " with id " + id + " and index " + (int)token["idx"]);
- }
- foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray())
- {
- JObject? token = jtoken.TryCast();
- if (token != null)
- {
- string dataType = Util.GetJTokenName(token, 2);
- if (typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping))
- {
- if (typeHandlers.TryGetValue(typeMapping.type, out var handlers))
- {
- foreach (var handler in handlers)
- {
- handler(token, false);
- }
- }
- }
- }
- }
- }
-
- ///
- /// Processes the prefab registry and populates the PrefabManager with custom prefabs.
- ///
- internal static void ProcessPrefabs()
- {
- Plugin.logger.LogInfo($"Processing prefabs, count: {Registry.prefabNames.Count}.");
- foreach (KeyValuePair item in Registry.prefabNames)
- {
- UnitData.Type unitPrefabType = UnitData.Type.Scout;
- string prefabId = item.Value;
- int hashKey = PrefabManager.GetSkinnedHashKey((UnitData.Type)item.Key, TribeType.None, SkinType.Default);
- if (!Enum.TryParse(CultureInfo.CurrentCulture.TextInfo.ToTitleCase(prefabId), out unitPrefabType)) // Existing unit prefabs
- {
- KeyValuePair prefabInfo = Registry.unitPrefabs.FirstOrDefault(
- kv => kv.Key.name == prefabId
- );
- if (!EqualityComparer.Default.Equals(prefabInfo.Key, default))
- {
- Visual.UnitPrefabInfo unitPrefabInfo = new(
- EnumCache.GetName((UnitData.Type)item.Key),
- EnumCache.GetName(TribeType.None),
- EnumCache.GetName(SkinType.Default)
- );
- Visual.customPrefabs.Add(unitPrefabInfo, prefabInfo.Value);
- Plugin.logger.LogInfo($"Using custom prefab {prefabId} for unit {item.Key}.");
- continue;
- }
- }
- Plugin.logger.LogInfo($"Using existing prefab {EnumCache.GetName(unitPrefabType)} for unit {item.Key}.");
- PrefabManager.units.TryAdd(
- hashKey,
- PrefabManager.units[PrefabManager.GetSkinnedHashKey(unitPrefabType, TribeType.None, SkinType.Default)]
- );
- }
- }
-
- ///
- /// Processes embark overrides by mapping original embark unit types to configured overrides.
- ///
- internal static void ProcessEmbarkOverrides()
- {
- foreach (KeyValuePair entry in Main.embarkNames)
- {
- try
- {
- UnitData.Type unit = EnumCache.GetType(entry.Key);
- UnitData.Type newUnit = EnumCache.GetType(entry.Value);
- Main.embarkOverrides[unit] = newUnit;
- Plugin.logger.LogInfo($"Embark unit type for {entry.Key} is now {entry.Value}");
- }
- catch
- {
- Plugin.logger.LogError($"Embark unit type for {entry.Key} is not valid: {entry.Value}");
- }
- }
- }
-
- ///
- /// Processes attract overrides by mapping improvements to resources and terrain types based on configured overrides.
- ///
- internal static void ProcessAttractOverrides()
- {
- foreach (KeyValuePair entry in Main.attractsResourceNames)
- {
- try
- {
- ImprovementData.Type improvement = EnumCache.GetType(entry.Key);
- ResourceData.Type resource = EnumCache.GetType(entry.Value);
- Main.attractsResourceOverrides[improvement] = resource;
- Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts {entry.Value}");
- }
- catch
- {
- Plugin.logger.LogError($"Improvement {entry.Key} resource type is not valid: {entry.Value}");
- }
- }
- foreach (KeyValuePair entry in Main.attractsTerrainNames)
- {
- try
- {
- ImprovementData.Type improvement = EnumCache.GetType(entry.Key);
- Polytopia.Data.TerrainData.Type terrain = EnumCache.GetType(entry.Value);
- Main.attractsTerrainOverrides[improvement] = terrain;
- Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts on {entry.Value}");
- }
- catch
- {
- Plugin.logger.LogError($"Improvement {entry.Key} terrain type is not valid: {entry.Value}");
- }
- }
- }
+ Plugin.logger.LogInfo("Created mapping for tileEffect with id " + tileEffectId + " and index " + Registry.autoidx);
+ Registry.autoidx++;
+ }
+ }
+ }
+ }
+ }
+ Il2CppSystem.Collections.Generic.List modifiedSkins = skins._values;
+ foreach (var skin in Registry.skinInfo)
+ {
+ if (modifiedSkins.Contains(skin.id))
+ {
+ modifiedSkins.Remove(skin.id);
+ modifiedSkins.Add(skin.idx.ToString());
+ }
+ }
+ JArray newSkins = new JArray();
+ foreach (var item in modifiedSkins)
+ {
+ newSkins.Add(item);
+ }
+ token["skins"] = newSkins;
+ }
+ if (token["preview"] != null)
+ {
+ TribePreviews.PreviewTile[] preview = JsonSerializer.Deserialize(token["preview"].ToString())!;
+ Registry.tribePreviews[Util.GetJTokenName(token)] = preview;
+ }
+ }
+ })
+ },
+ [typeof(UnitData.Type)] = new List>() {
+ new((token, duringEnumCacheCreation) =>
+ {
+ if(!duringEnumCacheCreation)
+ {
+ if (token["embarksTo"] != null)
+ {
+ string unitId = Util.GetJTokenName(token);
+ string embarkUnitId = token["embarksTo"].ToString();
+ Main.embarkNames[unitId] = embarkUnitId;
+ }
+ if (token["weapon"] != null)
+ {
+ string weaponString = token["weapon"].ToString();
+ if (EnumCache.TryGetType(weaponString, out UnitData.WeaponEnum type))
+ {
+ token["weapon"] = (int)type;
+ }
+ }
+ }
+ })
+ },
+
+ [typeof(ImprovementData.Type)] = new List>() {
+ new((token, duringEnumCacheCreation) =>
+ {
+ if (duringEnumCacheCreation)
+ return;
+
+ if (token["prefab"] != null && (bool)token["prefab"])
+ {
+ bool levels = false;
+ if(token["maxLevel"] != null && (int)token["maxLevel"] > 0)
+ {
+ levels = true;
+ }
+ Prefabs.improvementPrefabInfosToCreate[(ImprovementData.Type)(int)token["idx"]] = levels;
+ }
+ string improvementId = Util.GetJTokenName(token);
+ if (token["attractsResource"] != null)
+ {
+ string attractsId = token["attractsResource"].ToString();
+ Main.attractsResourceNames[improvementId] = attractsId;
+ }
+ if (token["attractsToTerrain"] != null)
+ {
+ string attractsId = token["attractsToTerrain"].ToString();
+ Main.attractsTerrainNames[improvementId] = attractsId;
+ }
+ if(token["infoOverride"] != null)
+ {
+ Loc.buildingsInfoOverrides[improvementId] = Loc.ReplaceDashesWithDots(token["infoOverride"].ToString());
+ }
+ })
+ },
+
+ [typeof(ResourceData.Type)] = new List>() {
+ new((token, duringEnumCacheCreation) =>
+ {
+ if (duringEnumCacheCreation)
+ return;
+
+ if (token["prefab"] != null && (bool)token["prefab"])
+ {
+ Prefabs.resourcePrefabsInfosToCreate.Add((ResourceData.Type)(int)token["idx"]);
+ }
+ })
+ },
+
+ [typeof(SkinData)] = new List>() {
+ new((token, duringEnumCacheCreation) =>
+ {
+ var prop = token.Parent.Cast();
+ prop.Replace(new JProperty(prop.Name.ToLower(), prop.Value));
+ })
+ },
+ };
+
+ ///
+ /// Represents information for a custom game mode button.
+ ///
+ /// The index of the game mode.
+ /// The action to perform when the button is clicked.
+ /// The index of the button in the UI.
+ /// The sprite for the button.
+ /// The name of the sprite for the button.
+ public record GameModeButtonsInformation(int gameModeIndex, UIButtonBase.ButtonAction action, int? buttonIndex, Sprite? sprite, string? spriteName);
+
+ ///
+ /// Adds a new game mode button.
+ ///
+ /// The unique identifier for the game mode.
+ /// The action to perform when the button is clicked.
+ /// The boolean which decides if GameMode should appear in the menu.
+ /// The sprite for the button.
+ /// The name of the sprite for the button.
+ public static void AddGameMode(string id, UIButtonBase.ButtonAction action, bool shouldShowInMenu = true, Sprite? sprite = null, string? spriteName = null)
+ {
+ EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx);
+ EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx);
+ if(shouldShowInMenu)
+ gamemodes.Add(new GameModeButtonsInformation(Registry.gameModesAutoidx, action, null, sprite, spriteName));
+ Registry.gameModesAutoidx++;
+ }
+
+ ///
+ /// Adds a new data type for patching.
+ ///
+ /// The identifier for the data type in JSON.
+ /// "The C# type corresponding to the identifier.
+ public static void AddPatchDataType(string typeId, Type type)
+ {
+ if (!typeMappings.ContainsKey(typeId))
+ typeMappings.Add(typeId, new TypeMapping(type));
+ }
+
+ public static void AddPatchDataType(string typeId, Type type, bool shouldCreateCache)
+ {
+ if (!typeMappings.ContainsKey(typeId))
+ typeMappings.Add(typeId, new TypeMapping(type, shouldCreateCache));
+ }
+
+ public static void AddTypeHandler(Type type, Action handler)
+ {
+ string typeString = type.ToString();
+ if(!typeMappings.ContainsValue(new TypeMapping(type, true)) && !typeMappings.ContainsValue(new TypeMapping(type, false)))
+ {
+ Plugin.logger.LogWarning($"Tried adding TypeHandler for type: {typeString} with missing TypeMapping. Please, add TypeMapping first.");
+ return;
+ }
+ if(!typeHandlers.ContainsKey(type))
+ typeHandlers[type] = new();
+
+ typeHandlers[type].Add(handler);
+
+ Plugin.logger.LogInfo($"Added TypeHandler for type: {typeString}.");
+ }
+
+ ///
+ /// Loads all mods from the mods directory.
+ ///
+ /// A dictionary to populate with the loaded mods.
+ internal static void RegisterMods(Dictionary mods)
+ {
+ Directory.CreateDirectory(Plugin.MODS_PATH);
+ string[] modContainers = Directory.GetDirectories(Plugin.MODS_PATH)
+ .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.polymod"))
+ .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.zip"))
+ .ToArray();
+ foreach (var modContainer in modContainers)
+ {
+ Mod.Manifest? manifest = null;
+ List files = new();
+
+ // Load mod from directory or zip archive
+ if (Directory.Exists(modContainer))
+ {
+ foreach (var file in Directory.GetFiles(modContainer, "*", SearchOption.AllDirectories))
+ {
+ ProcessModFile(
+ Path.GetRelativePath(modContainer, file),
+ File.ReadAllBytes(file),
+ files,
+ ref manifest
+ );
+ }
+ }
+ else
+ {
+ foreach (var entry in new ZipArchive(File.OpenRead(modContainer)).Entries)
+ {
+ ProcessModFile(
+ entry.FullName,
+ entry.ReadBytes(),
+ files,
+ ref manifest
+ );
+ }
+ }
+ #region ValidateManifest()
+ if (manifest == null)
+ {
+ Plugin.logger.LogError($"Mod manifest not found in {modContainer}");
+ continue;
+ }
+ if (manifest.id == null)
+ {
+ Plugin.logger.LogError($"Mod id not found in {modContainer}");
+ continue;
+ }
+ if (!Regex.IsMatch(manifest.id, @"^(?!polytopia$)[a-z_]+$"))
+ {
+ Plugin.logger.LogError($"Mod id {manifest.id} is invalid in {modContainer}");
+ continue;
+ }
+ if (Plugin.config.disabledMods.Contains(manifest.id))
+ {
+ Plugin.logger.LogError($"Mod {manifest.id} is disabled, skipping");
+ continue;
+ }
+ if (manifest.version == null)
+ {
+ Plugin.logger.LogError($"Mod version not found in {modContainer}");
+ continue;
+ }
+ if (manifest.authors == null || manifest.authors.Length == 0)
+ {
+ Plugin.logger.LogError($"Mod authors not found in {modContainer}");
+ continue;
+ }
+ if (mods.ContainsKey(manifest.id))
+ {
+ Plugin.logger.LogError($"Mod {manifest.id} already exists");
+ continue;
+ }
+ #endregion
+ mods.Add(manifest.id, new(
+ manifest,
+ Mod.Status.Success,
+ files
+ ));
+ Plugin.logger.LogInfo($"Registered mod {manifest.id}");
+ }
+
+ CheckDependencies(mods);
+ }
+
+ private static void ProcessModFile(string fullName, byte[] bytes, List files, ref Mod.Manifest? manifest)
+ {
+ if (fullName == "manifest.json")
+ {
+ manifest = JsonSerializer.Deserialize(
+ bytes,
+ new JsonSerializerOptions()
+ {
+ Converters = { new VersionJson() },
+ }
+ );
+ return;
+ }
+ files.Add(new(fullName, bytes));
+ }
+
+ internal static void LoadMods(Dictionary mods, out bool dependencyCycle)
+ {
+ dependencyCycle = !SortMods(Registry.mods);
+ if (dependencyCycle) return;
+
+ StringBuilder checksumString = new();
+ foreach (var (id, mod) in Registry.mods)
+ {
+ if (mod.status != Mod.Status.Success) continue;
+ foreach (var file in mod.files)
+ {
+ checksumString.Append(JsonSerializer.Serialize(file));
+ if (Path.GetExtension(file.name) == ".dll")
+ {
+ LoadAssemblyFile(mod, file);
+ }
+ Match spritesMatch = Regex.Match(Path.GetFileName(file.name), @"^sprites(?:_(.*))?\.json$");
+ if (spritesMatch.Success)
+ {
+ LoadSpriteInfoFile(mod, file);
+ }
+ Match languageMatch = Regex.Match(Path.GetFileName(file.name), @"^language(?:_(.*))?\.json$");
+ if (languageMatch.Success)
+ {
+ string languageName = languageMatch.Groups[1].Value;
+ LoadLanguageFile(
+ mod,
+ file,
+ languageName
+ );
+ }
+ }
+ if (!mod.client && id != "polytopia")
+ {
+ checksumString.Append(id);
+ checksumString.Append(mod.version.ToString());
+ }
+ }
+ Compatibility.HashSignatures(checksumString);
+ }
+
+ internal static void CheckDependencies(Dictionary mods)
+ {
+ foreach (var (id, mod) in mods)
+ {
+ foreach (var dependency in mod.dependencies ?? Array.Empty())
+ {
+ string? message = null;
+ if (!mods.ContainsKey(dependency.id))
+ {
+ message = $"Dependency {dependency.id} not found";
+ }
+ else
+ {
+ Version version = mods[dependency.id].version;
+ if (
+ (dependency.min != null && version < dependency.min)
+ ||
+ (dependency.max != null && version > dependency.max)
+ )
+ {
+ message = $"Need dependency {dependency.id} version {dependency.min} - {dependency.max} found {version}";
+ }
+ }
+ if (message != null)
+ {
+ if (dependency.required)
+ {
+ Plugin.logger.LogError(message);
+ mod.status = Mod.Status.DependenciesUnsatisfied;
+ }
+ else
+ {
+ Plugin.logger.LogWarning(message);
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Sorts mods based on their dependencies using a topological sort.
+ ///
+ /// The dictionary of mods to sort.
+ /// True if the mods could be sorted (no circular dependencies), false otherwise.
+ internal static bool SortMods(Dictionary mods)
+ {
+ Stopwatch s = new();
+ Dictionary> graph = new();
+ Dictionary inDegree = new();
+ Dictionary successfulMods = new();
+ Dictionary unsuccessfulMods = new();
+ foreach (var (id, mod) in mods)
+ {
+ if (mod.status == Mod.Status.Success) successfulMods.Add(id, mod);
+ else unsuccessfulMods.Add(id, mod);
+ }
+ foreach (var (id, _) in successfulMods)
+ {
+ graph[id] = new();
+ inDegree[id] = 0;
+ }
+ foreach (var (id, mod) in successfulMods)
+ {
+ foreach (var dependency in mod.dependencies ?? Array.Empty())
+ {
+ if (!graph.ContainsKey(dependency.id)) continue;
+ graph[dependency.id].Add(id);
+ inDegree[id]++;
+ }
+ }
+ Queue queue = new();
+ foreach (var (id, _) in successfulMods)
+ {
+ if (inDegree[id] == 0)
+ {
+ queue.Enqueue(id);
+ }
+ }
+ Dictionary sorted = new();
+ while (queue.Count > 0)
+ {
+ var id = queue.Dequeue();
+ var mod = successfulMods[id];
+ sorted.Add(id, mod);
+ foreach (var neighbor in graph[id])
+ {
+ inDegree[neighbor]--;
+ if (inDegree[neighbor] == 0)
+ {
+ queue.Enqueue(neighbor);
+ }
+ }
+ }
+ if (sorted.Count != successfulMods.Count)
+ {
+ return false;
+ }
+ mods.Clear();
+ mods.AddRange(sorted);
+ mods.AddRange(unsuccessfulMods);
+
+ return true;
+ }
+
+ ///
+ /// Loads an assembly file from a mod.
+ ///
+ /// The mod the assembly belongs to.
+ /// The assembly file to load.
+ public static void LoadAssemblyFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ Assembly assembly = Assembly.Load(file.bytes);
+ if (assembly
+ .GetTypes()
+ .FirstOrDefault(t => t.IsSubclassOf(typeof(Api.PolyScriptBase)))
+ is { } modType)
+ {
+ var modInstance = (Api.PolyScriptBase) Activator.CreateInstance(modType)!;
+ modInstance.Initialize(mod, BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}"));
+ modInstance.Load();
+ return;
+ }
+ foreach (Type type in assembly.GetTypes())
+ {
+ MethodInfo? loadWithLogger = type.GetMethod("Load", new Type[] { typeof(ManualLogSource) });
+ if (loadWithLogger != null)
+ {
+ loadWithLogger.Invoke(null, new object[]
+ {
+ BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}")
+ });
+ Plugin.logger.LogInfo($"Invoked Load method with logger from {type.FullName} from {mod.id} mod");
+ }
+ MethodInfo? load = type.GetMethod("Load", Array.Empty());
+ if (load != null)
+ {
+ load.Invoke(null, null);
+ Plugin.logger.LogInfo($"Invoked Load method from {type.FullName} from {mod.id} mod");
+ }
+ }
+ }
+ catch (TargetInvocationException exception)
+ {
+ if (exception.InnerException != null)
+ {
+ Plugin.logger.LogError($"Error on loading assembly from {mod.id} mod: {exception.InnerException.Message}");
+ mod.status = Mod.Status.Error;
+ }
+ }
+ }
+
+ ///
+ /// Loads a localization file from a mod.
+ ///
+ /// The mod the localization file belongs to.
+ /// The localization file to load.
+ public static void LoadLocalizationFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ Loc.BuildAndLoadLocalization(JsonSerializer
+ .Deserialize>>(file.bytes)!);
+ Plugin.logger.LogInfo($"Registered localization from {mod.id} mod");
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading locatization from {mod.id} mod: {e.StackTrace}");
+ }
+ }
+
+ public static void LoadLanguageFile(Mod mod, Mod.File file, string languageName)
+ {
+ try
+ {
+ var dict = JsonSerializer.Deserialize>(file.bytes);
+ if(dict != null)
+ {
+ Loc.languagesToAdd.Add(languageName, dict);
+ Plugin.logger.LogInfo($"Registered language from {mod.id} mod");
+ }
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading language file from {mod.id} mod: {e.StackTrace}");
+ }
+ }
+
+ ///
+ /// Loads a sprite file from a mod.
+ ///
+ /// The mod the sprite file belongs to.
+ /// The sprite file to load.
+ public static void LoadTextureFile(Mod mod, Mod.File file)
+ {
+ string name = Path.GetFileNameWithoutExtension(file.name);
+ Texture2D texture2D = Visual.BuildTexture(file.bytes);
+ Registry.textures[name] = new KeyValuePair(texture2D, false);
+ }
+
+ ///
+ /// Loads a sprite info file from a mod.
+ ///
+ /// The mod the sprite info file belongs to.
+ /// The sprite info file to load.
+ /// A dictionary of sprite information, or null if an error occurred.
+ public static Dictionary? LoadSpriteInfoFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ var deserialized = JsonSerializer.Deserialize>(
+ file.bytes,
+ new JsonSerializerOptions()
+ {
+ Converters = { new Vector2Json() },
+ }
+ );
+
+ if (deserialized != null)
+ {
+ foreach (var kvp in deserialized)
+ {
+ Registry.spriteInfos[kvp.Key] = kvp.Value;
+ }
+ }
+
+ Plugin.logger.LogInfo($"Registered sprite data from {mod.id} mod");
+ return deserialized;
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading sprite data from {mod.id} mod: {e.StackTrace}");
+ return null;
+ }
+ }
+
+ ///
+ /// Loads an audio file from a mod.
+ ///
+ /// The mod the audio file belongs to.
+ /// The audio file to load.
+ public static void LoadAudioFile(Mod mod, Mod.File file)
+ {
+ string name = Path.GetFileNameWithoutExtension(file.name).ToLower();
+ try
+ {
+ AudioClip clip = Managers.Audio.BuildAudioClip(file.bytes, name);
+ AudioSource audioSource = new GameObject($"PolyModAudio_{name}").AddComponent();
+ UnityEngine.Object.DontDestroyOnLoad(audioSource.gameObject);
+ audioSource.clip = clip;
+ Registry.audioClips[name] = audioSource;
+
+ Plugin.logger.LogInfo(
+ $"Registered audio {name} file from {mod.id} mod"
+ + $" ({clip.channels}ch {clip.frequency}Hz {clip.length:F2}s)."
+ );
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading audio {name} file from {mod.id} mod: {e.Message}");
+ }
+ }
+
+ public static void LoadPrefabsFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ var prefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions
+ {
+ Converters = { new Vector2Json() },
+ PropertyNameCaseInsensitive = true,
+ });
+ if (prefab == null)
+ return;
+
+ Prefabs.unitInfos.AddRange(prefab.units);
+ Prefabs.improvementInfos.AddRange(prefab.improvements);
+ Prefabs.resourceInfos.AddRange(prefab.resources);
+
+ Plugin.logger.LogInfo($"Registered prefabs {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod.");
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading prefabs {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod: {e.StackTrace}.");
+ }
+ }
+
+ public static void LoadUnitPrefabFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ var unitPrefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions
+ {
+ Converters = { new Vector2Json() },
+ PropertyNameCaseInsensitive = true,
+ });
+ if (unitPrefab == null)
+ return;
+
+ Prefabs.unitInfos.Add(unitPrefab);
+
+ Plugin.logger.LogInfo($"Registered unit prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod.");
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading unit prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod: {e.StackTrace}.");
+ }
+ }
+
+ public static void LoadImprovementPrefabFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ var improvementPrefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions
+ {
+ Converters = { new Vector2Json() },
+ PropertyNameCaseInsensitive = true,
+ });
+ if (improvementPrefab == null)
+ return;
+
+ Prefabs.improvementInfos.Add(improvementPrefab);
+
+ Plugin.logger.LogInfo($"Registered improvement prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod.");
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading improvement prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod: {e.StackTrace}.");
+ }
+ }
+
+ public static void LoadResourcePrefabFile(Mod mod, Mod.File file)
+ {
+ try
+ {
+ var resourcePrefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions
+ {
+ Converters = { new Vector2Json() },
+ PropertyNameCaseInsensitive = true,
+ });
+ if (resourcePrefab == null)
+ return;
+
+ Prefabs.resourceInfos.Add(resourcePrefab);
+
+ Plugin.logger.LogInfo($"Registered resource prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod.");
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading resource prefab {Path.GetFileNameWithoutExtension(file.name)} file from {mod.id} mod: {e.StackTrace}.");
+ }
+ }
+
+ ///
+ /// Loads and applies a game logic data patch from a mod.
+ ///
+ /// The mod the patch belongs to.
+ /// The original game logic data.
+ /// The patch to apply.
+ public static void LoadGameLogicDataPatch(Mod mod, JObject gld, JObject patch)
+ {
+ try
+ {
+ // Merge the patch into the game logic data
+ gld = JsonMerger.Merge(gld, patch);
+ Plugin.logger.LogInfo($"Registered patch from {mod.id} mod");
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on loading patch from {mod.id} mod: {e.StackTrace}");
+ mod.status = Mod.Status.Error;
+ }
+ }
+
+ ///
+ /// Loads an asset bundle from a mod.
+ ///
+ /// The mod the asset bundle belongs to.
+ /// The asset bundle file to load.
+ public static void LoadAssetBundle(Mod mod, Mod.File file)
+ {
+ Registry.assetBundles.Add(
+ Path.GetFileNameWithoutExtension(file.name),
+ AssetBundle.LoadFromMemory(file.bytes)
+ );
+ }
+
+ ///
+ /// Processes the merged game logic data after all mods have been loaded and patched.
+ ///
+ /// The game logic data object to populate.
+ /// The root JObject of the merged game logic data.
+ internal static void ProcessGameLogicData(GameLogicData gameLogicData, JObject rootObject)
+ {
+ try
+ {
+ CreateMappings(rootObject);
+ ProcessCustomTribes();
+ ProcessEmbarkOverrides();
+ ProcessAttractOverrides();
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on processing modified game logic data : {e.StackTrace}");
+ }
+ }
+
+ internal static void ProcessCustomTribes()
+ {
+ foreach (var tribe in Registry.customTribes)
+ {
+ if(!GameLogicData.legacyTribeTypesOrder.Contains(tribe))
+ {
+ GameLogicData.legacyTribeTypesOrder.Add(tribe);
+ }
+ }
+ }
+
+ ///
+ /// Creates EnumCache mappings for custom enum values and invokes type handlers.
+ ///
+ ///
+ internal static void CreateMappings(JObject rootObject)
+ {
+ foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray())
+ {
+ JObject? token = jtoken.TryCast();
+ if (token == null)
+ continue;
+
+ string dataType = Util.GetJTokenName(token, 2);
+ if (!typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping))
+ continue;
+
+ if (token["idx"] == null || !typeMapping.shouldCreateCache)
+ continue;
+
+ Type targetType = typeMapping.type;
+ if (!targetType.IsEnum)
+ {
+ Plugin.logger.LogWarning($"Type {targetType.FullName} is not an enum, skipping!");
+ continue;
+ }
+
+ string id = Util.GetJTokenName(token);
+
+ if((int)token["idx"] == -1)
+ {
+ token["idx"] = Registry.autoidx;
+ Registry.autoidx++;
+ }
+ else if(Plugin.config.allowUnsafeIndexes)
+ {
+ Array values = Enum.GetValues(targetType);
+
+ var maxValue = values.Cast().Max();
+
+ if(maxValue >= (int)token["idx"])
+ {
+ continue;
+ }
+ }
+ else
+ {
+ continue;
+ }
+
+ MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(targetType).GetMethod("AddMapping");
+ if (methodInfo == null)
+ {
+ Plugin.logger.LogWarning($"Missing AddMapping method for {targetType.FullName}");
+ continue;
+ }
+
+ methodInfo.Invoke(null, new object[] { id, (int)token["idx"] });
+ methodInfo.Invoke(null, new object[] { id, (int)token["idx"] });
+
+ if (typeHandlers.TryGetValue(targetType, out var handlers))
+ {
+ foreach(var handler in handlers)
+ {
+ handler(token, true);
+ }
+ }
+ Plugin.logger.LogInfo("Created mapping for " + targetType.ToString() + " with id " + id + " and index " + (int)token["idx"]);
+ }
+ foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray())
+ {
+ JObject? token = jtoken.TryCast();
+ if (token != null)
+ {
+ string dataType = Util.GetJTokenName(token, 2);
+ if (typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping))
+ {
+ if (typeHandlers.TryGetValue(typeMapping.type, out var handlers))
+ {
+ foreach(var handler in handlers)
+ {
+ handler(token, false);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Processes the prefab registry and populates the PrefabManager with custom prefabs.
+ ///
+ internal static void ProcessFilesQueue()
+ {
+ try
+ {
+ foreach (KeyValuePair keyValuePair in Registry.filesQueueToBeProcessedAfterGLDMerge)
+ {
+ Mod mod = keyValuePair.Key;
+ Mod.File file = keyValuePair.Value;
+ Match prefabsMatch = Regex.Match(Path.GetFileName(file.name), @"^prefabs(?:_(.*))?\.json$");
+ if (prefabsMatch.Success)
+ {
+ LoadPrefabsFile(mod, file);
+ continue;
+ }
+
+ Match unitPrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^unitPrefab(?:_(.*))?\.json$");
+ if (unitPrefabMatch.Success)
+ {
+ LoadUnitPrefabFile(mod, file);
+ continue;
+ }
+
+ Match improvementPrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^improvementPrefab(?:_(.*))?\.json$");
+ if (improvementPrefabMatch.Success)
+ {
+ LoadImprovementPrefabFile(mod, file);
+ continue;
+ }
+
+ Match resourcePrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^resourcePrefab(?:_(.*))?\.json$");
+ if (resourcePrefabMatch.Success)
+ {
+ LoadResourcePrefabFile(mod, file);
+ continue;
+ }
+
+ }
+ }
+ catch (Exception e)
+ {
+ Plugin.logger.LogError($"Error on processing post gld merge files queue : {e.StackTrace}");
+ }
+ }
+
+ ///
+ /// Processes embark overrides by mapping original embark unit types to configured overrides.
+ ///
+ internal static void ProcessEmbarkOverrides()
+ {
+ foreach (KeyValuePair entry in Main.embarkNames)
+ {
+ try
+ {
+ UnitData.Type unit = EnumCache.GetType(entry.Key);
+ UnitData.Type newUnit = EnumCache.GetType(entry.Value);
+ Main.embarkOverrides[unit] = newUnit;
+ Plugin.logger.LogInfo($"Embark unit type for {entry.Key} is now {entry.Value}");
+ }
+ catch
+ {
+ Plugin.logger.LogError($"Embark unit type for {entry.Key} is not valid: {entry.Value}");
+ }
+ }
+ }
+
+ ///
+ /// Processes attract overrides by mapping improvements to resources and terrain types based on configured overrides.
+ ///
+ internal static void ProcessAttractOverrides()
+ {
+ foreach (KeyValuePair entry in Main.attractsResourceNames)
+ {
+ try
+ {
+ ImprovementData.Type improvement = EnumCache.GetType(entry.Key);
+ ResourceData.Type resource = EnumCache.GetType(entry.Value);
+ Main.attractsResourceOverrides[improvement] = resource;
+ Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts {entry.Value}");
+ }
+ catch
+ {
+ Plugin.logger.LogError($"Improvement {entry.Key} resource type is not valid: {entry.Value}");
+ }
+ }
+ foreach (KeyValuePair entry in Main.attractsTerrainNames)
+ {
+ try
+ {
+ ImprovementData.Type improvement = EnumCache.GetType(entry.Key);
+ Polytopia.Data.TerrainData.Type terrain = EnumCache.GetType(entry.Value);
+ Main.attractsTerrainOverrides[improvement] = terrain;
+ Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts on {entry.Value}");
+ }
+ catch
+ {
+ Plugin.logger.LogError($"Improvement {entry.Key} terrain type is not valid: {entry.Value}");
+ }
+ }
+ }
}
diff --git a/src/Managers/Audio.cs b/src/Managers/Audio.cs
index 100cec7..aefd86c 100644
--- a/src/Managers/Audio.cs
+++ b/src/Managers/Audio.cs
@@ -1,5 +1,4 @@
using HarmonyLib;
-using Polytopia.Data;
using UnityEngine;
using PolytopiaBackendBase.Common;
@@ -17,7 +16,7 @@ public static class Audio
[HarmonyPatch(typeof(MusicData), nameof(MusicData.GetNatureAudioClip))]
private static bool MusicData_GetNatureAudioClip(ref AudioClip __result, TribeType type, SkinType skinType)
{
- AudioClip? audioClip = Registry.GetAudioClip("nature", Util.GetStyle(type, skinType));
+ AudioClip? audioClip = Registry.GetAudioClip("nature", type, skinType);
if (audioClip != null)
{
__result = audioClip;
@@ -33,7 +32,7 @@ private static bool MusicData_GetNatureAudioClip(ref AudioClip __result, TribeTy
[HarmonyPatch(typeof(MusicData), nameof(MusicData.GetMusicAudioClip))]
private static bool MusicData_GetMusicAudioClip(ref AudioClip __result, TribeType type, SkinType skinType)
{
- AudioClip? audioClip = Registry.GetAudioClip("music", Util.GetStyle(type, skinType));
+ AudioClip? audioClip = Registry.GetAudioClip("music", type, skinType);
if (audioClip != null)
{
__result = audioClip;
@@ -51,7 +50,7 @@ private static bool AudioSFXData_GetClip(ref AudioClip __result, SFXTypes id, Sk
{
AudioClip? audioClip = Registry.GetAudioClip(
EnumCache.GetName(id),
- EnumCache.GetName(skinType)
+ skin: skinType
);
if (audioClip != null)
{
@@ -61,15 +60,43 @@ private static bool AudioSFXData_GetClip(ref AudioClip __result, SFXTypes id, Sk
return true;
}
+ private const string CLIP_CREATION_UNAVAILABLE =
+ "this game build cannot create audio clips at runtime."
+ + " AudioClip.Create marshals its arguments as spans, and"
+ + " Il2CppSystem.ReadOnlySpan.GetPinnableReference() is missing from its IL2CPP build."
+ + " Custom audio stays disabled until the game ships calling AudioClip.Create and SetData";
+
+ private static bool clipCreationUnavailable = false;
+
///
/// Builds an audio clip from raw byte data.
///
- /// The byte data of the audio file.
+ /// The byte data of the audio file, which has to be a WAVE file.
+ /// The name to give the clip inside Unity.
/// The created audio clip.
- public static AudioClip BuildAudioClip(byte[] data)
+ /// The data is not a WAVE file that can be decoded.
+ /// The game build cannot create audio clips.
+ public static AudioClip BuildAudioClip(byte[] data, string name = "polymod_audio")
{
- // TODO: This is a placeholder. The actual implementation is tracked in issue #71.
- return new AudioClip(new());
+ WavDecoder.WavData wav = WavDecoder.Decode(data);
+ if (clipCreationUnavailable)
+ throw new NotSupportedException($"Cannot build {name}, {CLIP_CREATION_UNAVAILABLE}.");
+
+ try
+ {
+ AudioClip clip = AudioClip.Create(name, wav.lengthSamples, wav.channels, wav.frequency, false);
+ if (clip == null)
+ throw new InvalidOperationException($"Unity returned no audio clip for {name}.");
+ if (!clip.SetData(wav.samples, 0))
+ throw new InvalidOperationException($"Unity rejected the samples for {name}.");
+ return clip;
+ }
+ catch (MissingMethodException exception)
+ {
+ // Remembered so that a mod shipping many files reports this once rather than per file.
+ clipCreationUnavailable = true;
+ throw new NotSupportedException($"Cannot build {name}, {CLIP_CREATION_UNAVAILABLE}.", exception);
+ }
}
///
diff --git a/src/Managers/AutoUpdate.cs b/src/Managers/AutoUpdate.cs
deleted file mode 100644
index 58ea639..0000000
--- a/src/Managers/AutoUpdate.cs
+++ /dev/null
@@ -1,182 +0,0 @@
-using System.Diagnostics;
-using System.IO.Compression;
-using System.Text.Json;
-using HarmonyLib;
-using UnityEngine;
-
-namespace PolyMod.Managers;
-
-///
-/// Manages the automatic update process for PolyMod.
-///
-internal static class AutoUpdate
-{
- ///
- /// Checks for updates when the start screen is shown.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnShow))]
- private static void StartScreen_UI2_OnShow()
- {
- if (!Plugin.config.autoUpdate) return;
- if (Environment.GetEnvironmentVariable("WINEPREFIX") != null)
- {
- Plugin.logger.LogError("Autoupdate is not supported on Wine!");
- return;
- }
- HttpClient client = new();
- client.DefaultRequestHeaders.Add("User-Agent", "PolyMod");
- try
- {
- // Fetch release information from GitHub API
- var json = JsonDocument.Parse(
- client.GetAsync("https://api.github.com/repos/PolyModdingTeam/PolyMod/releases").UnwrapAsync()
- .Content.ReadAsStringAsync().UnwrapAsync()
- );
- JsonElement? latest = null;
- for (int i = 0; i < json.RootElement.GetArrayLength(); i++)
- {
- var release = json.RootElement[i];
- if (release.GetProperty("prerelease").GetBoolean() && !Plugin.config.updatePrerelease) continue;
- latest = release;
- break;
- }
- string newVersion = latest?.GetProperty("tag_name").GetString()!.TrimStart('v')!;
- if (newVersion.IsVersionOlderOrEqual(Plugin.VERSION)) return;
-
- // Determine the correct BepInEx URL for the current OS
- string os = Application.platform switch
- {
- RuntimePlatform.WindowsPlayer => "win",
- RuntimePlatform.LinuxPlayer => "linux",
- RuntimePlatform.OSXPlayer => "macos",
- _ => "unknown",
- };
- if (os == "unknown")
- {
- Plugin.logger.LogError("Unsupported platform for autoupdate!");
- return;
- }
- string bepinex_url = client
- .GetAsync("https://polymod.dev/data/bepinex.txt").UnwrapAsync()
- .Content.ReadAsStringAsync().UnwrapAsync()
- .Replace("{os}", os);
-
- // The actual update logic
- void Update()
- {
- Time.timeScale = 0;
-
- // Download new PolyMod DLL and BepInEx files
- File.WriteAllBytes(
- Path.Combine(Plugin.BASE_PATH, "PolyMod.new.dll"),
- client.GetAsync(latest?.GetProperty("assets")[0].GetProperty("browser_download_url").GetString()!).UnwrapAsync()
- .Content.ReadAsByteArrayAsync().UnwrapAsync()
- );
- using ZipArchive bepinex = new(client.GetAsync(bepinex_url).UnwrapAsync().Content.ReadAsStream());
- bepinex.ExtractToDirectory(Path.Combine(Plugin.BASE_PATH, "New"), overwriteFiles: true);
-
- // Create and run the appropriate update script for the OS
- ProcessStartInfo info = new()
- {
- WorkingDirectory = Path.Combine(Plugin.BASE_PATH),
- CreateNoWindow = true,
- };
- if (Application.platform == RuntimePlatform.WindowsPlayer)
- {
- string batchPath = Path.Combine(Plugin.BASE_PATH, "update.bat");
- File.WriteAllText(batchPath, $@"
- @echo off
- echo Waiting for Polytopia.exe to exit...
- :waitloop
- tasklist | findstr /I ""Polytopia.exe"" >nul
- if not errorlevel 1 (
- timeout /T 1 >nul
- goto waitloop
- )
-
- echo Updating...
- robocopy ""New"" . /E /MOVE /NFL /NDL /NJH /NJS /NP >nul
- rmdir /S /Q ""New""
- del /F /Q ""BepInEx\plugins\PolyMod.dll""
- move /Y ""PolyMod.new.dll"" ""BepInEx\plugins\PolyMod.dll""
-
- echo Launching game...
- start steam://rungameid/874390
- timeout /T 3 /NOBREAK >nul
- exit
- ");
- info.FileName = "cmd.exe";
- info.Arguments = $"/C start \"\" \"{batchPath}\"";
- info.WorkingDirectory = Plugin.BASE_PATH;
- info.CreateNoWindow = true;
- info.UseShellExecute = false;
- }
- if (Application.platform == RuntimePlatform.LinuxPlayer || Application.platform == RuntimePlatform.OSXPlayer)
- {
- string bashPath = Path.Combine(Plugin.BASE_PATH, "update.sh");
- File.WriteAllText(bashPath, $@"
- #!/bin/bash
-
- echo ""Waiting for Polytopia to exit...""
- while pgrep -x ""Polytopia"" > /dev/null; do
- sleep 1
- done
-
- echo ""Updating...""
- mv New/* . && rm -rf New
- rm -f BepInEx/plugins/PolyMod.dll
- mv -f PolyMod.new.dll BepInEx/plugins/PolyMod.dll
-
- echo ""Launching game...""
- xdg-open steam://rungameid/874390 &
-
- sleep 3
- exit 0
- ");
-
- System.Diagnostics.Process chmod = new System.Diagnostics.Process();
- chmod.StartInfo.FileName = "chmod";
- chmod.StartInfo.Arguments = $"+x \"{bashPath}\"";
- chmod.StartInfo.UseShellExecute = false;
- chmod.StartInfo.CreateNoWindow = true;
- chmod.Start();
- chmod.WaitForExit();
-
- info.FileName = "/bin/bash";
- info.Arguments = $"\"{bashPath}\"";
- info.WorkingDirectory = Plugin.BASE_PATH;
- info.CreateNoWindow = true;
- info.UseShellExecute = false;
- }
- Process.Start(info);
- Application.Quit();
- }
-
- // Show a popup to the user asking if they want to update
- PopupManager.GetBasicPopupWithData(new(
- Localization.Get("polymod.autoupdate"),
- Localization.Get("polymod.autoupdate.description"),
- new(new PopupBase.PopupButtonData[] {
- new(
- "polymod.autoupdate.update",
- PopupBase.PopupButtonData.States.None,
- (Il2CppSystem.Action)Update
- )
- }))
- ).Show();
- }
- catch (Exception e)
- {
- Plugin.logger.LogError($"Failed to check updates: {e.Message}");
- }
- }
-
- ///
- /// Initializes the AutoUpdate manager by patching the necessary methods.
- ///
- internal static void Init()
- {
- Harmony.CreateAndPatchAll(typeof(AutoUpdate));
- }
-}
\ No newline at end of file
diff --git a/src/Managers/Compatibility.cs b/src/Managers/Compatibility.cs
index 1d55bb7..c2d2498 100644
--- a/src/Managers/Compatibility.cs
+++ b/src/Managers/Compatibility.cs
@@ -1,4 +1,5 @@
using HarmonyLib;
+using Il2CppInterop.Runtime;
using System.Text;
using UnityEngine;
using UnityEngine.EventSystems;
@@ -20,6 +21,12 @@ internal static class Compatibility
///
internal static bool shouldResetSettings = false;
private static bool sawSignatureWarning;
+ private const string downloadLink = "https://polymod.dev/download/";
+
+ // TODO: rewrite all of this bullshit! each mod should have a checksum. and then using per mod checksum, we should calculate overall checksum
+ // why? launcher will let you install a mod if u dont have it. if u have a mod with this id, but it doesnt match any checksum of any ver of
+ // this mod on polymod.dev, then we just leave it as "install". if there is a new ver, we can simply put "update"
+ // also add a client mod check or whatever
///
/// Hashes the signatures of all loaded mods to create a checksum.
@@ -87,6 +94,36 @@ private static bool CheckSignatures(Action action, Il2CppSystem.Guid gameId)
[HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnShow))]
private static void StartScreen_UI2_OnShow()
{
+ if(!Plugin.ValidLaunch && Application.platform != RuntimePlatform.Android)
+ {
+ PopupManager.GetBasicPopupWithData(
+ new(
+ Localization.Get("polymod.title.invalid"),
+ Localization.Get("polymod.description.invalid"),
+ new PopupBase.PopupButtonData[] {
+ new(
+ "buttons.exitgame",
+ PopupBase.PopupButtonData.States.None,
+ callback: (Il2CppSystem.Action)Application.Quit,
+ closesPopup: false
+ ),
+ new(
+ "buttons.download.launcher",
+ PopupBase.PopupButtonData.States.None,
+ callback: DelegateSupport.ConvertDelegate(OpenSite),
+ closesPopup: false
+ )
+ }
+ )
+ ).Show();
+
+ void OpenSite()
+ {
+ NativeHelpers.OpenURL(downloadLink, false);
+ Application.Quit();
+ }
+ return;
+ }
string lastChecksum = checksum;
try
{
diff --git a/src/Managers/Hub.cs b/src/Managers/Hub.cs
index 5afdaf9..99db641 100644
--- a/src/Managers/Hub.cs
+++ b/src/Managers/Hub.cs
@@ -1,443 +1,388 @@
-using System.Text.Json;
using Cpp2IL.Core.Extensions;
using HarmonyLib;
-using I2.Loc;
using Il2CppInterop.Runtime;
-using TMPro;
using UnityEngine;
-using UnityEngine.EventSystems;
-using UnityEngine.UI;
using static PopupBase;
-using PolytopiaBackendBase.Common;
-using System.Text.Encodings.Web;
-using Il2CppSystem.Linq;
-using System.Text.RegularExpressions;
-
namespace PolyMod.Managers;
-///
-/// Manages the PolyMod hub, including UI elements and popups.
-///
internal static class Hub
{
- private const string HEADER_PREFIX = "";
- private const string HEADER_POSTFIX = "";
- private const int POPUP_WIDTH = 1400;
- private static UIRoundButton_UI2? polyModButton = null;
- private static UIRoundButton_UI2? polyModVersion = null;
-
- ///
- /// Whether the configuration popup is currently active.
- ///
- public static bool isConfigPopupActive = false;
-
- ///
- /// Patches the splash screen to play a custom intro video.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(SplashController), nameof(SplashController.LoadAndPlayClip))]
- private static bool SplashController_LoadAndPlayClip(SplashController __instance)
- {
- string name = "intro.mp4";
- string path = Path.Combine(Application.persistentDataPath, name);
- File.WriteAllBytesAsync(path, Plugin.GetResource(name).ReadBytes());
- __instance.lastPlayTime = Time.realtimeSinceStartup;
- __instance.videoPlayer.url = path;
- __instance.videoPlayer.Play();
- return false;
- }
-
- ///
- /// Patches the popup button container to correctly anchor buttons.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(PopupButtonContainer), nameof(PopupButtonContainer.SetButtonData))]
- private static void PopupButtonContainer_SetButtonData(PopupButtonContainer __instance)
- {
- int num = __instance.buttons.Length;
- for (int i = 0; i < num; i++)
- {
- UITextButton uitextButton = __instance.buttons[i];
- Vector2 vector = new((num == 1) ? 0.5f : (i / (num - 1.0f)), 0.5f);
- uitextButton.rectTransform.anchorMin = vector;
- uitextButton.rectTransform.anchorMax = vector;
- uitextButton.rectTransform.pivot = vector;
- }
- }
-
- [HarmonyPostfix]
- [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.Init))]
- private static void StartScreen_UI2_Init(StartScreen_UI2 __instance, RectTransform transform)
- {
- polyModButton = UILibrary.NewRoundButton(transform).SetStyle(UIButtonBase_UI2.ButtonStyle.Suggested);
- polyModButton.bg.sprite = Visual.BuildSprite(Plugin.GetResource("polymod_icon.png").ReadBytes());
- polyModButton.OnClickedSignal.Add(DelegateSupport.ConvertDelegate(ShowPolyModHub));
-
- polyModVersion = UILibrary.NewRoundButton(transform).SetStyle(UIButtonBase_UI2.ButtonStyle.Suggested);
- polyModVersion.Text = $"PolyMod {Plugin.VERSION}";
- polyModVersion.titleTextField.textField.fontSize = 18f;
-
- if (Main.dependencyCycle)
- {
- BasicPopup popup = PopupManager.GetBasicPopupWithData(
- new(
- Localization.Get("polymod.cycle"),
- Localization.Get("polymod.cycle.description"),
- new PopupBase.PopupButtonData[] {
- new(
- "buttons.exitgame",
- PopupBase.PopupButtonData.States.None,
- (Il2CppSystem.Action)Application.Quit,
- closesPopup: false,
- customColorStates: ColorConstants.redButtonColorStates
- )
- }
- )
- );
-
- popup.IsUnskippable = true;
- popup.Show();
- }
-
- }
-
- ///
- /// Patches the start screen to add the PolyMod hub button and version text.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RunLayout))]
- private static void StartScreen_UI2_RunLayout(StartScreen_UI2 __instance, ScreenBase_UI2.ScreenSize screenSize)
- {
- if (polyModButton == null)
- {
- Plugin.logger.LogWarning("PolyMod Hub button is null when running layout!");
- return;
- }
- polyModButton.iconContainer.gameObject.SetActive(false);
- polyModButton.outline.gameObject.SetActive(false);
- polyModButton.bg.color = Color.white;
- polyModButton.Text = Localization.Get("polymod.hub");
- float num = 50f;
- polyModButton.SetPosition(screenSize.safeRect.Right - (num * 2.5f), screenSize.safeRect.Top - num);
-
- if (polyModVersion == null)
- {
- Plugin.logger.LogWarning("PolyMod Version is null when running layout!");
- return;
- }
- polyModVersion.iconContainer.gameObject.SetActive(false);
- polyModVersion.outline.gameObject.SetActive(false);
- polyModVersion.bg.gameObject.SetActive(false);
- polyModVersion.SetPosition(screenSize.safeRect.Left + num * 1.15f, screenSize.safeRect.Bottom + num * 1.15f);
- }
-
- ///
- /// Patches the game manager to handle key presses for the config popup.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(GameManager), nameof(GameManager.Update))]
- private static void GameManager_Update()
- {
- if (Input.GetKey(KeyCode.LeftControl) && Input.GetKeyDown(KeyCode.Tab) && !isConfigPopupActive)
- {
- ShowConfigPopup();
- }
- }
-
- ///
- /// Updates sprite information from dumped files.
- ///
- internal static void UpdateSpriteInfos()
- {
- string message = string.Empty;
- Directory.CreateDirectory(Plugin.DUMPED_DATA_PATH);
-
- foreach (var file in Directory.GetFiles(Plugin.DUMPED_DATA_PATH))
- {
- string? name = Path.GetFileNameWithoutExtension(file);
- List subnames = new();
- if (name.Contains("sprites_"))
- {
- subnames = name.Split('_').ToList();
- Mod.File spriteInfo = new(Path.GetFileNameWithoutExtension(file), File.ReadAllBytes(file));
- Dictionary? deserialized = Loader.LoadSpriteInfoFile(Registry.mods[subnames[1]], spriteInfo);
- if (deserialized != null)
- {
- foreach (var kvp in deserialized)
- {
- Loader.UpdateSprite(kvp.Key);
- }
- message += Localization.Get("polymod.spriteinfo.updated", new Il2CppSystem.Object[] { subnames[1] });
- }
- }
- }
- if (message == string.Empty)
- {
- message = Localization.Get("polymod.spriteinfo.notupdated");
- }
- NotificationManager.Notify(message);
- }
-
- ///
- /// Dump all data.
- ///
- internal static void DumpData()
- {
- Directory.CreateDirectory(Plugin.DUMPED_DATA_PATH);
- Directory.CreateDirectory(Plugin.LOGIC_DUMP_PATH);
- Directory.CreateDirectory(Plugin.LOCALIZATION_DUMP_PATH);
- File.WriteAllTextAsync(
- Path.Combine(Plugin.LOGIC_DUMP_PATH, "gameLogicData.json"),
- PolytopiaDataManager.provider.LoadGameLogicData(VersionManager.GameLogicDataVersion)
- );
- File.WriteAllTextAsync(
- Path.Combine(Plugin.LOGIC_DUMP_PATH, "avatarData.json"),
- PolytopiaDataManager.provider.LoadAvatarData(1337)
- );
- var source = LocalizationManager.Sources[0];
- foreach (var language in source.GetLanguages())
- {
- int languageIndex = source.GetLanguageIndex(language);
- var dict = new Dictionary();
-
- foreach (var term in source.mTerms)
- {
- var translation = term.GetTranslation(languageIndex);
-
- if (!string.IsNullOrEmpty(translation))
- dict[term.Term] = translation;
- }
- var options = new JsonSerializerOptions
- {
- WriteIndented = true,
- Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
- };
-
- string json = JsonSerializer.Serialize(dict, options);
-
- File.WriteAllText(
- Path.Combine(Plugin.LOCALIZATION_DUMP_PATH, $"language_{source.mLanguages[languageIndex].Code}.json"),
- json
- );
- }
- foreach (var category in source.GetCategories())
- File.WriteAllTextAsync(
- Path.Combine(Plugin.LOCALIZATION_DUMP_PATH, $"localization_{category}.csv"),
- source.Export_CSV(category)
- );
- foreach (KeyValuePair entry in Registry.mods)
- {
- foreach (Mod.File file in entry.Value.files)
- {
- Match spritesMatch = Regex.Match(Path.GetFileName(file.name), @"^sprites(?:_(.*))?\.json$");
- if (spritesMatch.Success)
- File.WriteAllBytes(
- Path.Combine(
- Plugin.DUMPED_DATA_PATH,
- $"{Path.GetFileNameWithoutExtension(file.name)}_{entry.Key}.json"
- ),
- file.bytes
- );
- }
- }
- foreach (TribeType type in Enum.GetValues(typeof(TribeType)))
- {
- List previewTiles = new();
- SelectTribePopup popup = PopupManager.GetSelectTribePopup();
- for (int x = -3; x <= 3; x++)
- {
- for (int y = -7; y <= 7; y++)
- {
- Vector2Int pos = new Vector2Int(x, y);
- if (popup.UIWorldPreview.worldPreviewData.TryGetData(pos, type, out UITileData tileData))
- {
- Visual.PreviewTile previewTile = new Visual.PreviewTile
- {
- x = tileData.Position.x,
- y = tileData.Position.y,
- terrainType = tileData.terrainType,
- resourceType = tileData.resourceType,
- unitType = tileData.unitType,
- improvementType = tileData.improvementType
- };
- previewTiles.Add(previewTile);
- }
- }
- }
- File.WriteAllTextAsync(
- Path.Combine(Plugin.DUMPED_DATA_PATH, $"preview_{type}.json"),
- JsonSerializer.Serialize(previewTiles, new JsonSerializerOptions { WriteIndented = true })
- );
- }
- NotificationManager.Notify(Localization.Get("polymod.hub.dumped"));
- }
-
- ///
- /// Shows the configuration popup.
- ///
- internal static void ShowConfigPopup()
- {
- BasicPopupLegacy polymodPopup = Visual.GetBasicPopupLegacy();
- polymodPopup.Header = Localization.Get("polymod.hub.config");
- polymodPopup.Description = "";
- polymodPopup.buttonData = CreateConfigPopupButtonData();
- polymodPopup.ShowSetWidth(POPUP_WIDTH);
- }
-
- internal static void ShowPolyModHub()
- {
- BasicPopupLegacy popup = Visual.GetBasicPopupLegacy();
- popup.Header = Localization.Get("polymod.hub");
- popup.Description = Localization.Get("polymod.hub.header", new Il2CppSystem.Object[] {
- HEADER_PREFIX,
- HEADER_POSTFIX
- }) + "\n\n";
- foreach (var mod in Registry.mods.Values)
- {
- popup.Description += Localization.Get("polymod.hub.mod", new Il2CppSystem.Object[] {
- mod.name,
- Localization.Get("polymod.hub.mod.status."
- + Enum.GetName(typeof(Mod.Status), mod.status)!.ToLower()),
- string.Join(", ", mod.authors),
- mod.version.ToString(),
- mod.description ?? ""
- });
- popup.Description += "\n\n";
- }
- popup.Description += Localization.Get("polymod.hub.footer", new Il2CppSystem.Object[] {
- HEADER_PREFIX,
- HEADER_POSTFIX
- });
-
- void OpenDiscord()
- {
- NativeHelpers.OpenURL(Plugin.DISCORD_LINK, false);
- }
-
- List popupButtons = new()
- {
- new("buttons.back"),
- new(
- "polymod.hub.discord",
- callback: DelegateSupport.ConvertDelegate(OpenDiscord)
- ),
- new(
- "polymod.hub.config",
- callback: DelegateSupport.ConvertDelegate(ShowConfigPopup)
- )
- };
- if (Plugin.config.debug)
- {
- popupButtons.Add(new(
- "polymod.hub.dump",
- callback: DelegateSupport.ConvertDelegate(DumpData),
- closesPopup: false
- ));
- popupButtons.Add(new(
- "polymod.hub.spriteinfo.update",
- callback: DelegateSupport.ConvertDelegate(UpdateSpriteInfos),
- closesPopup: false
- ));
- }
- popup.buttonData = popupButtons.ToArray();
- popup.ShowSetWidth(POPUP_WIDTH);
- }
-
- ///
- /// Creates the button data for the configuration popup.
- ///
- /// An array of popup button data.
- internal static PopupButtonData[] CreateConfigPopupButtonData()
- {
- List popupButtons = new()
- {
- new(Localization.Get("buttons.back"), PopupButtonData.States.None, DelegateSupport.ConvertDelegate(OnBackButtonClicked), -1, true, null)
- };
-
- if (GameManager.Instance.isLevelLoaded)
- {
- popupButtons.Add(new PopupButtonData(Localization.Get("polymod.hub.spriteinfo.update"), PopupButtonData.States.None, DelegateSupport.ConvertDelegate(OnUpdateSpritesButtonClicked), -1, true, null));
- }
- else
- {
- string debugButtonName = Localization.Get(
- Plugin.config.debug ? "polymod.hub.config.disable" : "polymod.hub.config.enable",
- new Il2CppSystem.Object[] { Localization.Get("polymod.debug",
- new Il2CppSystem.Object[]{}).ToUpperInvariant() }
- );
- string autoUpdateButtonName = Localization.Get(
- Plugin.config.autoUpdate ? "polymod.hub.config.disable" : "polymod.hub.config.enable",
- new Il2CppSystem.Object[] { Localization.Get("polymod.autoupdate",
- new Il2CppSystem.Object[]{}).ToUpperInvariant() }
- );
- string includeAlphasButtonName = Localization.Get(
- Plugin.config.updatePrerelease ? "polymod.hub.config.disable" : "polymod.hub.config.enable",
- new Il2CppSystem.Object[] { Localization.Get("polymod.autoupdate.alpha",
- new Il2CppSystem.Object[]{}).ToUpperInvariant() }
- );
- popupButtons.Add(new PopupButtonData(debugButtonName, PopupButtonData.States.None, DelegateSupport.ConvertDelegate(OnDebugButtonClicked), -1, true, null));
- popupButtons.Add(new PopupButtonData(autoUpdateButtonName, PopupButtonData.States.None, DelegateSupport.ConvertDelegate(OnAutoUpdateButtonClicked), -1, true, null));
- popupButtons.Add(new PopupButtonData(includeAlphasButtonName, Plugin.config.autoUpdate ? PopupButtonData.States.None : PopupButtonData.States.Disabled, DelegateSupport.ConvertDelegate(OnIncludeAlphasButtonClicked), -1, true, null));
- }
- return popupButtons.ToArray();
-
- void OnDebugButtonClicked()
- {
- Plugin.config = new(debug: !Plugin.config.debug, autoUpdate: Plugin.config.autoUpdate, updatePrerelease: Plugin.config.updatePrerelease);
- Plugin.WriteConfig();
- Plugin.UpdateConsole();
- NotificationManager.Notify(Localization.Get(
- "polymod.config.setto",
- new Il2CppSystem.Object[] { Localization.Get("polymod.debug",
- new Il2CppSystem.Object[]{}), Plugin.config.debug }
- ));
- isConfigPopupActive = false;
- }
-
- void OnAutoUpdateButtonClicked()
- {
- Plugin.config = new(debug: Plugin.config.debug, autoUpdate: !Plugin.config.autoUpdate, updatePrerelease: Plugin.config.updatePrerelease);
- Plugin.WriteConfig();
- Plugin.UpdateConsole();
- NotificationManager.Notify(Localization.Get(
- "polymod.config.setto",
- new Il2CppSystem.Object[] { Localization.Get("polymod.autoupdate",
- new Il2CppSystem.Object[]{}), Plugin.config.autoUpdate }
- ));
- isConfigPopupActive = false;
- }
-
- void OnIncludeAlphasButtonClicked()
- {
- Plugin.config = new(debug: Plugin.config.debug, autoUpdate: Plugin.config.autoUpdate, updatePrerelease: !Plugin.config.updatePrerelease);
- Plugin.WriteConfig();
- Plugin.UpdateConsole();
- NotificationManager.Notify(Localization.Get(
- "polymod.config.setto",
- new Il2CppSystem.Object[] { Localization.Get("polymod.autoupdate.alpha",
- new Il2CppSystem.Object[]{}), Plugin.config.updatePrerelease }
- ));
- isConfigPopupActive = false;
- }
-
- void OnUpdateSpritesButtonClicked()
- {
- UpdateSpriteInfos();
- isConfigPopupActive = false;
- }
-
- void OnBackButtonClicked()
- {
- isConfigPopupActive = false;
- }
- }
-
- ///
- /// Initializes the Hub manager by patching the necessary methods.
- ///
- internal static void Init()
- {
- Harmony.CreateAndPatchAll(typeof(Hub));
- }
+ private record Setting(
+ string buttonKey,
+ string nameKey,
+ string descriptionKey,
+ Func isEnabled,
+ Action toggle
+ );
+
+ private const string HEADER_PREFIX = "";
+ private const string HEADER_POSTFIX = "";
+
+ private const string COLOR_MUTED = "#9AA0A6";
+ private const string COLOR_ENABLED = "#4CC76A";
+ private const string COLOR_DISABLED = "#E36A5B";
+ private const string COLOR_WARNING = "#E0A52E";
+ private const float HUB_POPUP_WIDTH_SHARE = 0.78f;
+ private const float HUB_POPUP_MAX_WIDTH = 1400f;
+ private const float SETTINGS_POPUP_WIDTH_SHARE = 0.5f;
+ private const float SETTINGS_POPUP_MAX_WIDTH = 900f;
+ private const float START_SCREEN_MARGIN = 50f;
+ private const float VERSION_LABEL_FONT_SIZE = 18f;
+ private const float VERSION_LABEL_GAP = 10f;
+ /// Margin the game keeps between the safe area and its own corner widgets.
+ private const float VERSION_LABEL_MARGIN = 15f;
+ private const string INTRO = "intro.mp4";
+
+ private const KeyCode HOTKEY_MODIFIER = KeyCode.LeftControl;
+ private const KeyCode HOTKEY_MODIFIER_ALTERNATIVE = KeyCode.RightControl;
+ private const KeyCode HOTKEY = KeyCode.Tab;
+
+ private static readonly Setting[] SETTINGS = new Setting[]
+ {
+ new(
+ "polymod.hub.config.debug",
+ "polymod.debug",
+ "polymod.debug.description",
+ () => Plugin.config.debug,
+ () =>
+ {
+ Plugin.config = Plugin.config with { debug = !Plugin.config.debug };
+ Plugin.UpdateConsole();
+ }
+ ),
+ new(
+ "polymod.hub.config.coordinates",
+ "polymod.coordinates",
+ "polymod.coordinates.description",
+ () => Plugin.config.showCoordinates,
+ () => Plugin.config = Plugin.config with { showCoordinates = !Plugin.config.showCoordinates }
+ ),
+ };
+
+ private static UIRoundButton_UI2? polyModButton = null;
+ private static NotificationBadge? brokenModsBadge = null;
+ private static TextField_UI2? polyModVersion = null;
+
+ private static PopupBase? openPopup = null;
+
+ public static bool IsPopupOpen
+ {
+ get
+ {
+ if (openPopup != null)
+ {
+ try
+ {
+ if (openPopup.IsShowing()) return true;
+ }
+ catch (Exception) {}
+ openPopup = null;
+ }
+ return false;
+ }
+ }
+
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(SplashController), nameof(SplashController.LoadAndPlayClip))]
+ private static bool SplashController_LoadAndPlayClip(SplashController __instance)
+ {
+ string path = Path.Combine(Application.persistentDataPath, INTRO);
+ File.WriteAllBytesAsync(path, Plugin.GetResource(INTRO).ReadBytes());
+ __instance.lastPlayTime = Time.realtimeSinceStartup;
+ __instance.videoPlayer.url = path;
+ __instance.videoPlayer.Play();
+ return false;
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.Init))]
+ private static void StartScreen_UI2_Init(RectTransform transform)
+ {
+ polyModButton = UILibrary.NewRoundButton(transform).SetStyle(UIButtonBase_UI2.ButtonStyle.Suggested);
+ polyModButton.bg.sprite = Visual.BuildSprite(Plugin.GetResource("polymod_icon.png").ReadBytes());
+ polyModButton.OnClickedSignal.Add(Callback(ShowPolyModHub));
+
+ brokenModsBadge = UILibrary.NewNotificationBadge(polyModButton.rectTransform).SetValue(CountBrokenMods());
+ polyModButton.SetOverlay(brokenModsBadge, UIButtonBase_UI2.OverlayPosition.TopRight);
+
+ // NewHeaderTinyText is the prefab a round button builds its own title from, so the label
+ // keeps the game's header font rather than the lighter body font NewText would give it.
+ polyModVersion = UILibrary.NewHeaderTinyText(transform);
+ polyModVersion.gameObject.name = "PolyModVersion";
+ polyModVersion.textField.fontSize = VERSION_LABEL_FONT_SIZE;
+ polyModVersion.textField.color = Color.white;
+ polyModVersion.Text = $"PolyMod {Plugin.VERSION}";
+
+ if (Main.dependencyCycle)
+ ShowDependencyCyclePopup();
+ }
+
+ ///
+ /// Patches the start screen layout to place the PolyMod hub button and the version label.
+ ///
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RunLayout))]
+ private static void StartScreen_UI2_RunLayout(StartScreen_UI2 __instance, ScreenBase_UI2.ScreenSize screenSize)
+ {
+ if (polyModButton == null || polyModVersion == null)
+ {
+ Plugin.logger.LogWarning("PolyMod start screen widgets are missing, skipping their layout.");
+ return;
+ }
+
+ polyModButton.iconContainer.gameObject.SetActive(false);
+ polyModButton.outline.gameObject.SetActive(false);
+ polyModButton.bg.color = Color.white;
+ // Setting the text re-runs the button's own layout, which also repositions the badge.
+ polyModButton.Text = Localization.Get("polymod.hub");
+ polyModButton.SetPosition(
+ screenSize.safeRect.Right - START_SCREEN_MARGIN * 2.5f,
+ screenSize.safeRect.Top - START_SCREEN_MARGIN
+ );
+ brokenModsBadge?.SetValue(CountBrokenMods());
+
+ // Anchored by its edges, not its centre, so a longer version string cannot run off screen.
+ float versionX = screenSize.safeRect.Left + VERSION_LABEL_MARGIN + polyModVersion.GetHalfWidth();
+ float versionY = screenSize.safeRect.Bottom + VERSION_LABEL_MARGIN + polyModVersion.GetHalfHeight();
+ polyModVersion.SetPosition(versionX, versionY);
+
+ // The bottom row of buttons is centred and of fixed width, so a narrow window walks it into
+ // the version label. The row is already laid out by now, so lift the label clear of it.
+ UIRoundButton_UI2 bottomRowStart = __instance.settingsButton;
+ if (bottomRowStart != null
+ && polyModVersion.GetRight() + VERSION_LABEL_GAP > bottomRowStart.GetLeft())
+ {
+ polyModVersion.SetPosition(
+ versionX,
+ bottomRowStart.GetTop() + polyModVersion.GetHalfHeight() + VERSION_LABEL_GAP
+ );
+ }
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(GameManager), nameof(GameManager.Update))]
+ private static void GameManager_Update()
+ {
+ bool modifierHeld = Input.GetKey(HOTKEY_MODIFIER) || Input.GetKey(HOTKEY_MODIFIER_ALTERNATIVE);
+ if (modifierHeld && Input.GetKeyDown(HOTKEY) && !IsPopupOpen)
+ ShowPolyModHub();
+ }
+
+ internal static void ShowPolyModHub()
+ {
+ BasicPopupLegacy popup = Visual.GetBasicPopupLegacy();
+ popup.Header = Localization.Get("polymod.hub");
+ popup.Description = string.Join(
+ "\n\n",
+ Localization.Get("polymod.hub.header", Args(HEADER_PREFIX, HEADER_POSTFIX)),
+ BuildModList(),
+ Localization.Get("polymod.hub.footer", Args(HEADER_PREFIX, HEADER_POSTFIX))
+ );
+
+ void OpenDiscord() => NativeHelpers.OpenURL(Plugin.DISCORD_LINK, false);
+
+ List buttons = new()
+ {
+ new("buttons.back"),
+ new("polymod.hub.discord", callback: Callback(OpenDiscord)),
+ new("polymod.hub.config", callback: Callback(ShowConfigPopup)),
+ };
+ if (Plugin.config.debug)
+ {
+ buttons.Add(new("polymod.hub.dump", callback: Callback(DumpData), closesPopup: false));
+ buttons.Add(new("polymod.hub.hotreload", callback: Callback(HotReload), closesPopup: false));
+ }
+
+ popup.buttonData = buttons.ToArray();
+ openPopup = popup;
+ popup.ShowSetWidth(HUB_POPUP_WIDTH_SHARE, HUB_POPUP_MAX_WIDTH);
+ }
+
+ internal static void ShowConfigPopup()
+ {
+ BasicPopupLegacy popup = Visual.GetBasicPopupLegacy();
+ popup.Header = Localization.Get("polymod.hub.config");
+ popup.Description = BuildSettingsList();
+
+ List buttons = new()
+ {
+ new("buttons.back", callback: Callback(ShowPolyModHub)),
+ };
+ foreach (Setting setting in SETTINGS)
+ {
+ void OnSettingClicked()
+ {
+ setting.toggle();
+ Plugin.WriteConfig();
+ popup.Description = BuildSettingsList();
+ }
+ buttons.Add(new(setting.buttonKey, callback: Callback(OnSettingClicked), closesPopup: false));
+ }
+
+ popup.buttonData = buttons.ToArray();
+ openPopup = popup;
+ popup.ShowSetWidth(SETTINGS_POPUP_WIDTH_SHARE, SETTINGS_POPUP_MAX_WIDTH);
+ }
+
+ private static void ShowDependencyCyclePopup()
+ {
+ BasicPopup popup = PopupManager.GetBasicPopupWithData(
+ new(
+ Localization.Get("polymod.cycle"),
+ Localization.Get("polymod.cycle.description"),
+ new PopupButtonData[]
+ {
+ new(
+ "buttons.exitgame",
+ PopupButtonData.States.None,
+ (Il2CppSystem.Action)Application.Quit,
+ closesPopup: false,
+ customColorStates: ColorConstants.redButtonColorStates
+ ),
+ }
+ )
+ );
+ popup.IsUnskippable = true;
+ popup.Show();
+ }
+
+ private static void DumpData()
+ {
+ Dumper.DumpAll();
+ string path = Dumper.RootPath;
+
+ BasicPopup popup = PopupManager.GetBasicPopup();
+ popup.Header = Localization.Get("polymod.hub.dump");
+ popup.Description = Localization.Get("polymod.hub.dumped", Args(path));
+
+ void OpenDumpFolder()
+ {
+ try
+ {
+ Application.OpenURL(new Uri(path).AbsoluteUri);
+ }
+ catch (Exception exception)
+ {
+ Plugin.logger.LogError($"Could not open the dump folder at {path}: {exception}");
+ }
+ }
+
+ popup.buttonData = new PopupButtonData[]
+ {
+ new("polymod.hub.openfolder", callback: Callback(OpenDumpFolder)),
+ new("buttons.ok"),
+ };
+ popup.Show();
+ }
+
+ private static void HotReload()
+ {
+ BasicPopup popup = PopupManager.GetBasicPopup();
+ popup.Header = Localization.Get("polymod.hub.hotreload");
+ popup.Description = Localization.Get("polymod.hotreload.unavailable");
+ popup.SetDefaultOkbutton();
+ popup.Show();
+ }
+
+ private static string BuildModList()
+ {
+ if (Registry.mods.Count == 0)
+ return Muted(Localization.Get("polymod.hub.mods.none"));
+
+ int loaded = 0;
+ foreach (Mod mod in Registry.mods.Values)
+ {
+ if (mod.status == Mod.Status.Success) loaded++;
+ }
+
+ List blocks = new()
+ {
+ Muted(Localization.Get(
+ "polymod.hub.summary",
+ Args(loaded.ToString(), Registry.mods.Count.ToString())
+ )),
+ };
+ foreach (Mod mod in Registry.mods.Values)
+ {
+ blocks.Add(BuildModEntry(mod));
+ }
+ return string.Join("\n\n", blocks);
+ }
+
+ private static string BuildModEntry(Mod mod)
+ {
+ string status = Localization.Get(
+ "polymod.hub.mod.status." + Enum.GetName(mod.status)!.ToLowerInvariant()
+ );
+ string title = $"{mod.name} " + Muted($"v{mod.version}");
+ string meta = $"{status}";
+ if (mod.authors.Length > 0)
+ {
+ string authors = Localization.Get(
+ "polymod.hub.mod.authors",
+ Args(string.Join(", ", mod.authors))
+ );
+ meta += " " + Muted($"{authors}");
+ }
+
+ List lines = new() { title, meta };
+ if (!string.IsNullOrWhiteSpace(mod.description))
+ lines.Add($"{mod.description}");
+
+ return string.Join("\n", lines);
+ }
+
+ private static string BuildSettingsList()
+ {
+ List blocks = new();
+ foreach (Setting setting in SETTINGS)
+ {
+ bool enabled = setting.isEnabled();
+ string state = Localization.Get(enabled ? "polymod.on" : "polymod.off");
+ string color = enabled ? COLOR_ENABLED : COLOR_DISABLED;
+ blocks.Add(
+ $"{Localization.Get(setting.nameKey)} {state}\n"
+ + Muted($"{Localization.Get(setting.descriptionKey)}")
+ );
+ }
+ return string.Join("\n\n", blocks);
+ }
+
+ private static string Muted(string text) => $"{text}";
+
+ private static string StatusColor(Mod.Status status) => status switch
+ {
+ Mod.Status.Success => COLOR_ENABLED,
+ Mod.Status.DependenciesUnsatisfied => COLOR_WARNING,
+ _ => COLOR_DISABLED,
+ };
+
+ private static int CountBrokenMods()
+ {
+ int broken = 0;
+ foreach (Mod mod in Registry.mods.Values)
+ {
+ if (mod.status != Mod.Status.Success) broken++;
+ }
+ return broken;
+ }
+
+ private static Il2CppSystem.Action Callback(Action callback)
+ => DelegateSupport.ConvertDelegate(callback)!;
+
+ private static Il2CppSystem.Object[] Args(params string[] values)
+ {
+ Il2CppSystem.Object[] arguments = new Il2CppSystem.Object[values.Length];
+ for (int i = 0; i < values.Length; i++)
+ {
+ arguments[i] = values[i];
+ }
+ return arguments;
+ }
+
+ internal static void Init()
+ {
+ Harmony.CreateAndPatchAll(typeof(Hub));
+ }
}
diff --git a/src/Managers/Main.cs b/src/Managers/Main.cs
index f271e77..188544b 100644
--- a/src/Managers/Main.cs
+++ b/src/Managers/Main.cs
@@ -17,432 +17,474 @@ namespace PolyMod.Managers;
///
public static class Main
{
- internal static bool dependencyCycle;
-
- ///
- /// The maximum tier for technology, used to extend the tech tree.
- ///
- internal const int MAX_TECH_TIER = 100;
-
- ///
- /// A stopwatch to measure the time taken to load mods.
- ///
- internal static readonly Stopwatch stopwatch = new();
-
- ///
- /// Whether the mod has been fully initialized.
- ///
- internal static bool fullyInitialized;
-
- ///
- /// A dictionary mapping unit IDs to the IDs of the units they embark into.
- ///
- internal static Dictionary embarkNames = new();
-
- ///
- /// A dictionary mapping unit types to the types of the units they embark into.
- ///
- internal static Dictionary embarkOverrides = new();
-
- ///
- /// Whether an embark action is currently being executed.
- ///
- internal static bool currentlyEmbarking = false;
-
- ///
- /// A dictionary mapping improvement IDs to the IDs of the resources they attract.
- ///
- internal static Dictionary attractsResourceNames = new();
-
- ///
- /// A dictionary mapping improvement IDs to the IDs of the terrain types they attract resources on.
- ///
- internal static Dictionary attractsTerrainNames = new();
-
- ///
- /// A dictionary mapping improvement types to the types of the resources they attract.
- ///
- internal static Dictionary attractsResourceOverrides = new();
-
- ///
- /// A dictionary mapping improvement types to the types of the terrain they attract resources on.
- ///
- internal static Dictionary attractsTerrainOverrides = new();
-
- ///
- /// Patches the game logic data parsing to load PolyMod content.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(GameLogicData), nameof(GameLogicData.AddGameLogicPlaceholders))]
- private static void GameLogicData_AddGameLogicPlaceholders(GameLogicData __instance, ref JObject rootObject)
- {
- if (!fullyInitialized)
- {
- Load(__instance, rootObject);
- fullyInitialized = true;
- }
- }
-
- ///
- /// Patches the purchase manager to unlock custom skins.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.IsSkinUnlocked))]
- private static bool PurchaseManager_IsSkinUnlockedInternal(ref bool __result, SkinType skinType)
- {
- __result = (int)skinType >= Plugin.AUTOIDX_STARTS_FROM && skinType != SkinType.Test;
- return !__result;
- }
-
- ///
- /// Patches the purchase manager to unlock custom tribes.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.IsTribeUnlocked))]
- private static void PurchaseManager_IsTribeUnlocked(ref bool __result, TribeType type)
- {
- __result = (int)type >= Plugin.AUTOIDX_STARTS_FROM || __result;
- }
-
- ///
- /// Patches the steam purchase manager to unlock custom content.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(SteamPlatformPurchaseManager), nameof(SteamPlatformPurchaseManager.IsProductUnlocked))]
- private static bool SteamPlatformPurchaseManager_IsProductUnlocked(ref bool __result, IAPProduct iapProduct)
- {
- __result = iapProduct == null;
- return iapProduct != null;
- }
-
- ///
- /// Patches the purchase manager to add custom tribes to the list of unlocked tribes.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.GetUnlockedTribes))]
- private static void PurchaseManager_GetUnlockedTribes(
- ref Il2CppSystem.Collections.Generic.List __result,
- bool forceUpdate = false
- )
- {
- foreach (var tribe in Registry.customTribes) __result.Add(tribe);
- }
-
- ///
- /// Patches the Unity log callback to filter out spammy messages.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(IL2CPPUnityLogSource), nameof(IL2CPPUnityLogSource.UnityLogCallback))]
- private static bool IL2CPPUnityLogSource_UnityLogCallback(string logLine, string exception, LogType type)
- {
- foreach (string stringToIgnore in Plugin.LOG_MESSAGES_IGNORE)
- {
- if (logLine.Contains(stringToIgnore))
- return false;
- }
- return true;
- }
-
- ///
- /// Patches the game mode screen to add custom game modes.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(GameModeScreen), nameof(GameModeScreen.Init))]
- private static void GameModeScreen_Init(GameModeScreen __instance)
- {
- List list = __instance.buttons.ToList();
- for (int i = 0; i < Loader.gamemodes.Count; i++)
- {
- var item = Loader.gamemodes[i];
- var button = GameObject.Instantiate(__instance.buttons[2]);
- list.Add(button);
- Sprite? sprite = item.sprite;
- if (item.sprite == null && item.spriteName != null)
- {
- sprite = Registry.GetSprite(item.spriteName);
- }
- Loader.gamemodes[i] = new Loader.GameModeButtonsInformation(item.gameModeIndex, item.action, __instance.buttons.Length, sprite, item.spriteName);
- }
-
- var newArray = list.ToArray();
- for (int i = 0; i < __instance.buttons.Length; i++)
- {
- if (newArray[i] != null) newArray[i].OnClicked = __instance.buttons[i].OnClicked;
- }
-
- for (int i = 0; i < Loader.gamemodes.Count; i++)
- {
- if (Loader.gamemodes[i].buttonIndex != null)
- newArray[Loader.gamemodes[i].buttonIndex!.Value].OnClicked = Loader.gamemodes[i].action;
- }
-
- __instance.buttons = newArray;
-
- for (int i = 0; i < __instance.buttons.Length; i++)
- {
- GamemodeButton button = __instance.buttons[i];
- var newData = button.gamemodeData.ToList();
- foreach (var info in Loader.gamemodes)
- {
- string id = EnumCache.GetName((GameMode)info.gameModeIndex).ToLower();
- newData.Add(new GamemodeButton.GamemodeButtonData()
- {
- gameMode = (GameMode)info.gameModeIndex,
- id = System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(id),
- descriptionKey = "gamemode." + id + ".description.button",
- headerKey = "gamemode." + id + ".caps",
- icon = info.sprite
- });
- }
- button.gamemodeData = newData.ToArray();
-
- for (int j = 0; j < Loader.gamemodes.Count; j++)
- {
- Loader.GameModeButtonsInformation info = Loader.gamemodes[j];
-
- if (info.buttonIndex == i)
- {
- button.SetGamemode(info.buttonIndex.Value);
- }
- }
- }
- }
-
- ///
- /// Patches the tech view to correctly display the tech tree with custom technologies.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(TechView), nameof(TechView.CreateNode))]
- public static bool TechView_CreateNode(TechView __instance, TechData data, TechItem parentItem, float angle)
- {
- // This patch is a reimplementation of the original CreateNode method to fix layout issues with custom techs.
- // It recalculates the angles for each node to ensure they are displayed correctly.
- GameLogicData gameLogicData = GameManager.GameState.GameLogicData;
- TribeData tribeData = gameLogicData.GetTribeData(GameManager.LocalPlayer.tribe);
- float baseAngle = 360 / gameLogicData.GetOverride(gameLogicData.GetTechData(TechData.Type.Basic), tribeData).techUnlocks.Count;
- float childAngle = 0f;
- if (parentItem != null)
- childAngle = angle + baseAngle * (data.techUnlocks.Count - 1) / 2f;
- foreach (var techData in data.techUnlocks)
- {
- if (gameLogicData.TryGetData(techData.type, out TechData techData2))
- {
- TechData @override = gameLogicData.GetOverride(techData, tribeData);
- TechItem techItem = __instance.CreateTechItem(@override, parentItem, childAngle);
- __instance.currTechIdx++;
- if (@override.techUnlocks != null && @override.techUnlocks.Count > 0)
- __instance.CreateNode(@override, techItem, childAngle);
- childAngle -= baseAngle;
- }
- }
- Il2CppSystem.Action onItemsRefreshed = __instance.OnItemsRefreshed;
- if (onItemsRefreshed == null)
- {
- return false;
- }
- onItemsRefreshed.Invoke(__instance);
- return false;
- }
-
- ///
- /// Patches the embark action to set a flag indicating that an embark is in progress.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(EmbarkAction), nameof(EmbarkAction.Execute))]
- private static bool EmbarkAction_Execute_Prefix(EmbarkAction __instance, GameState gameState)
- {
- currentlyEmbarking = true;
- return true;
- }
-
- ///
- /// Patches the unit training method to handle custom embark units.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(ActionUtils), nameof(ActionUtils.TrainUnit))]
- private static bool ActionUtils_TrainUnit(ref UnitState __result, GameState gameState, PlayerState playerState, TileData tile, ref UnitData unitData)
- {
- if (tile == null || tile.unit == null)
- {
- return true;
- }
-
- if (currentlyEmbarking)
- {
- if (embarkOverrides.TryGetValue(tile.unit.type, out UnitData.Type newType))
- {
- gameState.GameLogicData.TryGetData(newType, out unitData);
- }
- currentlyEmbarking = false;
- }
- return true;
- }
-
- ///
- /// Patches the start turn action to handle the 'Attract' ability for custom improvements.
- ///
- [HarmonyPostfix]
- [HarmonyPatch(typeof(StartTurnAction), nameof(StartTurnAction.Execute))]
- private static void StartTurnAction_Execute(StartTurnAction __instance, GameState state)
- {
- // Clear any existing CreateResource actions to prevent duplicates.
- for (int i = state.ActionStack.Count - 1; i >= 0; i--)
- {
- if (state.ActionStack[i].GetActionType() == ActionType.CreateResource)
- {
- state.ActionStack.RemoveAt(i);
- }
- }
-
- // Iterate through all tiles owned by the current player.
- for (int i = 0; i < state.Map.Tiles.Length; i++)
- {
- TileData tileData = state.Map.Tiles[i];
- if (tileData.owner == __instance.PlayerId && tileData.improvement != null && state.CurrentTurn > 0U)
- {
- ImprovementData improvementData;
- state.GameLogicData.TryGetData(tileData.improvement.type, out improvementData);
- if (improvementData != null)
- {
- // If the improvement has the 'Attract' ability and is ready to spawn a resource.
- if (improvementData.HasAbility(ImprovementAbility.Type.Attract) && tileData.improvement.GetAge(state) % improvementData.growthRate == 0)
- {
- ResourceData.Type resourceType = ResourceData.Type.Game;
- if (attractsResourceOverrides.TryGetValue(tileData.improvement.type, out ResourceData.Type newType))
- {
- resourceType = newType;
- }
- Polytopia.Data.TerrainData.Type targetTerrain = Polytopia.Data.TerrainData.Type.Forest;
- if (attractsTerrainOverrides.TryGetValue(tileData.improvement.type, out Polytopia.Data.TerrainData.Type newTerrain))
- {
- targetTerrain = newTerrain;
- }
-
- // Find a valid tile to spawn the resource on.
- foreach (TileData tileData2 in state.Map.GetArea(tileData.coordinates, 1, true, false))
- {
- if (tileData2.owner == __instance.PlayerId && tileData2.improvement == null && tileData2.resource == null && tileData2.terrain == targetTerrain)
- {
- state.ActionStack.Add(new CreateResourceAction(__instance.PlayerId, resourceType, tileData2.coordinates, CreateResourceAction.CreateReason.Attract));
- break;
- }
- }
- }
- }
- }
- }
- }
-
- ///
- /// Patches the unit creation method to handle custom unit prefabs.
- ///
- [HarmonyPrefix]
- [HarmonyPatch(typeof(Unit), nameof(Unit.CreateUnit))]
- private static bool Unit_CreateUnit(Unit __instance, UnitData unitData, TribeType tribe, SkinType unitSkin)
- {
- Unit unit = PrefabManager.GetPrefab(unitData.type, tribe, unitSkin);
- if (unit == null) Console.Write("THIS FUCKING SHIT IS NULL WHAT THE FUCK");
- return true;
- }
-
- ///
- /// Initializes the Main manager.
- ///
- internal static void Init()
- {
- stopwatch.Start();
- Harmony.CreateAndPatchAll(typeof(Main));
- Mod.Manifest polytopia = new(
- "polytopia",
- "The Battle of Polytopia",
- null,
- new(Application.version.ToString()),
- new string[] { "Midjiwan AB" },
- Array.Empty()
- );
- Registry.mods.Add(polytopia.id, new(polytopia, Mod.Status.Success, new()));
- Loader.RegisterMods(Registry.mods);
- Loader.LoadMods(Registry.mods, out var cycle);
- dependencyCycle = cycle;
- stopwatch.Stop();
- }
-
- ///
- /// Loads all mod content.
- ///
- /// The game logic data to patch.
- /// The JSON object representing the game logic data.
- internal static void Load(GameLogicData gameLogicData, JObject json)
- {
- stopwatch.Start();
- Loc.BuildAndLoadLocalization(
- JsonSerializer.Deserialize>>(
- Plugin.GetResource("localization.json")
- )!
- );
- if (dependencyCycle) return;
-
- foreach (var (id, mod) in Registry.mods)
- {
- if (mod.status != Mod.Status.Success) continue;
- foreach (var file in mod.files)
- {
- if (mod.status != Mod.Status.Success) break;
- Match localizationMatch = Regex.Match(Path.GetFileName(file.name), @"^localization(_.*)?\.json$");
- if (localizationMatch.Success)
- {
- Loader.LoadLocalizationFile(mod, file);
- continue;
- }
- Match patchMatch = Regex.Match(Path.GetFileName(file.name), @"^patch(_.*)?\.json$");
- if (patchMatch.Success)
- {
- var patchText = new StreamReader(new MemoryStream(file.bytes)).ReadToEnd();
- var template = new Api.GldConfigTemplate(patchText, mod.id);
- var text = template.Render();
- if (text is null)
- {
- mod.status = Mod.Status.Error;
- continue;
- }
- Loader.LoadGameLogicDataPatch(
- mod,
- json,
- JObject.Parse(text)
- );
- continue;
- }
- Match prefabMatch = Regex.Match(Path.GetFileName(file.name), @"^prefab(?:_(.*))?\.json$");
- if (prefabMatch.Success)
- {
- Loader.LoadPrefabInfoFile(
- mod,
- file
- );
- continue;
- }
-
- switch (Path.GetExtension(file.name))
- {
- case ".png":
- Loader.LoadSpriteFile(mod, file);
- break;
- case ".wav":
- Loader.LoadAudioFile(mod, file);
- break;
- case ".bundle":
- Loader.LoadAssetBundle(mod, file);
- break;
- }
- }
- }
- TechItem.techTierFirebaseId.Clear();
- for (int i = 0; i <= MAX_TECH_TIER; i++)
- {
- TechItem.techTierFirebaseId.Add($"tech_research_{i}");
- }
- Loader.ProcessGameLogicData(gameLogicData, json);
- stopwatch.Stop();
- Plugin.logger.LogInfo($"Loaded all mods in {stopwatch.ElapsedMilliseconds}ms");
- }
+ internal static bool dependencyCycle;
+
+ ///
+ /// The maximum tier for technology, used to extend the tech tree.
+ ///
+ internal const int MAX_TECH_TIER = 100;
+
+ ///
+ /// A stopwatch to measure the time taken to load mods.
+ ///
+ internal static readonly Stopwatch stopwatch = new();
+
+ ///
+ /// Whether the mod has been fully initialized.
+ ///
+ internal static bool fullyInitialized;
+
+ ///
+ /// A dictionary mapping unit IDs to the IDs of the units they embark into.
+ ///
+ internal static Dictionary embarkNames = new();
+
+ ///
+ /// A dictionary mapping unit types to the types of the units they embark into.
+ ///
+ internal static Dictionary embarkOverrides = new();
+
+ ///
+ /// Whether an embark action is currently being executed.
+ ///
+ internal static bool currentlyEmbarking = false;
+
+ ///
+ /// A dictionary mapping improvement IDs to the IDs of the resources they attract.
+ ///
+ internal static Dictionary attractsResourceNames = new();
+
+ ///
+ /// A dictionary mapping improvement IDs to the IDs of the terrain types they attract resources on.
+ ///
+ internal static Dictionary attractsTerrainNames = new();
+
+ ///
+ /// A dictionary mapping improvement types to the types of the resources they attract.
+ ///
+ internal static Dictionary attractsResourceOverrides = new();
+
+ ///
+ /// A dictionary mapping improvement types to the types of the terrain they attract resources on.
+ ///
+ internal static Dictionary attractsTerrainOverrides = new();
+
+ ///
+ /// Patches the game logic data parsing to load PolyMod content.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(GameLogicData), nameof(GameLogicData.AddGameLogicPlaceholders))]
+ private static void GameLogicData_AddGameLogicPlaceholders(GameLogicData __instance, ref JObject rootObject)
+ {
+ if (!fullyInitialized)
+ {
+ Load(__instance, rootObject);
+ fullyInitialized = true;
+ }
+ }
+
+ ///
+ /// Patches the purchase manager to unlock custom skins.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.IsSkinUnlocked))]
+ private static bool PurchaseManager_IsSkinUnlockedInternal(ref bool __result, SkinType skinType)
+ {
+ __result = (int)skinType >= Plugin.AUTOIDX_STARTS_FROM && skinType != SkinType.Test;
+ return !__result;
+ }
+
+ ///
+ /// Patches the purchase manager to unlock custom tribes.
+ ///
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.IsTribeUnlocked))]
+ private static void PurchaseManager_IsTribeUnlocked(ref bool __result, TribeType type)
+ {
+ __result = (int)type >= Plugin.AUTOIDX_STARTS_FROM || __result;
+ }
+
+ ///
+ /// Patches the steam purchase manager to unlock custom content.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(SteamPlatformPurchaseManager), nameof(SteamPlatformPurchaseManager.IsProductUnlocked))]
+ private static bool SteamPlatformPurchaseManager_IsProductUnlocked(ref bool __result, IAPProduct iapProduct)
+ {
+ __result = iapProduct == null;
+ return iapProduct != null;
+ }
+
+ ///
+ /// Patches the purchase manager to add custom tribes to the list of unlocked tribes.
+ ///
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.GetUnlockedTribes))]
+ private static void PurchaseManager_GetUnlockedTribes(
+ ref Il2CppSystem.Collections.Generic.List __result,
+ bool forceUpdate = false
+ )
+ {
+ foreach (var tribe in Registry.customTribes) __result.Add(tribe);
+ }
+
+ ///
+ /// Patches the Unity log callback to filter out spammy messages.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(IL2CPPUnityLogSource), nameof(IL2CPPUnityLogSource.UnityLogCallback))]
+ private static bool IL2CPPUnityLogSource_UnityLogCallback(string logLine, string exception, LogType type)
+ {
+ foreach (string stringToIgnore in Plugin.LOG_MESSAGES_IGNORE)
+ {
+ if (logLine.Contains(stringToIgnore))
+ return false;
+ }
+ return true;
+ }
+
+ ///
+ /// Patches the game mode screen to add custom game modes.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(GameModeScreen), nameof(GameModeScreen.Init))]
+ private static void GameModeScreen_Init(GameModeScreen __instance)
+ {
+ List list = __instance.buttons.ToList();
+ for (int i = 0; i < Loader.gamemodes.Count; i++)
+ {
+ var item = Loader.gamemodes[i];
+ var button = GameObject.Instantiate(__instance.buttons[2]);
+ list.Add(button);
+ Sprite? sprite = item.sprite;
+ if (item.sprite == null && item.spriteName != null)
+ {
+ sprite = Registry.GetSprite(item.spriteName);
+ }
+ Loader.gamemodes[i] = new Loader.GameModeButtonsInformation(item.gameModeIndex, item.action, __instance.buttons.Length, sprite, item.spriteName);
+ }
+
+ var newArray = list.ToArray();
+ for (int i = 0; i < __instance.buttons.Length; i++)
+ {
+ if (newArray[i] != null) newArray[i].OnClicked = __instance.buttons[i].OnClicked;
+ }
+
+ for (int i = 0; i < Loader.gamemodes.Count; i++)
+ {
+ if (Loader.gamemodes[i].buttonIndex != null)
+ newArray[Loader.gamemodes[i].buttonIndex!.Value].OnClicked = Loader.gamemodes[i].action;
+ }
+
+ __instance.buttons = newArray;
+
+ for (int i = 0; i < __instance.buttons.Length; i++)
+ {
+ GamemodeButton button = __instance.buttons[i];
+ var newData = button.gamemodeData.ToList();
+ foreach (var info in Loader.gamemodes)
+ {
+ string id = EnumCache.GetName((GameMode)info.gameModeIndex).ToLower();
+ newData.Add(new GamemodeButton.GamemodeButtonData()
+ {
+ gameMode = (GameMode)info.gameModeIndex,
+ id = System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(id),
+ descriptionKey = "gamemode." + id + ".description.button",
+ headerKey = "gamemode." + id + ".caps",
+ icon = info.sprite
+ });
+ }
+ button.gamemodeData = newData.ToArray();
+
+ for (int j = 0; j < Loader.gamemodes.Count; j++)
+ {
+ Loader.GameModeButtonsInformation info = Loader.gamemodes[j];
+
+ if (info.buttonIndex == i)
+ {
+ button.SetGamemode(info.buttonIndex.Value);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Patches the tech view to correctly display the tech tree with custom technologies.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(TechView), nameof(TechView.CreateNode))]
+ public static bool TechView_CreateNode(TechView __instance, TechData data, TechItem parentItem, float angle)
+ {
+ // This patch is a reimplementation of the original CreateNode method to fix layout issues with custom techs.
+ // It recalculates the angles for each node to ensure they are displayed correctly.
+ GameLogicData gameLogicData = GameManager.GameState.GameLogicData;
+ TribeData tribeData = gameLogicData.GetTribeData(GameManager.LocalPlayer.tribe);
+ float baseAngle = 360 / gameLogicData.GetOverride(gameLogicData.GetTechData(TechData.Type.Basic), tribeData).techUnlocks.Count;
+ float childAngle = 0f;
+ if (parentItem != null)
+ childAngle = angle + baseAngle * (data.techUnlocks.Count - 1) / 2f;
+ foreach (var techData in data.techUnlocks)
+ {
+ if (gameLogicData.TryGetData(techData.type, out TechData techData2))
+ {
+ TechData @override = gameLogicData.GetOverride(techData, tribeData);
+ TechItem techItem = __instance.CreateTechItem(@override, parentItem, childAngle);
+ __instance.currTechIdx++;
+ if (@override.techUnlocks != null && @override.techUnlocks.Count > 0)
+ __instance.CreateNode(@override, techItem, childAngle);
+ childAngle -= baseAngle;
+ }
+ }
+ Il2CppSystem.Action onItemsRefreshed = __instance.OnItemsRefreshed;
+ if (onItemsRefreshed == null)
+ {
+ return false;
+ }
+ onItemsRefreshed.Invoke(__instance);
+ return false;
+ }
+
+ ///
+ /// Patches the embark action to set a flag indicating that an embark is in progress.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(EmbarkAction), nameof(EmbarkAction.Execute))]
+ private static bool EmbarkAction_Execute_Prefix(EmbarkAction __instance, GameState gameState)
+ {
+ currentlyEmbarking = true;
+ return true;
+ }
+
+ ///
+ /// Patches the unit training method to handle custom embark units.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(ActionUtils), nameof(ActionUtils.TrainUnit))]
+ private static bool ActionUtils_TrainUnit(ref UnitState __result, GameState gameState, PlayerState playerState, TileData tile, ref UnitData unitData)
+ {
+ if (tile == null || tile.unit == null)
+ {
+ return true;
+ }
+
+ if (currentlyEmbarking)
+ {
+ if (embarkOverrides.TryGetValue(tile.unit.type, out UnitData.Type newType))
+ {
+ gameState.GameLogicData.TryGetData(newType, out unitData);
+ }
+ currentlyEmbarking = false;
+ }
+ return true;
+ }
+
+ ///
+ /// Patches the start turn action to handle the 'Attract' ability for custom improvements.
+ ///
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(StartTurnAction), nameof(StartTurnAction.Execute))]
+ private static void StartTurnAction_Execute(StartTurnAction __instance, GameState state)
+ {
+ // Clear any existing CreateResource actions to prevent duplicates.
+ for (int i = state.ActionStack.Count - 1; i >= 0; i--)
+ {
+ if (state.ActionStack[i].GetActionType() == ActionType.CreateResource)
+ {
+ state.ActionStack.RemoveAt(i);
+ }
+ }
+
+ // Iterate through all tiles owned by the current player.
+ for (int i = 0; i < state.Map.Tiles.Length; i++)
+ {
+ TileData tileData = state.Map.Tiles[i];
+ if (tileData.owner == __instance.PlayerId && tileData.improvement != null && state.CurrentTurn > 0U)
+ {
+ ImprovementData improvementData;
+ state.GameLogicData.TryGetData(tileData.improvement.type, out improvementData);
+ if (improvementData != null)
+ {
+ // If the improvement has the 'Attract' ability and is ready to spawn a resource.
+ if (improvementData.HasAbility(ImprovementAbility.Type.Attract) && tileData.improvement.GetAge(state) % improvementData.growthRate == 0)
+ {
+ ResourceData.Type resourceType = ResourceData.Type.Game;
+ if (attractsResourceOverrides.TryGetValue(tileData.improvement.type, out ResourceData.Type newType))
+ {
+ resourceType = newType;
+ }
+ Polytopia.Data.TerrainData.Type targetTerrain = Polytopia.Data.TerrainData.Type.Forest;
+ if (attractsTerrainOverrides.TryGetValue(tileData.improvement.type, out Polytopia.Data.TerrainData.Type newTerrain))
+ {
+ targetTerrain = newTerrain;
+ }
+
+ // Find a valid tile to spawn the resource on.
+ foreach (TileData tileData2 in state.Map.GetArea(tileData.coordinates, 1, true, false))
+ {
+ if (tileData2.owner == __instance.PlayerId && tileData2.improvement == null && tileData2.resource == null && tileData2.terrain == targetTerrain)
+ {
+ state.ActionStack.Add(new CreateResourceAction(__instance.PlayerId, resourceType, tileData2.coordinates, CreateResourceAction.CreateReason.Attract));
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Serialization fixes for custom (modded) content.
+ /// GamePlayerSummary stores the tribe as a single byte, which custom tribe ids (>= 1000) overflow into garbage.
+ /// Instead the tribe is clamped to None before the untouched native serializer runs and menu summaries show a generic icon for custom tribes, nothing more.
+ ///
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(GamePlayerSummary), nameof(GamePlayerSummary.Serialize))]
+ private static bool GamePlayerSummary_Serialize(GamePlayerSummary __instance)
+ {
+ if ((int)__instance.TribeType >= byte.MaxValue)
+ {
+ __instance.TribeType = TribeType.None;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Serialization fixes for custom (modded) content.
+ /// PlayerState stores the play climate as a single byte, which custom climate ids (>= 1000) overflow into garbage.
+ /// Instead the climate is overriden by the tribe value, as those are effectively the same.
+ ///
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(PlayerState), nameof(PlayerState.Deserialize))]
+ private static void PlayerState_Deserialize(PlayerState __instance, Il2CppSystem.IO.BinaryReader reader, int version)
+ {
+ if ((int)__instance.tribe >= Plugin.AUTOIDX_STARTS_FROM)
+ {
+ __instance.climate = __instance.tribe;
+ }
+ }
+
+ ///
+ /// Initializes the Main manager.
+ ///
+ internal static void Init()
+ {
+ stopwatch.Start();
+ Harmony.CreateAndPatchAll(typeof(Main));
+ Mod.Manifest polytopia = new(
+ "polytopia",
+ "The Battle of Polytopia",
+ null,
+ new(Application.version.ToString()),
+ new string[] { "Midjiwan AB" },
+ Array.Empty()
+ );
+ Registry.mods.Add(polytopia.id, new(polytopia, Mod.Status.Success, new()));
+ Loader.RegisterMods(Registry.mods);
+ Loader.LoadMods(Registry.mods, out var cycle);
+ dependencyCycle = cycle;
+ stopwatch.Stop();
+ }
+
+ ///
+ /// Loads all mod content.
+ ///
+ /// The game logic data to patch.
+ /// The JSON object representing the game logic data.
+ internal static void Load(GameLogicData gameLogicData, JObject json)
+ {
+ stopwatch.Start();
+ Loc.BuildAndLoadLocalization(
+ JsonSerializer.Deserialize>>(
+ Plugin.GetResource("localization.json")
+ )!
+ );
+ if (dependencyCycle) return;
+
+ foreach (var (id, mod) in Registry.mods)
+ {
+ if (mod.status != Mod.Status.Success) continue;
+ foreach (var file in mod.files)
+ {
+ if (mod.status != Mod.Status.Success) break;
+ Match localizationMatch = Regex.Match(Path.GetFileName(file.name), @"^localization(_.*)?\.json$");
+ if (localizationMatch.Success)
+ {
+ Loader.LoadLocalizationFile(mod, file);
+ continue;
+ }
+ Match patchMatch = Regex.Match(Path.GetFileName(file.name), @"^patch(_.*)?\.json$");
+ if (patchMatch.Success)
+ {
+ var patchText = new StreamReader(new MemoryStream(file.bytes)).ReadToEnd();
+ var template = new Api.GldConfigTemplate(patchText, mod.id);
+ var text = template.Render();
+ if (text is null)
+ {
+ mod.status = Mod.Status.Error;
+ continue;
+ }
+ Loader.LoadGameLogicDataPatch(
+ mod,
+ json,
+ JObject.Parse(text)
+ );
+ continue;
+ }
+
+ Match prefabsMatch = Regex.Match(Path.GetFileName(file.name), @"^prefabs(?:_(.*))?\.json$");
+ if (prefabsMatch.Success)
+ {
+ Registry.filesQueueToBeProcessedAfterGLDMerge.Add(mod, file);
+ continue;
+ }
+
+ Match unitPrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^unitPrefab(?:_(.*))?\.json$");
+ if (unitPrefabMatch.Success)
+ {
+ Registry.filesQueueToBeProcessedAfterGLDMerge.Add(mod, file);
+ continue;
+ }
+
+ Match improvementPrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^improvementPrefab(?:_(.*))?\.json$");
+ if (improvementPrefabMatch.Success)
+ {
+ Registry.filesQueueToBeProcessedAfterGLDMerge.Add(mod, file);
+ continue;
+ }
+
+ Match resourcePrefabMatch = Regex.Match(Path.GetFileName(file.name), @"^resourcePrefab(?:_(.*))?\.json$");
+ if (resourcePrefabMatch.Success)
+ {
+ Registry.filesQueueToBeProcessedAfterGLDMerge.Add(mod, file);
+ continue;
+ }
+
+ switch (Path.GetExtension(file.name))
+ {
+ case ".png":
+ Loader.LoadTextureFile(mod, file);
+ break;
+ case ".wav":
+ Loader.LoadAudioFile(mod, file);
+ break;
+ case ".bundle":
+ Loader.LoadAssetBundle(mod, file);
+ break;
+ }
+ }
+ }
+ TechItem.techTierFirebaseId.Clear();
+ for (int i = 0; i <= MAX_TECH_TIER; i++)
+ {
+ TechItem.techTierFirebaseId.Add($"tech_research_{i}");
+ }
+ Loader.ProcessGameLogicData(gameLogicData, json);
+ Loader.ProcessFilesQueue();
+ Visual.CreateSprites();
+ Prefabs.CreatePrefabs();
+ stopwatch.Stop();
+ Plugin.logger.LogInfo($"Loaded all mods in {stopwatch.ElapsedMilliseconds}ms");
+ }
}
diff --git a/src/Managers/Prefabs.cs b/src/Managers/Prefabs.cs
new file mode 100644
index 0000000..3295f59
--- /dev/null
+++ b/src/Managers/Prefabs.cs
@@ -0,0 +1,654 @@
+using HarmonyLib;
+using Il2CppInterop.Runtime;
+using Polytopia.Data;
+using PolytopiaBackendBase.Common;
+using UnityEngine;
+using static PrefabManager;
+using static SkinVisualsReference;
+
+namespace PolyMod.Managers;
+
+public static class Prefabs
+{
+ internal static Dictionary unitsNew = new();
+ internal static Dictionary> unitOverridesNew = new(); // maybe in the future will implement
+ internal static Dictionary unitUIOverridesNew = new();
+
+ internal static Material? cachedUnitMaterial;
+ internal static Material? cachedSharedMaterial;
+ internal const string CACHED_SHARED_MATERIAL_NAME = "SpriteMaterial";
+
+ public static List unitInfos = new();
+
+ public static List resourceInfos = new();
+
+ public static List improvementInfos = new();
+ internal static Dictionary improvementPrefabInfosToCreate = new();
+ internal static List resourcePrefabsInfosToCreate = new();
+
+ private static List weaponInfos = new();
+ private static Dictionary weaponProjectilesCache = new();
+ private static Dictionary weaponAnimTimeCache = new()
+ {
+ {UnitData.WeaponEnum.Sting, 0.5f},
+ {UnitData.WeaponEnum.IceArrow, 1f},
+ {UnitData.WeaponEnum.Gun, 1.5f}, // __instance.Sway(); __instance.Tile.SpawnDarkPuff();
+ {UnitData.WeaponEnum.FireBlow, 2f}, // __instance.Tile.SpawnDarkPuff();
+ {UnitData.WeaponEnum.Poison, 2f}, // __instance.Tile.SpawnPoison(__instance.Owner.GetVisualSkin());
+ {UnitData.WeaponEnum.Pierce, 1f},
+ {UnitData.WeaponEnum.Water, 1.5f}, // __instance.Sway(); __instance.Tile.SpawnPuff();
+ };
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(PrefabManager), nameof(PrefabManager.Awake))]
+ private static void PrefabManager_Awake_Postfix(PrefabManager __instance)
+ {
+ unitsNew.Clear();
+ unitOverridesNew.Clear();
+ unitUIOverridesNew.Clear();
+
+ foreach (UnitPrefabData unitPrefabData in __instance.unitPrefabs)
+ {
+ if(cachedUnitMaterial == null)
+ {
+ SkinVisualsReference skinVisualsReference = unitPrefabData.prefab.GetComponent();
+ if (skinVisualsReference != null && skinVisualsReference.visualParts != null)
+ {
+ foreach (VisualPart currentPart in skinVisualsReference.visualParts)
+ {
+ if (currentPart.visualPart == null)
+ {
+ continue;
+ }
+
+ SpriteRenderer headSpriteRenderer = currentPart.visualPart.GetComponent();
+ if (headSpriteRenderer != null)
+ {
+ cachedUnitMaterial = headSpriteRenderer.sharedMaterial;
+ break;
+ }
+ }
+ }
+ }
+ unitsNew[new SkinnedKey(unitPrefabData.type, unitPrefabData.tribe, unitPrefabData.skin)] = unitPrefabData.prefab;
+ }
+
+ foreach (UnitOverridePrefabData unitOverridePrefabData in __instance.unitOverridePrefabs)
+ {
+ if (!unitOverridesNew.TryGetValue(unitOverridePrefabData.overrideCondition, out Dictionary? overridesForCondition))
+ {
+ overridesForCondition = new Dictionary();
+ unitOverridesNew[unitOverridePrefabData.overrideCondition] = overridesForCondition;
+ }
+ overridesForCondition[unitOverridePrefabData.type] = unitOverridePrefabData.prefab;
+ }
+
+ foreach (UnitPrefabData unitUIOverridePrefabData in __instance.unitUIOverridePrefabs)
+ {
+ unitUIOverridesNew[new SkinnedKey(unitUIOverridePrefabData.type, unitUIOverridePrefabData.tribe, unitUIOverridePrefabData.skin)] = unitUIOverridePrefabData.prefab;
+ }
+
+ Plugin.logger.LogInfo($"Built lookups: {unitsNew.Count} units, {unitUIOverridesNew.Count} UI overrides");
+ }
+
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(PrefabManager), nameof(PrefabManager.HasUIUnitOverride))]
+ public static bool HasUIUnitOverride(ref bool __result, UnitData.Type unitType, TribeType tribe, SkinType skin)
+ {
+ __result = unitUIOverridesNew.ContainsKey(new SkinnedKey(unitType, tribe, skin));
+ return false;
+ }
+
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(PrefabManager), nameof(PrefabManager.GetUnitOrUiOverride))]
+ public static bool GetUnitOrUiOverride(ref Unit __result, UnitData.Type unitType, TribeType tribe, SkinType skin)
+ {
+ if (unitUIOverridesNew.TryGetValue(new SkinnedKey(unitType, tribe, skin), out Unit? uiOverridePrefab))
+ {
+ __result = uiOverridePrefab;
+ }
+ else
+ {
+ __result = ResolveUnitPrefab(unitType, tribe, skin);
+ }
+ return false;
+ }
+
+ [HarmonyPrefix]
+ [HarmonyPatch(typeof(PrefabManager), nameof(PrefabManager.GetPrefab), new Type[] { typeof(UnitData.Type), typeof(TribeType), typeof(SkinType) })]
+ public static bool PrefabManager_GetPrefab(ref Unit __result, UnitData.Type type, TribeType tribe, SkinType skin)
+ {
+ __result = ResolveUnitPrefab(type, tribe, skin);
+ return false;
+ }
+
+ [HarmonyPostfix] // prolly remove later
+ [HarmonyPatch(typeof(SkinVisualsReference), nameof(SkinVisualsReference.SetupVisualPart))]
+ public static void SetupVisualPart(ref VisualPart visualSetting, bool isImprovement)
+ {
+ bool flag = false;
+ flag = visualSetting.outline != null && visualSetting.outline.transform.parent == visualSetting.visualPart.transform;
+ if (!flag && visualSetting.visualPart.transform.childCount > 0)
+ {
+ visualSetting.outline = visualSetting.visualPart.transform.GetChild(0).gameObject;
+ flag = true;
+ }
+ bool flag2 = false;
+ if (flag)
+ {
+ string defaultSpriteName = visualSetting.DefaultSpriteName;
+ if (visualSetting.outline != null && visualSetting.outline.name == defaultSpriteName + "_Outline")
+ {
+ flag2 = true;
+ }
+ }
+ visualSetting.hasValidOutline = flag2;
+ }
+
+ // [HarmonyPrefix] // overrides again.......
+ // [HarmonyPatch(typeof(Unit), nameof(Unit.IsProjectile))]
+ // private static bool Unit_IsProjectile(ref bool __result, UnitData.WeaponEnum weapon)
+ // {
+ // if(!weaponProjectilesCache.ContainsKey(weapon))
+ // return true;
+
+ // __result = weaponProjectilesCache[weapon];
+ // return false;
+ // }
+
+ // [HarmonyPrefix] // overrides again.......
+ // [HarmonyPatch(typeof(Unit), nameof(Unit.Attack))]
+ // private static bool Unit_Attack(Unit __instance, WorldCoordinates target, bool moveToTarget, Action onComplete)
+ // {
+ // UnitData.WeaponEnum weaponEnum = __instance.unitData.weapon;
+ // List weaponPrefabInfos = weaponInfos.Where(weaponInfo => weaponInfo.weaponEnum == weaponEnum).ToList();
+ // if(weaponPrefabInfos.Count == 0)
+ // return true;
+
+ // WeaponPrefabInfo weaponPrefabInfo = weaponPrefabInfos.Last();
+ // float chebyshevDistance = MapDataExtensions.ChebyshevDistance(__instance.Tile.Coordinates, target);
+
+ // float animationTime = 0.2f + chebyshevDistance * 0.05f;
+ // Tile targetTile = MapRenderer.Current.GetTileInstance(target);
+
+ // WeaponGFX pooledObject = ObjectPool.GetPooledObject(GetPrefab(weaponEnum).gameObject);
+ // pooledObject.IsUsed = true;
+ // Vector3 center = SkinVisualsRenderer.GetSpriteBounds(__instance.skinVisuals).center;
+ // pooledObject.transform.position = __instance.transform.position + center;
+ // pooledObject.SetSkin(__instance.Owner.GetVisualSkin());
+
+ // if ((bool)targetTile.Unit)
+ // {
+ // targetTile.Unit.Flipped = targetTile.Unit.UnitState.flipped;
+ // }
+ // __instance.Flipped = __instance.UnitState.flipped;
+
+ // if(weaponPrefabInfo.weaponPuffInfo != null)
+ // {
+ // animationTime *= weaponPrefabInfo.weaponPuffInfo.animationMultiplier;
+ // if(weaponPrefabInfo.weaponPuffInfo.sway)
+ // __instance.Sway();
+ // }
+ // if (Unit.IsProjectile(weaponEnum))
+ // {
+ // if(weaponPrefabInfo.weaponPuffInfo != null && weaponPrefabInfo.weaponPuffInfo!.animationDelegate != null)
+ // pooledObject.AnimateRanged(animationTime, target, DelegateSupport.ConvertDelegate(weaponPrefabInfo.weaponPuffInfo!.animationDelegate!));
+ // else
+ // pooledObject.AnimateRanged(animationTime, target);
+ // }
+ // else
+ // {
+ // pooledObject.AnimateCloseRangeAttack(0.2f);
+ // }
+
+ // if (__instance.unitData.GetRange() < 2)
+ // {
+ // __instance.AnimateMeleeAttackMovement(targetTile, moveToTarget, onComplete);
+ // }
+ // else
+ // {
+ // GameManager.DelayCall((int)(animationTime * 1000f), onComplete);
+ // }
+
+ // return false;
+ // }
+
+ // [HarmonyPrefix] // testing.......
+ // [HarmonyPatch(typeof(WeaponGFX), nameof(WeaponGFX.AnimateCloseRangeAttack))]
+ // public static bool WeaponGFX_AnimateCloseRangeAttack(ref float animTime, Action onComplete)
+ // {
+ // animTime = 100000f;
+ // return true;
+ // }
+
+ // [HarmonyPrefix] // testing.......
+ // [HarmonyPatch(typeof(WeaponGFX), nameof(WeaponGFX.AnimateRanged))]
+ // public static bool WeaponGFX_AnimateRanged(ref float animTime, WorldCoordinates to, Action onComplete)
+ // {
+ // animTime = 100000f;
+ // return true;
+ // }
+
+ private static Unit ResolveUnitPrefab(UnitData.Type unitType, TribeType tribeType, SkinType skinType)
+ {
+ if (SeasonManager.IsChristmas()
+ && unitOverridesNew.TryGetValue(OverrideCondition.Christmas, out Dictionary? christmasOverrides)
+ && christmasOverrides.TryGetValue(unitType, out Unit? christmasPrefab)
+ && christmasPrefab != null)
+ {
+ return christmasPrefab;
+ }
+
+ SkinnedKey exactKey = new SkinnedKey(unitType, tribeType, skinType);
+ if (unitsNew.TryGetValue(exactKey, out Unit? exactPrefab))
+ {
+ return exactPrefab;
+ }
+
+ if (skinType != SkinType.Default || tribeType != TribeType.None)
+ {
+ SkinnedKey noTribeKey = new SkinnedKey(unitType, TribeType.None, skinType);
+ SkinnedKey defaultSkinKey = new SkinnedKey(unitType, tribeType, SkinType.Default);
+ SkinnedKey noTribeDefaultSkinKey = new SkinnedKey(unitType, TribeType.None, SkinType.Default);
+
+ if (unitsNew.TryGetValue(noTribeKey, out Unit? fallbackPrefab)
+ || unitsNew.TryGetValue(defaultSkinKey, out fallbackPrefab)
+ || unitsNew.TryGetValue(noTribeDefaultSkinKey, out fallbackPrefab))
+ {
+ unitsNew[exactKey] = fallbackPrefab;
+ return fallbackPrefab;
+ }
+ }
+
+ Plugin.logger.LogInfo($"Couldn't find prefab for type: {unitType}");
+ unitsNew.TryGetValue(new SkinnedKey(default, TribeType.None, SkinType.Default), out Unit? lastResortPrefab);
+ return lastResortPrefab!;
+ }
+
+ internal static void CreatePrefabs()
+ {
+ // get materials
+ Material[] allLoadedMaterials = Resources.FindObjectsOfTypeAll();
+ foreach (Material loadedMaterial in allLoadedMaterials)
+ {
+ // Plugin.logger.LogInfo($"Material: {loadedMaterial.name}, shader: {loadedMaterial.shader.name}");
+
+ if(loadedMaterial.name == CACHED_SHARED_MATERIAL_NAME)
+ {
+ cachedSharedMaterial = loadedMaterial;
+ GameObject.DontDestroyOnLoad(cachedSharedMaterial);
+ break;
+ }
+ }
+
+ // creates dummy improvements. those have one visual part, and default name is the enum cache name of the type
+ foreach (KeyValuePair improvementInfo in improvementPrefabInfosToCreate)
+ {
+ ImprovementData.Type improvementType = improvementInfo.Key;
+ improvementInfos.Add(
+ new ImprovementPrefabInfo()
+ {
+ improvementType = improvementType,
+ visualParts = new()
+ {
+ new()
+ {
+ defaultSpriteName = EnumCache.GetName(improvementType),
+ levels = improvementInfo.Value,
+ }
+ }
+ }
+ );
+ }
+
+ // creates dummy resources. those have one visual part, and default name is the enum cache name of the type
+ foreach (ResourceData.Type resourceType in resourcePrefabsInfosToCreate)
+ {
+ resourceInfos.Add(
+ new ResourcePrefabInfo()
+ {
+ resourceType = resourceType,
+ visualParts = new()
+ {
+ new()
+ {
+ defaultSpriteName = EnumCache.GetName(resourceType),
+ }
+ }
+ }
+ );
+ }
+
+ foreach (var unitPrefabInfo in unitInfos)
+ {
+ ProcessUnitPrefabInfo(unitPrefabInfo);
+ }
+
+ foreach (var improvementPrefabInfo in improvementInfos)
+ {
+ ProcessImprovementPrefabInfo(improvementPrefabInfo);
+ }
+
+ foreach (var resourcePrefabInfo in resourceInfos)
+ {
+ ProcessResourcePrefabInfo(resourcePrefabInfo);
+ }
+ }
+
+ #region Units
+ internal static void ProcessUnitPrefabInfo(UnitPrefabInfo unitPrefabInfo)
+ {
+ // maybe just init new unit? hmmmmm will try later
+ var baseUnit = PrefabManager.GetPrefab(UnitData.Type.Warrior, TribeType.Imperius, SkinType.Default);
+ if (baseUnit == null)
+ return;
+
+ var unitInstance = GameObject.Instantiate(baseUnit);
+ if (unitInstance == null)
+ return;
+
+ GameObject.Destroy(unitInstance.spriteContainer.gameObject);
+
+ var spriteContainerObj = new GameObject("SpriteContainer");
+ spriteContainerObj.transform.SetParent(unitInstance.transform, false);
+ var spriteContainer = spriteContainerObj.transform;
+ unitInstance.spriteContainer = spriteContainer;
+
+ var visualParts = ApplyUnitVisualParts(unitPrefabInfo, spriteContainer);
+
+ Transform? headPositionMarker = null;
+ foreach (var vp in visualParts)
+ {
+ if (vp.visualPart.gameObject.name == unitPrefabInfo.headPositionMarker)
+ {
+ headPositionMarker = vp.visualPart.gameObject.transform;
+ break;
+ }
+ }
+
+ unitInstance.headPositionMarker = headPositionMarker ?? visualParts[0].visualPart.transform;
+
+ var svr = unitInstance.GetComponent();
+ svr.visualParts = visualParts.ToArray();
+
+ GameObject.DontDestroyOnLoad(unitInstance.gameObject);
+
+ if(unitPrefabInfo.uiOverride)
+ {
+ unitUIOverridesNew[unitPrefabInfo.skinnedKey] = unitInstance;
+ return;
+ }
+ unitsNew[unitPrefabInfo.skinnedKey] = unitInstance;
+ }
+
+ private static List ApplyUnitVisualParts(
+ UnitPrefabInfo unitPrefabInfo,
+ Transform spriteContainer)
+ {
+ List parts = new();
+ List visualPartInfos = unitPrefabInfo.visualParts;
+
+ if (visualPartInfos.Count == 0)
+ return parts;
+
+ int nextPartSortingOrder = 0;
+ int nextOutlineSortingOrder = -visualPartInfos.Count;
+ var newVisualInfo = visualPartInfos;
+ newVisualInfo.Reverse();
+ foreach (var info in newVisualInfo)
+ {
+ parts.Add(CreateUnitVisualPart(info, spriteContainer, info.sortingOrderOverride ?? nextPartSortingOrder, info.outlineSortingOrderOverride ?? nextOutlineSortingOrder));
+ if(nextOutlineSortingOrder < -1)
+ {
+ nextOutlineSortingOrder++;
+ }
+ nextPartSortingOrder++;
+ }
+
+ return parts;
+ }
+
+ private static SkinVisualsReference.VisualPart CreateUnitVisualPart(
+ VisualPartInfo info,
+ Transform parent,
+ int visualPartSortingOrder,
+ int outlineSortingOrder)
+ {
+ var visualPartObj = new GameObject(info.defaultSpriteName);
+ visualPartObj.transform.SetParent(parent);
+
+ var outlineObj = new GameObject($"{info.defaultSpriteName}_Outline");
+ outlineObj.transform.SetParent(visualPartObj.transform);
+
+ visualPartObj.transform.position = info.coordinates;
+ outlineObj.transform.position = info.coordinates;
+ visualPartObj.transform.rotation = outlineObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation);
+
+ // this bs was hunting me for so long. that is the thing which makes outline hidden in ui
+ outlineObj.SetActive(false);
+
+ var visualPart = info.ToVisualPart(false);
+ visualPart.visualPart = visualPartObj;
+ visualPart.outline = outlineObj;
+
+ var renderer = visualPartObj.AddComponent();
+ renderer.material = cachedUnitMaterial;
+ renderer.sortingOrder = visualPartSortingOrder;
+ renderer.sortingLayerName = "Units";
+ visualPart.renderer = new SkinVisualsReference.RendererUnion { spriteRenderer = renderer };
+
+ var outlineRenderer = outlineObj.AddComponent();
+ outlineRenderer.material = cachedUnitMaterial;
+ outlineRenderer.sortingOrder = outlineSortingOrder;
+ outlineRenderer.sortingLayerName = "Units";
+ visualPart.outlineRenderer = new SkinVisualsReference.RendererUnion { spriteRenderer = outlineRenderer };
+
+ return visualPart;
+ }
+
+ #endregion
+ #region Improvements
+ internal static void ProcessImprovementPrefabInfo(ImprovementPrefabInfo improvementPrefabInfo)
+ {
+ if(improvementPrefabInfo.improvementType == null)
+ return;
+
+ var baseBuilding = PrefabManager.GetPrefab(ImprovementData.Type.LumberHut);
+ if (baseBuilding == null)
+ return;
+
+ var buildingInstance = GameObject.Instantiate(baseBuilding);
+ if (buildingInstance == null)
+ return;
+
+ Transform buildingTransform = buildingInstance.transform;
+ for (int childIndex = buildingTransform.childCount - 1; childIndex >= 0; childIndex--)
+ {
+ GameObject.Destroy(buildingTransform.GetChild(childIndex).gameObject);
+ }
+
+ var visualParts = ApplyBuildingVisualParts(improvementPrefabInfo, buildingTransform);
+
+ var svr = buildingInstance.GetComponent();
+ svr.visualParts = visualParts.ToArray();
+
+ GameObject.DontDestroyOnLoad(buildingInstance.gameObject);
+
+ PrefabManager.improvements[(Polytopia.Data.ImprovementData.Type)improvementPrefabInfo.improvementType] = buildingInstance;
+ }
+
+ private static List ApplyBuildingVisualParts(
+ ImprovementPrefabInfo improvementPrefabInfo,
+ Transform spriteContainer)
+ {
+ List parts = new();
+ List visualPartInfos = improvementPrefabInfo.visualParts;
+
+ if (visualPartInfos.Count == 0)
+ return parts;
+
+ int nextPartSortingOrder = 0;
+ int nextOutlineSortingOrder = -visualPartInfos.Count;
+ foreach (var info in visualPartInfos)
+ {
+ parts.Add(CreateBuildingVisualPart(info, spriteContainer, nextPartSortingOrder, nextOutlineSortingOrder, improvementPrefabInfo.needsOutlines));
+ if(nextOutlineSortingOrder < -1)
+ {
+ nextOutlineSortingOrder++;
+ }
+ nextPartSortingOrder++;
+ }
+
+ return parts;
+ }
+
+ private static SkinVisualsReference.VisualPart CreateBuildingVisualPart(
+ VisualPartInfo info,
+ Transform parent,
+ int visualPartSortingOrder,
+ int outlineSortingOrder,
+ bool needsOutline = false)
+ {
+ var visualPartObj = new GameObject(info.defaultSpriteName);
+ visualPartObj.transform.SetParent(parent);
+
+ var outlineObj = new GameObject($"{info.defaultSpriteName}_Outline");
+ outlineObj.transform.SetParent(visualPartObj.transform);
+
+ visualPartObj.transform.position = outlineObj.transform.position = info.coordinates;
+ visualPartObj.transform.rotation = outlineObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation);
+
+ outlineObj.SetActive(false);
+
+ var visualPart = info.ToVisualPart(true);
+ visualPart.visualPart = visualPartObj;
+ visualPart.outline = outlineObj;
+
+ var renderer = AddPolytopiaSpriteRenderer(visualPartObj, visualPartSortingOrder);
+ visualPart.renderer = new SkinVisualsReference.RendererUnion { polytopiaSpriteRenderer = renderer };
+
+ if(needsOutline)
+ {
+ var outlineRenderer = AddPolytopiaSpriteRenderer(outlineObj, outlineSortingOrder);
+ visualPart.outlineRenderer = new SkinVisualsReference.RendererUnion { polytopiaSpriteRenderer = outlineRenderer };
+ }
+
+ return visualPart;
+ }
+
+ private static PolytopiaSpriteRenderer AddPolytopiaSpriteRenderer(GameObject targetGameObject, int sortingOrder, string sortingLayerName = "Terrain")
+ {
+ MeshFilter meshFilter = targetGameObject.AddComponent();
+ MeshRenderer meshRenderer = targetGameObject.AddComponent();
+ meshRenderer.receiveShadows = false;
+ meshRenderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
+ meshRenderer.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
+ meshRenderer.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off;
+
+ PolytopiaSpriteRenderer polytopiaSpriteRenderer = targetGameObject.AddComponent();
+ polytopiaSpriteRenderer.meshFilter = meshFilter;
+ polytopiaSpriteRenderer.meshRenderer = meshRenderer;
+
+ polytopiaSpriteRenderer.SortingLayer = SortingLayer.NameToID(sortingLayerName);
+ polytopiaSpriteRenderer.SortingOrder = sortingOrder;
+ if (cachedSharedMaterial != null)
+ {
+ polytopiaSpriteRenderer.SharedMaterial = cachedSharedMaterial;
+ }
+ return polytopiaSpriteRenderer;
+ }
+
+ #endregion
+ #region Resources
+
+ internal static void ProcessResourcePrefabInfo(ResourcePrefabInfo resourcePrefabInfo)
+ {
+ if(resourcePrefabInfo.resourceType == null)
+ return;
+
+ var baseResource = PrefabManager.GetPrefab(ResourceData.Type.Game);
+ if (baseResource == null)
+ return;
+
+ var resourceInstance = GameObject.Instantiate(baseResource);
+ if (resourceInstance == null)
+ return;
+
+ Transform resourceTransform = resourceInstance.transform;
+ for (int childIndex = resourceTransform.childCount - 1; childIndex >= 0; childIndex--)
+ {
+ GameObject.Destroy(resourceTransform.GetChild(childIndex).gameObject);
+ }
+
+ var visualParts = ApplyResourceVisualParts(resourcePrefabInfo, resourceTransform);
+
+ var svr = resourceInstance.GetComponent();
+ svr.visualParts = visualParts.ToArray();
+
+ GameObject.DontDestroyOnLoad(resourceInstance.gameObject);
+
+ PrefabManager.resources[(Polytopia.Data.ResourceData.Type)resourcePrefabInfo.resourceType] = resourceInstance;
+ }
+
+ private static List ApplyResourceVisualParts(
+ ResourcePrefabInfo resourcePrefabInfo,
+ Transform spriteContainer)
+ {
+ List parts = new();
+ List visualPartInfos = resourcePrefabInfo.visualParts;
+
+ if (visualPartInfos.Count == 0)
+ return parts;
+
+ int nextPartSortingOrder = 0;
+ int nextOutlineSortingOrder = -visualPartInfos.Count;
+ foreach (var info in visualPartInfos)
+ {
+ parts.Add(CreateResourceVisualPart(info, spriteContainer, nextPartSortingOrder, nextOutlineSortingOrder));
+ if(nextOutlineSortingOrder < -1)
+ {
+ nextOutlineSortingOrder++;
+ }
+ nextPartSortingOrder++;
+ }
+
+ return parts;
+ }
+
+ private static SkinVisualsReference.VisualPart CreateResourceVisualPart(
+ VisualPartInfo info,
+ Transform parent,
+ int visualPartSortingOrder,
+ int outlineSortingOrder)
+ {
+ var visualPartObj = new GameObject(info.defaultSpriteName);
+ visualPartObj.transform.SetParent(parent);
+
+ var outlineObj = new GameObject($"{info.defaultSpriteName}_Outline");
+ outlineObj.transform.SetParent(visualPartObj.transform);
+
+ visualPartObj.transform.position = outlineObj.transform.position = info.coordinates;
+ visualPartObj.transform.rotation = outlineObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation);
+
+ outlineObj.SetActive(false);
+
+ var visualPart = info.ToVisualPart(false);
+ visualPart.visualPart = visualPartObj;
+ visualPart.outline = outlineObj;
+
+ var renderer = AddPolytopiaSpriteRenderer(visualPartObj, visualPartSortingOrder);
+ visualPart.renderer = new SkinVisualsReference.RendererUnion { polytopiaSpriteRenderer = renderer };
+
+ var outlineRenderer = AddPolytopiaSpriteRenderer(outlineObj, outlineSortingOrder);
+ visualPart.outlineRenderer = new SkinVisualsReference.RendererUnion { polytopiaSpriteRenderer = outlineRenderer };
+
+ return visualPart;
+ }
+
+ #endregion
+
+ internal static void Init()
+ {
+ Harmony.CreateAndPatchAll(typeof(Prefabs));
+ }
+}
\ No newline at end of file
diff --git a/src/Managers/TribePreviews.cs b/src/Managers/TribePreviews.cs
new file mode 100644
index 0000000..b0c41fd
--- /dev/null
+++ b/src/Managers/TribePreviews.cs
@@ -0,0 +1,234 @@
+using HarmonyLib;
+using Polytopia.Data;
+using UnityEngine;
+using Il2CppSystem.Linq;
+using PolyMod.Json;
+using System.Text.Json.Serialization;
+using PolytopiaBackendBase.Common;
+
+namespace PolyMod.Managers;
+
+public static class TribePreviews
+{
+ ///
+ /// Represents a tile in a tribe preview.
+ ///
+ public class PreviewTile
+ {
+ /// The x-coordinate of the tile.
+ [JsonInclude]
+ public int? x = null;
+ /// The y-coordinate of the tile.
+ [JsonInclude]
+ public int? y = null;
+ /// The terrain type of the tile.
+ [JsonInclude]
+ [JsonConverter(typeof(EnumCacheJson))]
+ public Polytopia.Data.TerrainData.Type terrainType = Polytopia.Data.TerrainData.Type.None;
+ /// The resource type on the tile.
+ [JsonInclude]
+ [JsonConverter(typeof(EnumCacheJson))]
+ public ResourceData.Type? resourceType = null;
+ /// The unit type on the tile.
+ [JsonInclude]
+ [JsonConverter(typeof(EnumCacheJson))]
+ public UnitData.Type? unitType = null;
+ /// The improvement type on the tile.
+ [JsonInclude]
+ [JsonConverter(typeof(EnumCacheJson))]
+ public ImprovementData.Type? improvementType = null;
+ /// The effect types on the tile.
+ [JsonInclude]
+ [JsonConverter(typeof(EnumCacheListJson))]
+ public List tileEffects = new();
+ }
+
+ public record PreviewInfo(int arrayIdx, SaveStateData saveStateData, PreviewTile[] customPreview);
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(TribePreviewRegistry), nameof(TribePreviewRegistry.PreloadTribePreviews))]
+ public static void PreloadTribePreviews()
+ {
+ Dictionary previewSaveStateDatas = new();
+
+ SaveStateData fallbackSaveStateData = TribePreviewRegistry.saveStateDatas[TribePreviewRegistry.fallbackStateIndex];
+ foreach(string tribe in Registry.tribePreviews.Keys)
+ {
+ string previewId = "worldpreview_" + tribe;
+ PreviewTile[] customPreview = Registry.tribePreviews[tribe];
+ previewSaveStateDatas[previewId] = new(TribePreviewRegistry.fallbackStateIndex, fallbackSaveStateData, customPreview);
+ }
+
+ for (int i = 0; i < TribePreviewRegistry.saveStateDatas.Length; i++)
+ {
+ SaveStateData saveStateData = TribePreviewRegistry.saveStateDatas[i];
+ if(previewSaveStateDatas.ContainsKey(saveStateData.name))
+ {
+ previewSaveStateDatas[saveStateData.name] = new(i, saveStateData, previewSaveStateDatas[saveStateData.name].customPreview);
+ }
+ }
+
+ foreach (string previewId in previewSaveStateDatas.Keys)
+ {
+ var data = previewSaveStateDatas[previewId];
+ SaveStateData? customSaveStateData = ApplyCustomPreview(previewId, data.saveStateData, data.customPreview);
+ if(customSaveStateData == null)
+ continue;
+
+ if(data.arrayIdx == TribePreviewRegistry.fallbackStateIndex)
+ {
+ List saveStateDatas = TribePreviewRegistry.saveStateDatas.ToList();
+ saveStateDatas.Add(customSaveStateData);
+ TribePreviewRegistry.saveStateDatas = saveStateDatas.ToArray();
+ }
+ else
+ {
+ TribePreviewRegistry.saveStateDatas[data.arrayIdx] = customSaveStateData;
+ }
+ }
+ }
+
+ private static SaveStateData? ApplyCustomPreview(string previewId, SaveStateData originalPreview, PreviewTile[] preview)
+ {
+ SaveStateData? saveStateData = null;
+ if (DiskSerializationHelpers.FromLZ4CompressedByteArray(
+ originalPreview.byteArray, out ClientSerializationWrapper clientSerializationWrapper, out int version))
+ {
+ GameState currentGameState = clientSerializationWrapper.GetCurrentGameState();
+ foreach (var previewTile in preview)
+ {
+ if(previewTile.x == null || previewTile.y == null)
+ continue;
+
+ WorldCoordinates coordinates = new((int)previewTile.x, (int)previewTile.y);
+ TileData? tileData = currentGameState.Map.GetTile(coordinates);
+ if(tileData == null)
+ continue;
+
+ if(previewTile.terrainType != Polytopia.Data.TerrainData.Type.None)
+ tileData.terrain = previewTile.terrainType;
+
+ if(previewTile.resourceType != null)
+ {
+ ResourceState? resourceState = null;
+ ResourceData.Type resourceType = (ResourceData.Type)previewTile.resourceType;
+ if(resourceType != ResourceData.Type.None)
+ {
+ resourceState = new ResourceState { type = resourceType };
+ }
+ tileData.resource = resourceState;
+ }
+
+ if(previewTile.unitType != null)
+ {
+ tileData.unit = null;
+ if(previewTile.unitType != Polytopia.Data.UnitData.Type.None &&
+ currentGameState.TryGetPlayer(currentGameState.CurrentPlayer, out PlayerState playerState) &&
+ currentGameState.GameLogicData.TryGetData((UnitData.Type)previewTile.unitType, out UnitData unitData))
+ {
+ UnitState unitState = ActionUtils.TrainUnit(currentGameState, playerState, tileData, unitData);
+ unitState.moved = false;
+ unitState.attacked = false;
+ }
+ }
+
+
+ if(previewTile.improvementType != null)
+ {
+ tileData.improvement = null;
+ ImprovementData.Type improvementType = (ImprovementData.Type)previewTile.improvementType;
+ if(
+ previewTile.improvementType != ImprovementData.Type.None &&
+ currentGameState.GameLogicData.TryGetData(
+ improvementType,
+ out ImprovementData improvementData)
+ )
+ {
+ tileData.improvement = new ImprovementState
+ {
+ type = improvementData.type,
+ borderSize = (ushort)improvementData.borderSize,
+ level = 0,
+ xp = 0,
+ production = 1,
+ founded = (ushort)currentGameState.CurrentTurn,
+ baseScore = (ushort)improvementData.GetScoreReward(),
+ founder = currentGameState.CurrentPlayer
+ };
+ }
+ }
+
+ if(previewTile.tileEffects.Count > 0)
+ tileData.effects = new();
+
+ foreach (TileData.EffectType tileEffect in previewTile.tileEffects)
+ {
+ tileData.AddEffect(tileEffect);
+ }
+ }
+
+ saveStateData = ScriptableObject.CreateInstance();
+ saveStateData.name = previewId;
+ saveStateData.byteArray = DiskSerializationHelpers.ToLZ4CompressedByteArray(clientSerializationWrapper, version);
+ }
+
+ return saveStateData;
+ }
+
+ [HarmonyPostfix]
+ [HarmonyPatch(typeof(SelectTribePopup), nameof(SelectTribePopup.SetBackground))]
+ private static void SelectTribePopup_SetBackground(SelectTribePopup __instance)
+ {
+ if(!Plugin.config.showCoordinates)
+ return;
+
+ try
+ {
+ TribeType tribeType = (__instance.mixTribeData != null) ? __instance.mixTribeData.type : __instance.tribeData.type;
+
+ __instance.mapSnapshot = new SpriteCamera.SnapshotMapData();
+ __instance.mapSnapshot.LoadMapState(
+ TribePreviewRegistry.GetTribePreviewData(tribeType, __instance.SkinType)
+ );
+
+ float width = __instance.worldPreviewRawImage.rectTransform.GetWidth();
+ float height = __instance.worldPreviewRawImage.rectTransform.GetHeight();
+
+ int tribeColor = PolytopiaDataManager.GetGameLogicData(
+ VersionManager.GameLogicDataVersion
+ ).GetTribeColor(
+ tribeType,
+ __instance.SkinType
+ );
+
+ var instance = SpriteCamera.instance;
+ float baseOrthographicCameraSize = instance.spriteCamera.orthographicSize;
+
+ instance.spriteCamera.orthographicSize = (float)baseOrthographicCameraSize * 2;
+
+ Texture2D texture2D = SpriteCamera.TakeSnapshotOfMapState(
+ __instance.mapSnapshot,
+ width,
+ height,
+ tribeType,
+ __instance.SkinType,
+ tribeType,
+ tribeColor
+ );
+
+ instance.spriteCamera.orthographicSize = baseOrthographicCameraSize;
+
+ __instance.worldPreviewRawImage.texture = texture2D;
+ __instance.worldPreviewRawImage.color = Color.white;
+ }
+ catch (Exception ex)
+ {
+ Plugin.logger.LogWarning($"Failed to load debug tribe preview {ex.Message}");
+ }
+ }
+
+ internal static void Init()
+ {
+ Harmony.CreateAndPatchAll(typeof(TribePreviews));
+ }
+}
\ No newline at end of file
diff --git a/src/Managers/Visual.cs b/src/Managers/Visual.cs
index 628b7b2..82acd4d 100644
--- a/src/Managers/Visual.cs
+++ b/src/Managers/Visual.cs
@@ -1,14 +1,11 @@
-using System.Reflection;
using HarmonyLib;
using Polytopia.Data;
using UnityEngine;
-using UnityEngine.U2D;
-using UnityEngine.UI;
using Il2CppSystem.Linq;
-using PolyMod.Json;
-using System.Text.Json.Serialization;
using PolytopiaBackendBase.Common;
using Il2CppInterop.Runtime;
+using System.Text.Json.Serialization;
+using PolyMod.Json;
using TMPro;
namespace PolyMod.Managers;
@@ -18,109 +15,76 @@ namespace PolyMod.Managers;
///
public static class Visual
{
- ///
- /// Represents a tile in a tribe preview.
- ///
- public class PreviewTile
- {
- /// The x-coordinate of the tile.
- [JsonInclude]
- public int? x = null;
- /// The y-coordinate of the tile.
- [JsonInclude]
- public int? y = null;
- /// The terrain type of the tile.
- [JsonInclude]
- [JsonConverter(typeof(EnumCacheJson))]
- public Polytopia.Data.TerrainData.Type terrainType = Polytopia.Data.TerrainData.Type.None;
- /// The resource type on the tile.
- [JsonInclude]
- [JsonConverter(typeof(EnumCacheJson))]
- public ResourceData.Type? resourceType = null;
- /// The unit type on the tile.
- [JsonInclude]
- [JsonConverter(typeof(EnumCacheJson))]
- public UnitData.Type? unitType = null;
- /// The improvement type on the tile.
- [JsonInclude]
- [JsonConverter(typeof(EnumCacheJson))]
- public ImprovementData.Type? improvementType = null;
- }
-
- /// Represents information about a sprite, such as its pivot and pixels per unit.
- public record SpriteInfo(float? pixelsPerUnit, Vector2? pivot);
+ /// Represents information about a sprite, such as its pivot and pixels per unit.
+ public class SpriteInfo
+ {
+ [JsonInclude]
+ public float? pixelsPerUnit;
+
+ [JsonInclude]
+ public Vector2? pivot;
+
+ [JsonInclude]
+ public SpriteOrigin? spriteOrigin = null;
+
+ [JsonInclude]
+ public bool transformToOutline = false;
+
+ [JsonInclude]
+ public float? gaussianBlurRadius = null;
+
+ public SpriteInfo(float? pixelsPerUnit, Vector2? pivot, bool transformToOutline = false, SpriteOrigin? spriteOrigin = null, float? gaussianBlurRadius = null)
+ {
+ this.pixelsPerUnit = pixelsPerUnit;
+ this.pivot = pivot;
+ this.spriteOrigin = spriteOrigin;
+ this.transformToOutline = transformToOutline;
+ this.gaussianBlurRadius = gaussianBlurRadius;
+ }
+ }
+
+ public class SpriteOrigin
+ {
+ [JsonInclude]
+ public string? name;
+
+ [JsonInclude]
+ [JsonConverter(typeof(EnumNameJson))]
+ public OriginType originType = OriginType.Outer;
+ }
+
+ public enum OriginType
+ {
+ Outer, // Outer means sprite comes from a mod, and not from game's files
+ Inner // Inner means sprite is to be fetched from game's files
+ }
/// Represents information about a custom skin.
public record SkinInfo(int idx, string id, SkinData? skinData);
- /// A dictionary of custom widths for basic popups.
- public static Dictionary basicPopupWidths = new();
- /// Represents information about a unit prefab.
- public struct UnitPrefabInfo
- {
- public UnitPrefabInfo(string type, string tribe, string skin)
- {
- this.type = type;
- this.tribe = tribe;
- this.skin = skin;
- }
- public string type;
- public string tribe;
- public string skin;
- }
- private const int BASIC_POPUP_LEGACY_ID = 29;
- public static Dictionary customPrefabs = new();
- private static UnitData.Type currentUnitTypeUI = UnitData.Type.None;
- private static TribeType attackerTribe = TribeType.None;
- ///
- /// A dictionary of skin types of tribes, which can flood tiles, keyed by custom flood tile effect.
- ///
- internal static Dictionary customFloodingSkins = new();
- private static bool isTakingSnapshot = false;
- private static float? baseOrthographicCameraSize = null;
-
- /// The type of a custom prefab.
- public enum PrefabType
- {
- Unit,
- Improvement,
- Resource
- }
+ /// A dictionary of custom widths for basic popups.
+ public static Dictionary basicPopupWidths = new();
- /// Represents information about a custom prefab.
- public record PrefabInfo
- {
- public PrefabType type { get; init; }
- public string name { get; init; }
- public List visualParts { get; init; }
- public string headPositionMarker { get; init; }
-
- [JsonConstructor]
- public PrefabInfo(
- PrefabType type,
- List visualParts,
- string name = "",
- string headPositionMarker = "")
- {
- this.type = type;
- this.name = name.ToLowerInvariant();
- this.visualParts = visualParts;
- this.headPositionMarker = headPositionMarker;
- }
- }
+ public record PopupWidth(BasicPopupLegacy popup, float share, float maximum);
+
+ private const int BASIC_POPUP_LEGACY_ID = 29;
+
+ /// The width the game gives its own popups, used as the floor for custom widths.
+ private const float DEFAULT_POPUP_WIDTH = 402f;
+
+ /// Smallest gap left between two buttons before they are moved onto separate rows.
+ private const float POPUP_BUTTON_GAP = 8f;
- /// Represents information about a visual part of a prefab.
- public record VisualPartInfo(
- string gameObjectName,
- string baseName,
- float rotation = 0f,
- Vector2 coordinates = new Vector2(),
- Vector2 scale = new Vector2(),
- bool tintable = false
- );
- private static bool enableOutlines = false;
- private static bool seenWarningWCPopup = false;
- public record PreviewInfo(int arrayIdx, SaveStateData saveStateData, PreviewTile[] customPreview);
+ /// Vertical gap between two rows of popup buttons.
+ private const float POPUP_BUTTON_ROW_GAP = 10f;
+ ///
+ /// A dictionary of skin types of tribes, which can flood tiles, keyed by custom flood tile effect.
+ ///
+ internal static Dictionary customFloodingSkins = new();
+ private static bool seenWarningWCPopup = false;
+
+ private static Dictionary? cachedSpriteDataReverse = null;
+ private static Dictionary cachedReversedNames = new();
#region General
@@ -131,242 +95,222 @@ private static void TechItem_SetupComplete()
{
}
- /// Patches the sprite atlas manager to load custom sprites.
- [HarmonyPrefix]
- [HarmonyPatch(typeof(SpriteAtlasManager), nameof(SpriteAtlasManager.LoadSprite), typeof(string), typeof(string), typeof(SpriteCallback))]
- private static bool SpriteAtlasManager_LoadSprite(SpriteAtlasManager __instance, string atlas, string sprite, SpriteCallback completion)
- {
- bool found = false;
- __instance.LoadSpriteAtlas(atlas, (Il2CppSystem.Action)GetAtlas);
-
- return !found;
-
- void GetAtlas(SpriteAtlas spriteAtlas)
+ /// Patches the sprite atlas manager to look up custom sprites.
+ internal static void CacheSpriteNames()
+ {
+ cachedSpriteDataReverse = new();
+ foreach (Polytopia.Data.TerrainData.Type terrainType in Enum.GetValues())
{
- if (spriteAtlas != null)
+ string result = SpriteData.TerrainToString(terrainType);
+ if(terrainType == Polytopia.Data.TerrainData.Type.Wetland)
{
- List names = sprite.Split('_').ToList();
- List filteredNames = new List(names);
- string style = "";
- foreach (string item in names)
- {
- string upperitem = char.ToUpper(item[0]) + item[1..];
- if (EnumCache.TryGetType(item, out TribeType tribe) || EnumCache.TryGetType(item, out SkinType skin)
- || EnumCache.TryGetType(upperitem, out TribeType tribeUpper) || EnumCache.TryGetType(upperitem, out SkinType skinUpper))
- {
- filteredNames.Remove(item);
- style = item;
- continue;
- }
- }
- string name = string.Join("_", filteredNames);
- Sprite? newSprite = Registry.GetSprite(name, style);
- if (newSprite != null)
- {
- completion?.Invoke(atlas, sprite, newSprite);
- found = true;
- }
+ cachedSpriteDataReverse.TryAdd(result, EnumCache.GetName(Polytopia.Data.TerrainData.Type.Field) + "_flooded");
+ continue;
}
- }
- }
-
- /// Patches the sprite atlas manager to look up custom sprites.
- [HarmonyPostfix]
- [HarmonyPatch(typeof(SpriteAtlasManager), nameof(SpriteAtlasManager.DoSpriteLookup))]
- private static void SpriteAtlasManager_DoSpriteLookup(ref SpriteAtlasManager.SpriteLookupResult __result, SpriteAtlasManager __instance, string baseName, TribeType tribe, SkinType skin, int level)
- {
- baseName = Util.FormatSpriteName(baseName);
-
- Sprite? sprite = Registry.GetSprite(baseName, Util.GetStyle(tribe, skin), level);
- if (sprite != null)
- __result.sprite = sprite;
- }
-
- #endregion
- #region Units
-
- /// Enables outlines when the interaction bar is shown.
- [HarmonyPrefix]
- [HarmonyPatch(typeof(InteractionBar), nameof(InteractionBar.Show))]
- private static bool InteractionBar_Show(InteractionBar __instance, bool instant, bool force)
- {
- enableOutlines = true;
- return true;
- }
-
- /// Prevents outlines from being created when they are disabled.
- [HarmonyPrefix]
- [HarmonyPatch(typeof(UISpriteDuplicator), nameof(UISpriteDuplicator.CreateImage), typeof(SpriteRenderer), typeof(Transform), typeof(Transform), typeof(float), typeof(Vector2), typeof(bool))]
- private static bool UISpriteDuplicator_CreateImage(SpriteRenderer spriteRenderer, Transform source, Transform destination, float scale, Vector2 offset, bool forceFullAlpha)
- {
- return !(spriteRenderer.sortingOrder == -1 && !enableOutlines);
- }
-
- /// Disables outlines after the interaction bar is shown.
- [HarmonyPostfix]
- [HarmonyPatch(typeof(InteractionBar), nameof(InteractionBar.Show))]
- private static void InteractionBar_Show_Postfix(InteractionBar __instance, bool instant, bool force)
- {
- enableOutlines = false;
- }
-
- /// Sets the current unit type being rendered in the UI.
- [HarmonyPrefix]
- [HarmonyPatch(typeof(UIUnitRenderer), nameof(UIUnitRenderer.CreateUnit))]
- private static bool UIUnitRenderer_CreateUnit_Prefix(UIUnitRenderer __instance)
- {
- currentUnitTypeUI = __instance.unitType;
- return true;
- }
-
- /// Resets the current unit type being rendered in the UI.
- [HarmonyPostfix]
- [HarmonyPatch(typeof(UIUnitRenderer), nameof(UIUnitRenderer.CreateUnit))]
- private static void UIUnitRenderer_CreateUnit_Postfix(UIUnitRenderer __instance)
- {
- currentUnitTypeUI = UnitData.Type.None;
- }
- /// Skins the visual parts of a unit with custom sprites.
- [HarmonyPostfix]
- [HarmonyPatch(typeof(SkinVisualsRenderer), nameof(SkinVisualsRenderer.SkinWorldObject))]
- private static void SkinVisualsRenderer_SkinWorldObject(
- SkinVisualsRenderer.SkinWorldType type,
- SkinVisualsReference skinVisuals,
- SkinVisualsTransientData transientSkinData,
- bool checkOutlines,
- int level)
- {
- if (type != SkinVisualsRenderer.SkinWorldType.Unit || skinVisuals == null ||
- skinVisuals.visualParts == null || transientSkinData == null)
- return;
-
- Unit unit = skinVisuals.gameObject.GetComponent();
-
- string unitTypeName = EnumCache