diff --git a/Assets/Editor/SaveResetMenu.cs b/Assets/Editor/SaveResetMenu.cs new file mode 100644 index 0000000..19ccee3 --- /dev/null +++ b/Assets/Editor/SaveResetMenu.cs @@ -0,0 +1,43 @@ +using System.IO; +using UnityEditor; +using UnityEngine; + +// Tools -> Сбросить сохранения. Для разработчиков: новая игра одной кнопкой. +// - в Play Mode -- тот же сброс, что кнопка в Настройках (все модули + прогресс); +// - вне Play Mode -- просто удаляет файлы save.json и progress.json. +// Документ: Docs/Modules/Save.md +public static class SaveResetMenu +{ + static readonly string[] SaveFiles = { "save.json", "progress.json" }; + + [MenuItem("Tools/Сбросить сохранения")] + static void ResetSaves() + { + if (!EditorUtility.DisplayDialog("Сбросить сохранения?", + "Ресурсы, здания, Ратуша, войска, походы, задания и почта начнутся заново.\nЭто нельзя отменить.", + "Сбросить", "Отмена")) + return; + + if (Application.isPlaying) + { + GameProgress.Instance.ResetAll(); + return; + } + + int deleted = 0; + foreach (var name in SaveFiles) + { + string path = Path.Combine(Application.persistentDataPath, name); + if (!File.Exists(path)) continue; + File.Delete(path); + deleted++; + } + Debug.Log($"[СОХРАНЕНИЕ] Удалено файлов сохранения: {deleted} ({Application.persistentDataPath}). Следующий запуск -- новая игра."); + } + + [MenuItem("Tools/Открыть папку сохранений")] + static void OpenSaveFolder() + { + EditorUtility.RevealInFinder(Application.persistentDataPath); + } +} diff --git a/Assets/Editor/SaveResetMenu.cs.meta b/Assets/Editor/SaveResetMenu.cs.meta new file mode 100644 index 0000000..5c56127 --- /dev/null +++ b/Assets/Editor/SaveResetMenu.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 972f2f5a1fa0c38439b03355997f98e9 \ No newline at end of file diff --git a/Assets/Scripts/App.meta b/Assets/Scripts/App.meta new file mode 100644 index 0000000..f864b12 --- /dev/null +++ b/Assets/Scripts/App.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bb59bee2c4134da4b8a13896c9b2e177 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs new file mode 100644 index 0000000..8dc7583 --- /dev/null +++ b/Assets/Scripts/App/GameModules.cs @@ -0,0 +1,67 @@ +using UnityEngine; + +// ТОЧКА СБОРКИ МОДУЛЕЙ (слой приложения). Документ: Docs/Modules/README.md +// +// Здесь и только здесь модули соединяются с остальной игрой: +// 1. регистрируются реализации контрактов, которые не регистрируются сами; +// 2. события модулей подключаются к системам, которые ещё не разнесены по модулям (GameProgress, Казармы). +// Модули друг о друге ничего не знают -- всю "проводку" видно в одном файле. +public static class GameModules +{ + // После SubsystemRegistration (там ядро очищается) и раньше любых Awake + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void Register() + { + // Боевая система -- чистый C#, сама зарегистрироваться не может + GameServices.Register(new BattleSystem()); + + // Армия: войска пока хранятся в GameProgress, контракт реализует мост + GameServices.Register(ArmyBridge.Service); + + // Казармы появляются в списке стройки кодом -- до восстановления зданий из сохранения + EventBus.Subscribe(_ => BarracksBootstrap.Ensure()); + + // Статистика для заданий (GameProgress) -- по событиям Мира + EventBus.Subscribe(OnMarchStarted); + EventBus.Subscribe(OnMarchReturned); + } + + // Сцена загружена, Awake всех объектов прошли, Start -- ещё нет (сохранение грузится в Start) + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void MigrateLegacyMarches() + { + // Раньше походы хранились в progress.json (GameProgress.Data.sashaMarches). + // Теперь их сохраняет модуль Мира в save.json, секция "world". Переносим один раз. + var data = GameProgress.Instance.Data; + if (data.sashaMarches == null || data.sashaMarches.Count == 0 || MarchManager.Instance == null) return; + + // Если в save.json уже есть секция "world", загрузка сохранения заменит эти походы на сохранённые + MarchManager.Instance.LoadMarches(new System.Collections.Generic.List(data.sashaMarches)); + + // Из progress.json убираем, только когда походы уже записаны в save.json -- иначе вылет игры их потеряет + EventBus.Subscribe(ClearLegacyMarches); + } + + static void ClearLegacyMarches(GameSavedEvent e) + { + EventBus.Unsubscribe(ClearLegacyMarches); + GameProgress.Instance.Data.sashaMarches.Clear(); + GameProgress.Instance.MarkChanged(); + Debug.Log("[МОДУЛИ] Походы перенесены из progress.json в модуль Мира."); + } + + static void OnMarchStarted(MarchStartedEvent e) + { + var data = GameProgress.Instance.Data; + data.marchesSent++; + data.dailyMarches++; + GameProgress.Instance.MarkChanged(); + } + + static void OnMarchReturned(MarchReturnedEvent e) + { + if (!e.Victory) return; + GameProgress.Instance.Data.worldWins++; + GameProgress.Instance.MarkChanged(); + } +} diff --git a/Assets/Scripts/App/GameModules.cs.meta b/Assets/Scripts/App/GameModules.cs.meta new file mode 100644 index 0000000..b101fd6 --- /dev/null +++ b/Assets/Scripts/App/GameModules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2afadf4e4024d5c48a2c26eddb75cbb1 \ No newline at end of file diff --git a/Assets/Scripts/Economy/ResourceManager.cs b/Assets/Scripts/Economy/ResourceManager.cs deleted file mode 100644 index 5497611..0000000 --- a/Assets/Scripts/Economy/ResourceManager.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Collections.Generic; -using UnityEngine; - -// Центральное хранилище всех ресурсов игрока. -// Один объект на сцену (Singleton) -- любой скрипт обращается через ResourceManager.Instance - -public enum ResourceType -{ - Population = 0, // люди - Food = 1, // еда - Wood = 2, // дерево - Stone = 3, // камень - Iron = 4, // металл - Gold = 5, // золото - Gems = 6, // кристаллы - Power = 7 // "сила" -- как в референсе HUD -} - -public class ResourceManager : MonoBehaviour -{ - public static ResourceManager Instance { get; private set; } - - // Событие: вызывается каждый раз, когда любой ресурс меняется. - // UI подписывается на него, чтобы обновлять текст автоматически, без Update() каждый кадр. - public event Action OnResourceChanged; - - [Header("Стартовые значения (для теста/баланса)")] - public float startWood = 0f; - public float startStone = 0f; - public float startIron = 0f; - public float startFood = 0f; - public float startGold = 0f; - public float startGems = 0f; - public float startPower = 0f; - public float startPopulation = 0f; - - private Dictionary resources = new Dictionary(); - - void Awake() - { - // Классический паттерн Singleton: если уже есть экземпляр -- уничтожаем дубликат - if (Instance != null && Instance != this) - { - Destroy(gameObject); - return; - } - Instance = this; - DontDestroyOnLoad(gameObject); // менеджер ресурсов должен жить между сценами (меню -> игра) - - resources[ResourceType.Wood] = startWood; - resources[ResourceType.Stone] = startStone; - resources[ResourceType.Iron] = startIron; - resources[ResourceType.Food] = startFood; - resources[ResourceType.Gold] = startGold; - resources[ResourceType.Gems] = startGems; - resources[ResourceType.Power] = startPower; - resources[ResourceType.Population] = startPopulation; - } - - // Добавить ресурс (можно и отрицательное число -- для трат) - public void AddResource(ResourceType type, float amount) - { - if (!resources.ContainsKey(type)) - resources[type] = 0f; - - resources[type] += amount; - if (resources[type] < 0f) resources[type] = 0f; // ресурс не уходит в минус - - OnResourceChanged?.Invoke(type, resources[type]); - } - - // Проверить, хватает ли ресурса (для трат/построек в будущем) - public bool HasEnough(ResourceType type, float amount) - { - return GetResource(type) >= amount; - } - - // Попробовать потратить -- вернёт false, если не хватило (и ничего не спишет) - public bool TrySpend(ResourceType type, float amount) - { - if (!HasEnough(type, amount)) return false; - AddResource(type, -amount); - return true; - } - - public float GetResource(ResourceType type) - { - return resources.TryGetValue(type, out float value) ? value : 0f; - } - - // Устанавливает значение напрямую, БЕЗ проверок и без учёта прошлого значения. - // Используется только при загрузке сохранения -- обычная игровая логика должна - // использовать AddResource/TrySpend, а не этот метод. - public void SetResource(ResourceType type, float value) - { - resources[type] = value; - OnResourceChanged?.Invoke(type, value); - } - - // Все ресурсы разом -- удобно для сохранения - public Dictionary GetAllResources() - { - return new Dictionary(resources); - } -} diff --git a/Assets/Scripts/Meta/Core/GameProgress.cs b/Assets/Scripts/Meta/Core/GameProgress.cs index e1d4e3c..71d0b07 100644 --- a/Assets/Scripts/Meta/Core/GameProgress.cs +++ b/Assets/Scripts/Meta/Core/GameProgress.cs @@ -176,29 +176,26 @@ public class GameProgress : MonoBehaviour catch (Exception e) { Debug.LogWarning("[ПРОГРЕСС] Не удалось сохранить: " + e.Message); } } - // Полный сброс: прогресс, ресурсы, здания, Ратуша. Кнопка в Настройках. + // СБРОС СОХРАНЕНИЙ: прогресс (progress.json) + все модули (save.json): ресурсы, здания, Ратуша, походы. + // Кнопка в Настройках и меню Tools -> Сбросить сохранения. Документ: Docs/Modules/Save.md public void ResetAll() { if (File.Exists(SavePath)) File.Delete(SavePath); - if (SaveManager.Instance != null) SaveManager.Instance.DeleteSave(); - // Здания: удаляем -- BuildingSlotLink сам освободит слоты - foreach (var b in FindObjectsByType(FindObjectsSortMode.None)) Destroy(b.gameObject); - if (TownHall.Instance != null) TownHall.Instance.currentLevel = 1; - - var rm = ResourceManager.Instance; - if (rm != null) + // Модули сбрасывают себя сами через модуль Сохранений; возврат ресурсов к старту -- не "сбор" + suppressTracking++; + if (GameServices.TryGet(out ISaveSystem save)) { - suppressTracking++; - rm.SetResource(ResourceType.Wood, rm.startWood); - rm.SetResource(ResourceType.Stone, rm.startStone); - rm.SetResource(ResourceType.Iron, rm.startIron); - rm.SetResource(ResourceType.Food, rm.startFood); - rm.SetResource(ResourceType.Gold, rm.startGold); - rm.SetResource(ResourceType.Gems, rm.startGems); - rm.SetResource(ResourceType.Population, rm.startPopulation); - suppressTracking--; + save.ResetAll(); } + else + { + // Сцена без SaveManager (например, карту мира запустили напрямую из редактора) + string modulesSave = Path.Combine(Application.persistentDataPath, "save.json"); + if (File.Exists(modulesSave)) File.Delete(modulesSave); + foreach (var participant in SaveRegistry.All) participant.ResetState(); + } + suppressTracking--; Data = new ProgressData(); FixData(); diff --git a/Assets/Scripts/Modules.meta b/Assets/Scripts/Modules.meta new file mode 100644 index 0000000..7f09e8e --- /dev/null +++ b/Assets/Scripts/Modules.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ad9d63e3dce07ca4ab1ae01670f1046f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Army.meta b/Assets/Scripts/Modules/Army.meta new file mode 100644 index 0000000..6fe240a --- /dev/null +++ b/Assets/Scripts/Modules/Army.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2e9b04cfaf4d8d54abf9b9656431a844 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Army/ArmyMath.cs b/Assets/Scripts/Modules/Army/ArmyMath.cs new file mode 100644 index 0000000..f94dcea --- /dev/null +++ b/Assets/Scripts/Modules/Army/ArmyMath.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; + +// МОДУЛЬ АРМИИ: войска и их распределение. Документ: Docs/Modules/Army.md +// Без Unity: правила деления войск одинаковы на клиенте и на сервере. + +public static class ArmyMath +{ + // Сколько воинов каждого типа взять, чтобы набрать count, пропорционально составу армии. + // Остаток округления раздаётся типам с наибольшей дробной частью. null -- столько воинов нет. + public static int[] TakeProportional(int[] troops, int count) + { + int total = 0; + foreach (var t in troops) total += t; + if (count <= 0 || count > total) return null; + + var taken = new int[troops.Length]; + int given = 0; + var remainders = new List>(); + for (int i = 0; i < troops.Length; i++) + { + double exact = (double)count * troops[i] / total; + taken[i] = Math.Min(troops[i], (int)Math.Floor(exact)); + given += taken[i]; + remainders.Add(new KeyValuePair(i, exact - taken[i])); + } + remainders.Sort((a, b) => b.Value.CompareTo(a.Value)); + for (int k = 0; given < count && k < remainders.Count * 2; k++) + { + int i = remainders[k % remainders.Count].Key; + if (taken[i] < troops[i]) { taken[i]++; given++; } + } + return taken; + } + + // Как разложить survivors выживших по типам, если в поход ушли sentUnits. + // Остаток округления -- в самый многочисленный тип похода. + public static int[] SplitSurvivors(int[] sentUnits, int survivors) + { + var back = new int[sentUnits.Length]; + survivors = Math.Max(0, survivors); + + int sent = 0; + foreach (var u in sentUnits) sent += u; + + int given = 0; + for (int i = 0; i < sentUnits.Length; i++) + { + back[i] = sent > 0 ? (int)Math.Floor((double)survivors * sentUnits[i] / sent) : 0; + given += back[i]; + } + + int best = 0; + for (int i = 1; i < sentUnits.Length; i++) if (sentUnits[i] > sentUnits[best]) best = i; + if (back.Length > 0) back[best] += survivors - given; + return back; + } +} diff --git a/Assets/Scripts/Modules/Army/ArmyMath.cs.meta b/Assets/Scripts/Modules/Army/ArmyMath.cs.meta new file mode 100644 index 0000000..4048487 --- /dev/null +++ b/Assets/Scripts/Modules/Army/ArmyMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f4460b101c5560a41ab054b5465462a8 \ No newline at end of file diff --git a/Assets/Scripts/Modules/Army/Bogatyri.Army.asmdef b/Assets/Scripts/Modules/Army/Bogatyri.Army.asmdef new file mode 100644 index 0000000..f48331f --- /dev/null +++ b/Assets/Scripts/Modules/Army/Bogatyri.Army.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Bogatyri.Army", + "rootNamespace": "", + "references": ["Bogatyri.Core"], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/Assets/Scripts/Modules/Army/Bogatyri.Army.asmdef.meta b/Assets/Scripts/Modules/Army/Bogatyri.Army.asmdef.meta new file mode 100644 index 0000000..db8af91 --- /dev/null +++ b/Assets/Scripts/Modules/Army/Bogatyri.Army.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 87359e74a3d522242ab7014f9512ff00 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Battle.meta b/Assets/Scripts/Modules/Battle.meta new file mode 100644 index 0000000..a7cde17 --- /dev/null +++ b/Assets/Scripts/Modules/Battle.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 872c434d8b5a5fb489b82412fbc08b16 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Battle/BattleSystem.cs b/Assets/Scripts/Modules/Battle/BattleSystem.cs new file mode 100644 index 0000000..180a967 --- /dev/null +++ b/Assets/Scripts/Modules/Battle/BattleSystem.cs @@ -0,0 +1,26 @@ +// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md +// Без Unity и без float: этот же код без изменений сможет считать бои на сервере. +// +// Правила пока самые простые: побеждает тот, у кого больше солдат. +// Потом сюда добавятся типы войск, герой, технологии -- остальные модули менять не придётся. +public sealed class BattleSystem : IBattleSystem +{ + public BattleOutcome Resolve(BattleRequest request) + { + int attackers = request.Attackers; + int defenders = request.Defenders; + bool victory = attackers > defenders; // ничья -- в пользу защитника + + // Проигравший теряет половину своего войска, победитель -- столько же + int loserArmy = victory ? defenders : attackers; + int losses = (loserArmy + 1) / 2; + + return new BattleOutcome + { + Victory = victory, + AttackerLosses = losses, + DefenderLosses = losses, + Survivors = attackers - losses + }; + } +} diff --git a/Assets/Scripts/Modules/Battle/BattleSystem.cs.meta b/Assets/Scripts/Modules/Battle/BattleSystem.cs.meta new file mode 100644 index 0000000..fed8631 --- /dev/null +++ b/Assets/Scripts/Modules/Battle/BattleSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 258ab05e5beedbd4588480adc03be0b6 \ No newline at end of file diff --git a/Assets/Scripts/Modules/Battle/Bogatyri.Battle.asmdef b/Assets/Scripts/Modules/Battle/Bogatyri.Battle.asmdef new file mode 100644 index 0000000..9ded4bb --- /dev/null +++ b/Assets/Scripts/Modules/Battle/Bogatyri.Battle.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Bogatyri.Battle", + "rootNamespace": "", + "references": ["Bogatyri.Core"], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/Assets/Scripts/Modules/Battle/Bogatyri.Battle.asmdef.meta b/Assets/Scripts/Modules/Battle/Bogatyri.Battle.asmdef.meta new file mode 100644 index 0000000..8cf2891 --- /dev/null +++ b/Assets/Scripts/Modules/Battle/Bogatyri.Battle.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5b5c1ae272a80b9418c2bca3b0d6aa50 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Core.Unity.meta b/Assets/Scripts/Modules/Core.Unity.meta new file mode 100644 index 0000000..4b69842 --- /dev/null +++ b/Assets/Scripts/Modules/Core.Unity.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 78fe3f117b017be48a5f870d88e6a2a4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Core.Unity/Bogatyri.Core.Unity.asmdef b/Assets/Scripts/Modules/Core.Unity/Bogatyri.Core.Unity.asmdef new file mode 100644 index 0000000..028731c --- /dev/null +++ b/Assets/Scripts/Modules/Core.Unity/Bogatyri.Core.Unity.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Bogatyri.Core.Unity", + "rootNamespace": "", + "references": ["Bogatyri.Core"], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Scripts/Modules/Core.Unity/Bogatyri.Core.Unity.asmdef.meta b/Assets/Scripts/Modules/Core.Unity/Bogatyri.Core.Unity.asmdef.meta new file mode 100644 index 0000000..8fba96e --- /dev/null +++ b/Assets/Scripts/Modules/Core.Unity/Bogatyri.Core.Unity.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 189d07a7182d0984599ea459f965a9ea +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Core.Unity/CoreUnityBootstrap.cs b/Assets/Scripts/Modules/Core.Unity/CoreUnityBootstrap.cs new file mode 100644 index 0000000..e8db7fb --- /dev/null +++ b/Assets/Scripts/Modules/Core.Unity/CoreUnityBootstrap.cs @@ -0,0 +1,16 @@ +using UnityEngine; + +// Unity-часть ядра: подключает чистое ядро (без Unity) к движку. +public static class CoreUnityBootstrap +{ + // Самый ранний момент запуска игры -- раньше любых Awake. + // Чистим статику: в редакторе с выключенной перезагрузкой домена она переживает прошлый Play. + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetCore() + { + GameServices.Clear(); + EventBus.Clear(); + SaveRegistry.Clear(); + EventBus.OnHandlerError = Debug.LogException; + } +} diff --git a/Assets/Scripts/Modules/Core.Unity/CoreUnityBootstrap.cs.meta b/Assets/Scripts/Modules/Core.Unity/CoreUnityBootstrap.cs.meta new file mode 100644 index 0000000..ff95393 --- /dev/null +++ b/Assets/Scripts/Modules/Core.Unity/CoreUnityBootstrap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e1017811ac1606e48b62e813e60ee061 \ No newline at end of file diff --git a/Assets/Scripts/Meta/Core/UIBlocker.cs b/Assets/Scripts/Modules/Core.Unity/UIBlocker.cs similarity index 100% rename from Assets/Scripts/Meta/Core/UIBlocker.cs rename to Assets/Scripts/Modules/Core.Unity/UIBlocker.cs diff --git a/Assets/Scripts/Meta/Core/UIBlocker.cs.meta b/Assets/Scripts/Modules/Core.Unity/UIBlocker.cs.meta similarity index 100% rename from Assets/Scripts/Meta/Core/UIBlocker.cs.meta rename to Assets/Scripts/Modules/Core.Unity/UIBlocker.cs.meta diff --git a/Assets/Scripts/Modules/Core.meta b/Assets/Scripts/Modules/Core.meta new file mode 100644 index 0000000..94653e5 --- /dev/null +++ b/Assets/Scripts/Modules/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f5a125ad92983cc4fad8c6163ebe05c8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Core/Bogatyri.Core.asmdef b/Assets/Scripts/Modules/Core/Bogatyri.Core.asmdef new file mode 100644 index 0000000..7e9cc39 --- /dev/null +++ b/Assets/Scripts/Modules/Core/Bogatyri.Core.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Bogatyri.Core", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/Assets/Scripts/Modules/Core/Bogatyri.Core.asmdef.meta b/Assets/Scripts/Modules/Core/Bogatyri.Core.asmdef.meta new file mode 100644 index 0000000..936ea81 --- /dev/null +++ b/Assets/Scripts/Modules/Core/Bogatyri.Core.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0e8277a21993ac64d82edc7a08e61d26 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Core/Contracts.meta b/Assets/Scripts/Modules/Core/Contracts.meta new file mode 100644 index 0000000..abc97f0 --- /dev/null +++ b/Assets/Scripts/Modules/Core/Contracts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d32ca5b87f2cfcb4f8a7bdd8e1bc06ef +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs new file mode 100644 index 0000000..4095265 --- /dev/null +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; + +// Контракты модулей: всё, что один модуль может попросить у другого. +// Реализация живёт в своём модуле, здесь -- только "что умеет". +// Добавляя метод сюда, обнови документ модуля в Docs/Modules. + +// ---------------- Экономика: ресурсы и производство ---------------- +public interface IEconomy +{ + float Get(ResourceType type); + bool Has(ResourceType type, float amount); + void Add(ResourceType type, float amount); + bool TrySpend(ResourceType type, float amount); + + bool CanAfford(IReadOnlyList cost); + // Списывает всё или ничего + bool TrySpend(IReadOnlyList cost); +} + +// ---------------- Поселение: здания и развитие города ---------------- +public interface ISettlement +{ + int TownHallLevel { get; } + int BuildingCount { get; } + bool IsCityLoaded { get; } // открыта ли сейчас сцена города +} + +// Здание с уровнем (добывающие постройки и т.п.) -- Поселение сохраняет уровень, не зная, чьё это здание +public interface ILeveledBuilding +{ + int Level { get; set; } +} + +// ---------------- Армия: войска ---------------- +public interface IArmy +{ + int SoldiersAtHome { get; } + // Забрать воинов из города в поход (запоминает состав похода) + bool TakeForMarch(int marchId, int count); + // Вернуть выживших из похода по тем же типам войск + void ReturnFromMarch(int marchId, int survivors); +} + +// ---------------- Глобальный мир: карта и перемещения ---------------- +public interface IWorld +{ + int ActiveMarches { get; } + bool TryStartMarch(string targetId, int soldiers, out string error); +} + +// ---------------- Боевая система: урон, потери, исход ---------------- +public struct BattleRequest +{ + public int Attackers; + public int Defenders; +} + +public struct BattleOutcome +{ + public bool Victory; // победил атакующий + public int AttackerLosses; + public int DefenderLosses; + public int Survivors; // сколько атакующих осталось +} + +public interface IBattleSystem +{ + BattleOutcome Resolve(BattleRequest request); +} + +// ---------------- Сохранения: игровое состояние ---------------- +public interface ISaveSystem +{ + bool HasSave { get; } + void SaveNow(); + void DeleteSave(); + // Новая игра: все модули возвращаются к стартовому состоянию, файл перезаписывается + void ResetAll(); +} + +// Участник сохранения: каждый модуль сам решает, что и как хранит, в своей секции файла. +public interface ISaveParticipant +{ + string SaveKey { get; } // имя секции в файле: "economy", "settlement", "world"... + string Capture(); // JSON секции; null -- оставить то, что уже было в файле + void Restore(string json); + void ResetState(); // вернуться к состоянию новой игры (сброс сохранений) +} + +// Список участников. Модули регистрируются сами, модуль Сохранений обходит их при записи и загрузке. +public static class SaveRegistry +{ + private static readonly List participants = new List(); + + public static IReadOnlyList All => participants; + + public static void Register(ISaveParticipant participant) + { + participants.RemoveAll(p => p.SaveKey == participant.SaveKey); + participants.Add(participant); + } + + public static void Unregister(ISaveParticipant participant) + { + participants.Remove(participant); + } + + public static void Clear() + { + participants.Clear(); + } +} diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs.meta b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs.meta new file mode 100644 index 0000000..ac6da18 --- /dev/null +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6232ac4b6b090c04e9897a596e5a1810 \ No newline at end of file diff --git a/Assets/Scripts/Modules/Core/EventBus.cs b/Assets/Scripts/Modules/Core/EventBus.cs new file mode 100644 index 0000000..98573e9 --- /dev/null +++ b/Assets/Scripts/Modules/Core/EventBus.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; + +// Шина событий: модуль сообщает "что случилось", не зная, кто это слушает. +// +// EventBus.Publish(new MarchReturnedEvent { ... }); // модуль Мира +// EventBus.Subscribe(OnMarchReturned); // задания, статистика, UI +// +// Подписчик обязан отписаться (OnDestroy / OnDisable), иначе получит событие уже уничтоженным. +public static class EventBus +{ + private static readonly Dictionary handlers = new Dictionary(); + + // Ошибка в одном подписчике не должна ломать остальных. Ядро не знает про Unity, + // поэтому куда писать ошибку, задаёт Core.Unity (там это Debug.LogException). + public static Action OnHandlerError; + + public static void Subscribe(Action handler) + { + handlers.TryGetValue(typeof(T), out Delegate existing); + handlers[typeof(T)] = Delegate.Combine(existing, handler); + } + + public static void Unsubscribe(Action handler) + { + if (!handlers.TryGetValue(typeof(T), out Delegate existing)) return; + Delegate remaining = Delegate.Remove(existing, handler); + if (remaining == null) handlers.Remove(typeof(T)); + else handlers[typeof(T)] = remaining; + } + + public static void Publish(T gameEvent) + { + if (!handlers.TryGetValue(typeof(T), out Delegate existing)) return; + + // Копия списка: подписчик может отписаться прямо во время рассылки + foreach (Delegate d in existing.GetInvocationList()) + { + try + { + ((Action)d)(gameEvent); + } + catch (Exception e) + { + OnHandlerError?.Invoke(e); + } + } + } + + public static void Clear() + { + handlers.Clear(); + } +} diff --git a/Assets/Scripts/Modules/Core/EventBus.cs.meta b/Assets/Scripts/Modules/Core/EventBus.cs.meta new file mode 100644 index 0000000..bc95c6a --- /dev/null +++ b/Assets/Scripts/Modules/Core/EventBus.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: db68092ecfa3d154e99d73096b844bb0 \ No newline at end of file diff --git a/Assets/Scripts/Modules/Core/Events.meta b/Assets/Scripts/Modules/Core/Events.meta new file mode 100644 index 0000000..9cd982f --- /dev/null +++ b/Assets/Scripts/Modules/Core/Events.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3cc9acbef2da16f44ae9d2766065c6d0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs new file mode 100644 index 0000000..aa2591c --- /dev/null +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -0,0 +1,55 @@ +// События, которыми модули сообщают друг другу о случившемся. +// Событие -- это факт в прошедшем времени ("ресурс изменился"), а не просьба что-то сделать. +// Добавляя событие, впиши его в документ модуля-отправителя (Docs/Modules). + +// ---------------- Экономика ---------------- +public struct ResourceChangedEvent +{ + public ResourceType Type; + public float Value; +} + +// ---------------- Поселение ---------------- +public struct BuildingPlacedEvent +{ + public string BuildingName; +} + +public struct TownHallLevelChangedEvent +{ + public int Level; +} + +// Поселение начинает восстанавливать здания из сохранения: самое время добавить в список стройки +// здания, которые появляются кодом (например, Казармы) +public struct SettlementRestoringEvent { } + +// ---------------- Глобальный мир ---------------- +public struct MarchStartedEvent +{ + public int MarchId; + public string TargetId; + public int Soldiers; +} + +public struct MarchBattleResolvedEvent +{ + public int MarchId; + public string TargetId; + public bool Victory; + public int Soldiers; + public int Survivors; +} + +public struct MarchReturnedEvent +{ + public int MarchId; + public bool Victory; + public int Survivors; +} + +// ---------------- Сохранения ---------------- +public struct GameLoadedEvent { } +public struct GameSavedEvent { } +// Сохранения сброшены, все модули уже в состоянии новой игры +public struct GameResetEvent { } diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs.meta b/Assets/Scripts/Modules/Core/Events/GameEvents.cs.meta new file mode 100644 index 0000000..dd95c1c --- /dev/null +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d6f60490c6ffb04caeb3a70906349bb \ No newline at end of file diff --git a/Assets/Scripts/Modules/Core/GameServices.cs b/Assets/Scripts/Modules/Core/GameServices.cs new file mode 100644 index 0000000..984b6ce --- /dev/null +++ b/Assets/Scripts/Modules/Core/GameServices.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; + +// Реестр модулей: модуль регистрирует свой контракт, остальные получают его отсюда. +// Так модули не ссылаются на классы друг друга: Армия знает про IEconomy, но не про ResourceManager. +// +// GameServices.Register(this); // в модуле Экономики +// GameServices.Get().TrySpend(ResourceType.Food, 15); // в любом другом модуле +public static class GameServices +{ + private static readonly Dictionary services = new Dictionary(); + + public static void Register(T service) where T : class + { + services[typeof(T)] = service ?? throw new ArgumentNullException(nameof(service)); + } + + // Снимает регистрацию, только если зарегистрирован именно этот экземпляр + // (дубликат синглтона, уничтожаясь, не должен снять настоящий) + public static void Unregister(T service) where T : class + { + if (services.TryGetValue(typeof(T), out object current) && ReferenceEquals(current, service)) + services.Remove(typeof(T)); + } + + public static T Get() where T : class + { + if (services.TryGetValue(typeof(T), out object service)) return (T)service; + throw new InvalidOperationException( + $"Модуль {typeof(T).Name} не зарегистрирован. Проверь, что он есть на сцене или в App/GameModules."); + } + + public static bool TryGet(out T service) where T : class + { + if (services.TryGetValue(typeof(T), out object found)) + { + service = (T)found; + return true; + } + service = null; + return false; + } + + public static bool Has() where T : class + { + return services.ContainsKey(typeof(T)); + } + + // Полная очистка -- при старте игры (в редакторе статика может пережить прошлый запуск) + public static void Clear() + { + services.Clear(); + } +} diff --git a/Assets/Scripts/Modules/Core/GameServices.cs.meta b/Assets/Scripts/Modules/Core/GameServices.cs.meta new file mode 100644 index 0000000..f26e8a1 --- /dev/null +++ b/Assets/Scripts/Modules/Core/GameServices.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a565fc9a2c0580a46b91c4cb10d49c75 \ No newline at end of file diff --git a/Assets/Scripts/Modules/Core/ResourceType.cs b/Assets/Scripts/Modules/Core/ResourceType.cs new file mode 100644 index 0000000..28cffa9 --- /dev/null +++ b/Assets/Scripts/Modules/Core/ResourceType.cs @@ -0,0 +1,27 @@ +// Типы ресурсов игры. Лежат в ядре, потому что о ресурсах знают почти все модули +// (стоимость построек, найм войск, добыча в походах). +// Числовые значения НЕ менять: они записаны в сохранениях. +public enum ResourceType +{ + Population = 0, // люди + Food = 1, // еда + Wood = 2, // дерево + Stone = 3, // камень + Iron = 4, // металл + Gold = 5, // золото + Gems = 6, // кристаллы + Power = 7 // "сила" -- как в референсе HUD +} + +// Количество одного ресурса: цена, награда, добыча +public struct ResourceAmount +{ + public ResourceType Type; + public float Amount; + + public ResourceAmount(ResourceType type, float amount) + { + Type = type; + Amount = amount; + } +} diff --git a/Assets/Scripts/Modules/Core/ResourceType.cs.meta b/Assets/Scripts/Modules/Core/ResourceType.cs.meta new file mode 100644 index 0000000..c1d26ed --- /dev/null +++ b/Assets/Scripts/Modules/Core/ResourceType.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 15600a3fb45578a48afb3d5443890368 \ No newline at end of file diff --git a/Assets/Scripts/Modules/Economy.meta b/Assets/Scripts/Modules/Economy.meta new file mode 100644 index 0000000..f080117 --- /dev/null +++ b/Assets/Scripts/Modules/Economy.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bf7925db56b0b99438ee9c9a2ae14d43 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Economy/Bogatyri.Economy.asmdef b/Assets/Scripts/Modules/Economy/Bogatyri.Economy.asmdef new file mode 100644 index 0000000..84dcb4e --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Bogatyri.Economy.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Bogatyri.Economy", + "rootNamespace": "", + "references": ["Bogatyri.Core", "Bogatyri.Core.Unity"], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Scripts/Modules/Economy/Bogatyri.Economy.asmdef.meta b/Assets/Scripts/Modules/Economy/Bogatyri.Economy.asmdef.meta new file mode 100644 index 0000000..377ee48 --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Bogatyri.Economy.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d0d3d05504183fb40ba7f1ccfe994fa2 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Economy/ResourceCollector.cs b/Assets/Scripts/Modules/Economy/ResourceCollector.cs similarity index 86% rename from Assets/Scripts/Economy/ResourceCollector.cs rename to Assets/Scripts/Modules/Economy/ResourceCollector.cs index 44bb2dc..4413aaa 100644 --- a/Assets/Scripts/Economy/ResourceCollector.cs +++ b/Assets/Scripts/Modules/Economy/ResourceCollector.cs @@ -1,7 +1,11 @@ using UnityEngine; -public class ResourceCollector : MonoBehaviour +// Модуль Экономики: временная добыча по клику (уйдёт в этапе 3 -- производство через рабочих). +public class ResourceCollector : MonoBehaviour, ILeveledBuilding { + // Уровень здания для Поселения (оно сохраняет уровни, не зная, что это за здание) + public int Level { get => level; set => level = value; } + [Header("Что производит это здание")] public ResourceType resourceType; diff --git a/Assets/Scripts/Economy/ResourceCollector.cs.meta b/Assets/Scripts/Modules/Economy/ResourceCollector.cs.meta similarity index 100% rename from Assets/Scripts/Economy/ResourceCollector.cs.meta rename to Assets/Scripts/Modules/Economy/ResourceCollector.cs.meta diff --git a/Assets/Scripts/Modules/Economy/ResourceManager.cs b/Assets/Scripts/Modules/Economy/ResourceManager.cs new file mode 100644 index 0000000..cde6960 --- /dev/null +++ b/Assets/Scripts/Modules/Economy/ResourceManager.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +// МОДУЛЬ ЭКОНОМИКИ. Центральное хранилище всех ресурсов игрока. +// Другие модули работают с ним только через контракт IEconomy (GameServices.Get()), +// а изменения узнают из события ResourceChangedEvent. Документ: Docs/Modules/Economy.md +// +// Старый доступ ResourceManager.Instance оставлен для интерфейса (панели, HUD) -- модулям им пользоваться нельзя. +public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant +{ + public static ResourceManager Instance { get; private set; } + + // Событие для интерфейса (HUD). Модули слушают ResourceChangedEvent через EventBus. + public event Action OnResourceChanged; + + [Header("Стартовые значения (для теста/баланса)")] + public float startWood = 0f; + public float startStone = 0f; + public float startIron = 0f; + public float startFood = 0f; + public float startGold = 0f; + public float startGems = 0f; + public float startPower = 0f; + public float startPopulation = 0f; + + private Dictionary resources = new Dictionary(); + + void Awake() + { + // Классический паттерн Singleton: если уже есть экземпляр -- уничтожаем дубликат + if (Instance != null && Instance != this) + { + Destroy(gameObject); + return; + } + Instance = this; + DontDestroyOnLoad(gameObject); // менеджер ресурсов должен жить между сценами (меню -> игра) + + SetStartValues(notify: false); + + GameServices.Register(this); + SaveRegistry.Register(this); + } + + void OnDestroy() + { + if (Instance != this) return; + GameServices.Unregister(this); + SaveRegistry.Unregister(this); + } + + // Добавить ресурс (можно и отрицательное число -- для трат) + public void AddResource(ResourceType type, float amount) + { + if (!resources.ContainsKey(type)) + resources[type] = 0f; + + resources[type] += amount; + if (resources[type] < 0f) resources[type] = 0f; // ресурс не уходит в минус + + NotifyChanged(type); + } + + // Проверить, хватает ли ресурса (для трат/построек в будущем) + public bool HasEnough(ResourceType type, float amount) + { + return GetResource(type) >= amount; + } + + // Попробовать потратить -- вернёт false, если не хватило (и ничего не спишет) + public bool TrySpend(ResourceType type, float amount) + { + if (!HasEnough(type, amount)) return false; + AddResource(type, -amount); + return true; + } + + public float GetResource(ResourceType type) + { + return resources.TryGetValue(type, out float value) ? value : 0f; + } + + // Устанавливает значение напрямую, БЕЗ проверок и без учёта прошлого значения. + // Используется только при загрузке сохранения -- обычная игровая логика должна + // использовать AddResource/TrySpend, а не этот метод. + public void SetResource(ResourceType type, float value) + { + resources[type] = value; + NotifyChanged(type); + } + + // Все ресурсы разом -- удобно для сохранения + public Dictionary GetAllResources() + { + return new Dictionary(resources); + } + + void SetStartValues(bool notify) + { + var start = new Dictionary + { + { ResourceType.Wood, startWood }, + { ResourceType.Stone, startStone }, + { ResourceType.Iron, startIron }, + { ResourceType.Food, startFood }, + { ResourceType.Gold, startGold }, + { ResourceType.Gems, startGems }, + { ResourceType.Power, startPower }, + { ResourceType.Population, startPopulation }, + }; + resources.Clear(); + foreach (var kvp in start) + { + resources[kvp.Key] = kvp.Value; + if (notify) NotifyChanged(kvp.Key); + } + } + + void NotifyChanged(ResourceType type) + { + float value = resources[type]; + OnResourceChanged?.Invoke(type, value); + EventBus.Publish(new ResourceChangedEvent { Type = type, Value = value }); + } + + // ---------------- Контракт IEconomy ---------------- + + float IEconomy.Get(ResourceType type) => GetResource(type); + bool IEconomy.Has(ResourceType type, float amount) => HasEnough(type, amount); + void IEconomy.Add(ResourceType type, float amount) => AddResource(type, amount); + + public bool CanAfford(IReadOnlyList cost) + { + foreach (var c in cost) + { + if (!HasEnough(c.Type, c.Amount)) return false; + } + return true; + } + + public bool TrySpend(IReadOnlyList cost) + { + if (!CanAfford(cost)) return false; + foreach (var c in cost) AddResource(c.Type, -c.Amount); + return true; + } + + // ---------------- Сохранение (секция "economy") ---------------- + + [Serializable] + class ResourceSaveEntry + { + public ResourceType type; + public float amount; + } + + // Имена полей совпадают со старым save.json -- старые сохранения читаются без переделки + [Serializable] + class EconomySaveData + { + public List resources = new List(); + } + + public string SaveKey => "economy"; + + public string Capture() + { + var data = new EconomySaveData(); + foreach (var kvp in resources) + data.resources.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value }); + return JsonUtility.ToJson(data); + } + + public void Restore(string json) + { + var data = JsonUtility.FromJson(json); + if (data?.resources == null) return; + foreach (var entry in data.resources) SetResource(entry.type, entry.amount); + } + + // Новая игра -- стартовые значения из Inspector + public void ResetState() + { + SetStartValues(notify: true); + } +} diff --git a/Assets/Scripts/Economy/ResourceManager.cs.meta b/Assets/Scripts/Modules/Economy/ResourceManager.cs.meta similarity index 100% rename from Assets/Scripts/Economy/ResourceManager.cs.meta rename to Assets/Scripts/Modules/Economy/ResourceManager.cs.meta diff --git a/Assets/Scripts/Economy/ResourceProducer.cs b/Assets/Scripts/Modules/Economy/ResourceProducer.cs similarity index 100% rename from Assets/Scripts/Economy/ResourceProducer.cs rename to Assets/Scripts/Modules/Economy/ResourceProducer.cs diff --git a/Assets/Scripts/Economy/ResourceProducer.cs.meta b/Assets/Scripts/Modules/Economy/ResourceProducer.cs.meta similarity index 100% rename from Assets/Scripts/Economy/ResourceProducer.cs.meta rename to Assets/Scripts/Modules/Economy/ResourceProducer.cs.meta diff --git a/Assets/Scripts/Modules/Save.meta b/Assets/Scripts/Modules/Save.meta new file mode 100644 index 0000000..c50f382 --- /dev/null +++ b/Assets/Scripts/Modules/Save.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0c3352a3c46136049aabd5fdfe2bd415 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Save/Bogatyri.Save.asmdef b/Assets/Scripts/Modules/Save/Bogatyri.Save.asmdef new file mode 100644 index 0000000..8494eb0 --- /dev/null +++ b/Assets/Scripts/Modules/Save/Bogatyri.Save.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Bogatyri.Save", + "rootNamespace": "", + "references": ["Bogatyri.Core", "Bogatyri.Core.Unity"], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Scripts/Modules/Save/Bogatyri.Save.asmdef.meta b/Assets/Scripts/Modules/Save/Bogatyri.Save.asmdef.meta new file mode 100644 index 0000000..3efab7e --- /dev/null +++ b/Assets/Scripts/Modules/Save/Bogatyri.Save.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: fd66e6fd0a173ec45ac2ed27bc05e617 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Other/GameBootstrap.cs b/Assets/Scripts/Modules/Save/GameBootstrap.cs similarity index 90% rename from Assets/Scripts/Other/GameBootstrap.cs rename to Assets/Scripts/Modules/Save/GameBootstrap.cs index b74ad13..c29330f 100644 --- a/Assets/Scripts/Other/GameBootstrap.cs +++ b/Assets/Scripts/Modules/Save/GameBootstrap.cs @@ -1,5 +1,6 @@ using UnityEngine; +// МОДУЛЬ СОХРАНЕНИЙ. Документ: Docs/Modules/Save.md // Вешается на любой объект, который есть в игровой сцене с самого начала // (например, на сам SaveManager или на ResourceManager). // При старте сцены пытается загрузить сохранение, если оно есть. diff --git a/Assets/Scripts/Other/GameBootstrap.cs.meta b/Assets/Scripts/Modules/Save/GameBootstrap.cs.meta similarity index 100% rename from Assets/Scripts/Other/GameBootstrap.cs.meta rename to Assets/Scripts/Modules/Save/GameBootstrap.cs.meta diff --git a/Assets/Scripts/Modules/Save/SaveManager.cs b/Assets/Scripts/Modules/Save/SaveManager.cs new file mode 100644 index 0000000..a09ce7d --- /dev/null +++ b/Assets/Scripts/Modules/Save/SaveManager.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine; + +// МОДУЛЬ СОХРАНЕНИЙ: игровое состояние. Документ: Docs/Modules/Save.md +// +// Модуль не знает, ЧТО сохраняют другие модули: каждый модуль сам регистрирует себя в SaveRegistry +// (ISaveParticipant) и отдаёт свою секцию -- "economy", "settlement", "world" и т.д. +// +// Формат save.json, версия 2: { version: 2, sections: [ { key, json }, ... ] }. +// Версия 1 (старый плоский файл: resources, townHallLevel, placedBuildings) читается автоматически -- +// имена полей в секциях Экономики и Поселения совпадают со старыми. +// +// Файл лежит в Application.persistentDataPath -- папка переживает переустановку/обновление игры на телефоне. +public class SaveManager : MonoBehaviour, ISaveSystem +{ + public const int CurrentVersion = 2; + + public static SaveManager Instance { get; private set; } + + [Header("Автосохранение")] + [Tooltip("Как часто автоматически сохранять прогресс, в секундах")] + public float autoSaveInterval = 30f; + + [Serializable] + class SaveSection + { + public string key; + public string json; + } + + [Serializable] + class SaveFile + { + public int version; + public List sections = new List(); + } + + private string SavePath => Path.Combine(Application.persistentDataPath, "save.json"); + + // Последние известные секции: секция модуля, которого сейчас нет (например, город на карте мира), + // не должна пропасть из файла при следующем сохранении + private readonly Dictionary sections = new Dictionary(); + private bool loaded; // сохранение уже загружено (или файла нет) -- только после этого можно писать + + void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(gameObject); + return; + } + Instance = this; + DontDestroyOnLoad(gameObject); + GameServices.Register(this); + } + + void OnDestroy() + { + if (Instance == this) GameServices.Unregister(this); + } + + void Start() + { + InvokeRepeating(nameof(SaveGame), autoSaveInterval, autoSaveInterval); + } + + // Мобильные ОС часто "убивают" приложение без вызова OnApplicationQuit, + // поэтому OnApplicationPause -- самый надёжный момент для сохранения на телефоне. + void OnApplicationPause(bool pauseStatus) + { + if (pauseStatus) SaveGame(); + } + + void OnApplicationQuit() + { + SaveGame(); + } + + // ---------------- Контракт ISaveSystem ---------------- + + public bool HasSave => HasSaveFile(); + public void SaveNow() => SaveGame(); + + // ---------------- СОХРАНЕНИЕ ---------------- + + public void SaveGame() + { + // Запись раньше загрузки затёрла бы файл стартовыми значениями модулей (ресурсы ещё нулевые) + if (!loaded && HasSaveFile()) + { + Debug.LogWarning("[СОХРАНЕНИЕ] Пропущено: сохранение ещё не загружено."); + return; + } + loaded = true; + + foreach (var participant in SaveRegistry.All) + { + string json = participant.Capture(); + if (json != null) sections[participant.SaveKey] = json; // null -- модуль сейчас не может сохраниться + } + + var file = new SaveFile { version = CurrentVersion }; + foreach (var kvp in sections) file.sections.Add(new SaveSection { key = kvp.Key, json = kvp.Value }); + + File.WriteAllText(SavePath, JsonUtility.ToJson(file, true)); + EventBus.Publish(new GameSavedEvent()); + Debug.Log($"[СОХРАНЕНИЕ] Игра сохранена: {SavePath}"); + } + + // ---------------- ЗАГРУЗКА ---------------- + + public bool HasSaveFile() + { + return File.Exists(SavePath); + } + + public void LoadGame() + { + loaded = true; + if (!HasSaveFile()) + { + Debug.Log("[ЗАГРУЗКА] Файл сохранения не найден, начинаем новую игру."); + return; + } + + string text = File.ReadAllText(SavePath); + ReadSections(text); + + foreach (var participant in SaveRegistry.All) + { + if (sections.TryGetValue(participant.SaveKey, out string json)) + participant.Restore(json); + } + + EventBus.Publish(new GameLoadedEvent()); + Debug.Log($"[ЗАГРУЗКА] Игра загружена, секций: {sections.Count}"); + } + + void ReadSections(string text) + { + sections.Clear(); + SaveFile file = JsonUtility.FromJson(text); + + if (file != null && file.version >= 2) + { + foreach (var s in file.sections) sections[s.key] = s.json; + return; + } + + // Версия 1: старый плоский файл. Отдаём его целиком Экономике и Поселению -- + // их секции читают свои поля (resources / townHallLevel, placedBuildings) по старым именам. + sections["economy"] = text; + sections["settlement"] = text; + Debug.Log("[ЗАГРУЗКА] Старый формат save.json (версия 1) -- перенесён в секции."); + } + + // СБРОС СОХРАНЕНИЙ: новая игра. Каждый модуль сам возвращается к старту, файл пишется заново. + // GameProgress (progress.json) сбрасывает себя сам -- см. GameProgress.ResetAll. + public void ResetAll() + { + DeleteSave(); + foreach (var participant in SaveRegistry.All) participant.ResetState(); + + loaded = true; + SaveGame(); // сразу записать новую игру, чтобы старый прогресс не вернулся после перезапуска + EventBus.Publish(new GameResetEvent()); + Debug.Log("[СОХРАНЕНИЕ] Сохранения сброшены, начата новая игра."); + } + + // Только стирает файл сохранения (состояние модулей в памяти не трогает) + public void DeleteSave() + { + sections.Clear(); + if (File.Exists(SavePath)) + { + File.Delete(SavePath); + Debug.Log("[СОХРАНЕНИЕ] Файл сохранения удалён."); + } + } +} diff --git a/Assets/Scripts/Other/SaveManager.cs.meta b/Assets/Scripts/Modules/Save/SaveManager.cs.meta similarity index 100% rename from Assets/Scripts/Other/SaveManager.cs.meta rename to Assets/Scripts/Modules/Save/SaveManager.cs.meta diff --git a/Assets/Scripts/Modules/Settlement.meta b/Assets/Scripts/Modules/Settlement.meta new file mode 100644 index 0000000..b477dad --- /dev/null +++ b/Assets/Scripts/Modules/Settlement.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 042a753badc24aa4689e3b461e1cbd40 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Settlement/Bogatyri.Settlement.asmdef b/Assets/Scripts/Modules/Settlement/Bogatyri.Settlement.asmdef new file mode 100644 index 0000000..8b25ac9 --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/Bogatyri.Settlement.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Bogatyri.Settlement", + "rootNamespace": "", + "references": ["Bogatyri.Core", "Bogatyri.Core.Unity"], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Scripts/Modules/Settlement/Bogatyri.Settlement.asmdef.meta b/Assets/Scripts/Modules/Settlement/Bogatyri.Settlement.asmdef.meta new file mode 100644 index 0000000..6ac3b9f --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/Bogatyri.Settlement.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 76e5cc16f8a054f4b8e9d167047cbc74 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Construction of buildings/BuildManager.cs b/Assets/Scripts/Modules/Settlement/BuildManager.cs similarity index 94% rename from Assets/Scripts/Construction of buildings/BuildManager.cs rename to Assets/Scripts/Modules/Settlement/BuildManager.cs index f899218..25e1c86 100644 --- a/Assets/Scripts/Construction of buildings/BuildManager.cs +++ b/Assets/Scripts/Modules/Settlement/BuildManager.cs @@ -1,6 +1,7 @@ using UnityEngine; using System.Collections.Generic; +// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md // Управляет режимом строительства. Теперь здание можно поставить ТОЛЬКО // на заранее подготовленный участок (BuildSlot) -- свободное размещение убрано. // Наведение на пустой слот -> подсветка (можно строить). @@ -35,6 +36,12 @@ public class BuildManager : MonoBehaviour Instance = this; mainCam = Camera.main; } + + void Start() + { + // Повторный вход в город (например, с карты мира): здания восстанавливаем из памяти модуля + SettlementModule.ApplyToScene(); + } void Update() { @@ -124,6 +131,7 @@ public class BuildManager : MonoBehaviour link.linkedSlot = slot; Debug.Log($"[СТРОЙКА] Построено: {currentlyPlacing.buildingName} на участке {slot.name}"); + EventBus.Publish(new BuildingPlacedEvent { BuildingName = currentlyPlacing.buildingName }); currentlyPlacing = null; lastHighlightedSlot = null; diff --git a/Assets/Scripts/Construction of buildings/BuildManager.cs.meta b/Assets/Scripts/Modules/Settlement/BuildManager.cs.meta similarity index 100% rename from Assets/Scripts/Construction of buildings/BuildManager.cs.meta rename to Assets/Scripts/Modules/Settlement/BuildManager.cs.meta diff --git a/Assets/Scripts/Other/BuildSlot.cs b/Assets/Scripts/Modules/Settlement/BuildSlot.cs similarity index 100% rename from Assets/Scripts/Other/BuildSlot.cs rename to Assets/Scripts/Modules/Settlement/BuildSlot.cs diff --git a/Assets/Scripts/Other/BuildSlot.cs.meta b/Assets/Scripts/Modules/Settlement/BuildSlot.cs.meta similarity index 100% rename from Assets/Scripts/Other/BuildSlot.cs.meta rename to Assets/Scripts/Modules/Settlement/BuildSlot.cs.meta diff --git a/Assets/Scripts/Construction of buildings/BuildingData.cs b/Assets/Scripts/Modules/Settlement/BuildingData.cs similarity index 61% rename from Assets/Scripts/Construction of buildings/BuildingData.cs rename to Assets/Scripts/Modules/Settlement/BuildingData.cs index 6e8571b..8fbf111 100644 --- a/Assets/Scripts/Construction of buildings/BuildingData.cs +++ b/Assets/Scripts/Modules/Settlement/BuildingData.cs @@ -20,26 +20,24 @@ public class BuildingData : ScriptableObject [Header("Стоимость")] public ResourceCost[] cost; - // Проверка - хватает ли ресурсов? (только проверяет) - public bool CanAfford() + // Проверка - хватает ли ресурсов? (только проверяет) + // Ресурсы -- модуль Экономики, поэтому только через контракт IEconomy + public bool CanAfford() { - if (ResourceManager.Instance == null) return false; - - foreach (var c in cost) - { - if (!ResourceManager.Instance.HasEnough(c.type, c.amount)) - return false; - } - return true; + return GameServices.TryGet(out IEconomy economy) && economy.CanAfford(ToAmounts(cost)); } // Списывает стоимость постройки. Вызывать только после CanAfford() == true public void PayCost() { - foreach (var c in cost) - { - ResourceManager.Instance.TrySpend(c.type, c.amount); - } + if (GameServices.TryGet(out IEconomy economy)) economy.TrySpend(ToAmounts(cost)); + } + + public static ResourceAmount[] ToAmounts(ResourceCost[] costs) + { + var result = new ResourceAmount[costs?.Length ?? 0]; + for (int i = 0; i < result.Length; i++) result[i] = new ResourceAmount(costs[i].type, costs[i].amount); + return result; } // Текст стоимости для UI, например "50 Wood, 20 Stone" diff --git a/Assets/Scripts/Construction of buildings/BuildingData.cs.meta b/Assets/Scripts/Modules/Settlement/BuildingData.cs.meta similarity index 100% rename from Assets/Scripts/Construction of buildings/BuildingData.cs.meta rename to Assets/Scripts/Modules/Settlement/BuildingData.cs.meta diff --git a/Assets/Scripts/Construction of buildings/PlaceBuilding.cs b/Assets/Scripts/Modules/Settlement/PlacedBuildingId.cs similarity index 100% rename from Assets/Scripts/Construction of buildings/PlaceBuilding.cs rename to Assets/Scripts/Modules/Settlement/PlacedBuildingId.cs diff --git a/Assets/Scripts/Construction of buildings/PlaceBuilding.cs.meta b/Assets/Scripts/Modules/Settlement/PlacedBuildingId.cs.meta similarity index 100% rename from Assets/Scripts/Construction of buildings/PlaceBuilding.cs.meta rename to Assets/Scripts/Modules/Settlement/PlacedBuildingId.cs.meta diff --git a/Assets/Scripts/Modules/Settlement/SettlementModule.cs b/Assets/Scripts/Modules/Settlement/SettlementModule.cs new file mode 100644 index 0000000..18c9370 --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/SettlementModule.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +// МОДУЛЬ ПОСЕЛЕНИЯ: точка входа. Реализует ISettlement и сам сохраняет город (секция "settlement"). +// Документ: Docs/Modules/Settlement.md +// +// Город живёт на сцене MVP1, а сохраняться игра может и на карте мира. Поэтому модуль держит +// последнее известное состояние города в памяти: на карте мира сохраняется оно, а не пустая сцена. +public static class SettlementModule +{ + [Serializable] + public class PlacedBuildingSaveEntry + { + public string buildingName; // совпадает с BuildingData.buildingName + public float posX; + public float posY; + public int level; + } + + // Имена полей совпадают со старым save.json -- старые сохранения читаются без переделки + [Serializable] + public class SettlementSaveData + { + public int townHallLevel = 1; + public List placedBuildings = new List(); + } + + static SettlementSaveData cached; // последнее состояние города (из сцены или из файла) + static BuildManager appliedToBuildManager; // BuildManager сцены, для которой город уже восстановлен + static readonly Service service = new Service(); + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + static void Init() + { + cached = null; + appliedToBuildManager = null; + GameServices.Register(service); + SaveRegistry.Register(service); + } + + static bool CityLoaded => BuildManager.Instance != null; + + // ---------------- Сцена города <-> данные ---------------- + + static SettlementSaveData CaptureScene() + { + var data = new SettlementSaveData(); + if (TownHall.Instance != null) data.townHallLevel = TownHall.Instance.currentLevel; + + foreach (var placed in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + { + if (string.IsNullOrEmpty(placed.buildingName)) continue; // уже убрано, Destroy сработает в конце кадра + var leveled = placed.GetComponent(); + data.placedBuildings.Add(new PlacedBuildingSaveEntry + { + buildingName = placed.buildingName, + posX = placed.transform.position.x, + posY = placed.transform.position.y, + level = leveled != null ? leveled.Level : 1 + }); + } + return data; + } + + // Восстановить город на сцене из последнего известного состояния. + // Без force -- один раз на каждую загрузку сцены города (BuildManager.Start и загрузка сохранения + // могут прийти в любом порядке, повторное восстановление не нужно). + public static void ApplyToScene(bool force = false) + { + if (cached == null || !CityLoaded) return; + if (!force && appliedToBuildManager == BuildManager.Instance) return; + appliedToBuildManager = BuildManager.Instance; + + EventBus.Publish(new SettlementRestoringEvent()); // например, Казармы должны попасть в список стройки + + if (TownHall.Instance != null) TownHall.Instance.currentLevel = cached.townHallLevel; + + // Сначала убираем уже заспавненные здания, чтобы не задвоить постройки + ClearPlacedBuildings(); + + int restored = 0; + foreach (var entry in cached.placedBuildings) + { + BuildingData matchingData = FindBuildingDataByName(entry.buildingName); + if (matchingData == null) + { + Debug.LogWarning($"[ПОСЕЛЕНИЕ] Не найден BuildingData для '{entry.buildingName}', здание пропущено."); + continue; + } + + Vector3 pos = new Vector3(entry.posX, entry.posY, 0f); + GameObject built = UnityEngine.Object.Instantiate(matchingData.prefab, pos, Quaternion.identity); + if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным + + built.AddComponent().buildingName = entry.buildingName; + + var leveled = built.GetComponent(); + if (leveled != null) leveled.Level = entry.level; + + // Помечаем слот занятым, чтобы поверх нельзя было поставить ещё одно здание + BuildSlot slot = BuildManager.Instance.FindSlotAtPosition(pos); + if (slot != null) + { + slot.Occupy(built); + built.AddComponent().linkedSlot = slot; + } + restored++; + } + + Debug.Log($"[ПОСЕЛЕНИЕ] Город восстановлен. Построек: {restored}"); + } + + // Убрать все постройки со сцены. Участок освобождаем сразу: Destroy срабатывает в конце кадра, + // и иначе старое здание освободило бы участок, на который уже встало новое + static void ClearPlacedBuildings() + { + foreach (var e in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + { + var link = e.GetComponent(); + if (link != null && link.linkedSlot != null) + { + link.linkedSlot.Free(); + link.linkedSlot = null; + } + e.buildingName = null; // чтобы сохранение в этом же кадре уже не видело здание + UnityEngine.Object.Destroy(e.gameObject); + } + } + + static BuildingData FindBuildingDataByName(string buildingName) + { + foreach (var b in BuildManager.Instance.allBuildings) + { + if (b != null && b.buildingName == buildingName) return b; + } + return null; + } + + // ---------------- Контракты ---------------- + + class Service : ISettlement, ISaveParticipant + { + public int TownHallLevel => + TownHall.Instance != null ? TownHall.Instance.currentLevel : (cached?.townHallLevel ?? 1); + + public int BuildingCount => + CityLoaded + ? UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None).Length + : (cached?.placedBuildings.Count ?? 0); + + public bool IsCityLoaded => CityLoaded; + + public string SaveKey => "settlement"; + + public string Capture() + { + if (CityLoaded) cached = CaptureScene(); + // Город не открыт и ни разу не загружался -- не трогаем то, что уже лежит в файле + return cached != null ? JsonUtility.ToJson(cached) : null; + } + + // Новая игра: Ратуша 1 уровня, построек нет -- и на сцене, и в памяти модуля + public void ResetState() + { + cached = new SettlementSaveData(); + if (!CityLoaded) return; + + ClearPlacedBuildings(); + if (TownHall.Instance != null) TownHall.Instance.currentLevel = 1; + appliedToBuildManager = BuildManager.Instance; // город уже в состоянии новой игры + EventBus.Publish(new TownHallLevelChangedEvent { Level = 1 }); + } + + public void Restore(string json) + { + cached = JsonUtility.FromJson(json) ?? new SettlementSaveData(); + if (cached.placedBuildings == null) cached.placedBuildings = new List(); + ApplyToScene(force: true); + } + } +} diff --git a/Assets/Scripts/Modules/Settlement/SettlementModule.cs.meta b/Assets/Scripts/Modules/Settlement/SettlementModule.cs.meta new file mode 100644 index 0000000..5c37cfa --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/SettlementModule.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 146dd14fec3d97249bc58c96f748d78b \ No newline at end of file diff --git a/Assets/Scripts/Construction of buildings/NewEmptyCSharpScript.cs b/Assets/Scripts/Modules/Settlement/TownHall.cs similarity index 86% rename from Assets/Scripts/Construction of buildings/NewEmptyCSharpScript.cs rename to Assets/Scripts/Modules/Settlement/TownHall.cs index 5d26456..5fa101b 100644 --- a/Assets/Scripts/Construction of buildings/NewEmptyCSharpScript.cs +++ b/Assets/Scripts/Modules/Settlement/TownHall.cs @@ -1,5 +1,6 @@ using UnityEngine; +// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md // Вешается на объект Ратуши, который уже стоит на карте. // Хранит текущий уровень -- от него зависит, какие здания разблокированы в BuildManager. @@ -46,14 +47,7 @@ public class TownHall : MonoBehaviour public bool CanUpgrade() { if (currentLevel >= maxLevel) return false; - if (ResourceManager.Instance == null) return false; - - foreach (var c in GetUpgradeCost()) - { - if (!ResourceManager.Instance.HasEnough(c.type, c.amount)) - return false; - } - return true; + return GameServices.TryGet(out IEconomy economy) && economy.CanAfford(BuildingData.ToAmounts(GetUpgradeCost())); } public void Upgrade() @@ -64,12 +58,10 @@ public class TownHall : MonoBehaviour return; } - foreach (var c in GetUpgradeCost()) - { - ResourceManager.Instance.TrySpend(c.type, c.amount); - } + GameServices.Get().TrySpend(BuildingData.ToAmounts(GetUpgradeCost())); currentLevel++; + EventBus.Publish(new TownHallLevelChangedEvent { Level = currentLevel }); Debug.Log($"[РАТУША] Улучшена до уровня {currentLevel}!"); } void OnMouseDown() diff --git a/Assets/Scripts/Construction of buildings/NewEmptyCSharpScript.cs.meta b/Assets/Scripts/Modules/Settlement/TownHall.cs.meta similarity index 100% rename from Assets/Scripts/Construction of buildings/NewEmptyCSharpScript.cs.meta rename to Assets/Scripts/Modules/Settlement/TownHall.cs.meta diff --git a/Assets/Scripts/Modules/Tests.meta b/Assets/Scripts/Modules/Tests.meta new file mode 100644 index 0000000..669ba60 --- /dev/null +++ b/Assets/Scripts/Modules/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7bebbb8fb5871d042952add239421e77 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef b/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef new file mode 100644 index 0000000..6e465cf --- /dev/null +++ b/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef @@ -0,0 +1,27 @@ +{ + "name": "Bogatyri.Tests", + "rootNamespace": "", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "Bogatyri.Core", + "Bogatyri.Army", + "Bogatyri.Battle", + "Bogatyri.World.Simulation" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef.meta b/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef.meta new file mode 100644 index 0000000..e21744b --- /dev/null +++ b/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9767446f701fef9438c4c4d23080f5a8 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs new file mode 100644 index 0000000..e7e9628 --- /dev/null +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using Bogatyri.World; +using NUnit.Framework; + +// Тесты модулей без Unity-сцены. Запуск: Window -> General -> Test Runner -> EditMode -> Run All. +// Список тестов, которые команда решила добавить (план от 19.10.2026), -- в Docs/Modules/README.md. +public class ModuleTests +{ + [SetUp] + public void ResetCore() + { + GameServices.Clear(); + EventBus.Clear(); + SaveRegistry.Clear(); + } + + // ---------------- Армия: один солдат не может быть в двух местах ---------------- + + [Test] + public void ArmyMath_TakeProportional_NeverDoubleCountsOrLosesSoldiers() + { + var rnd = new Random(1); + for (int run = 0; run < 2000; run++) + { + var troops = new int[4]; + int total = 0; + for (int i = 0; i < 4; i++) { troops[i] = rnd.Next(0, 50); total += troops[i]; } + if (total == 0) continue; + + int count = rnd.Next(1, total + 1); + int[] taken = ArmyMath.TakeProportional(troops, count); + + int sum = 0; + for (int i = 0; i < 4; i++) + { + Assert.That(taken[i], Is.InRange(0, troops[i]), "нельзя взять больше, чем есть в типе"); + sum += taken[i]; + } + Assert.AreEqual(count, sum, "в поход уходит ровно столько, сколько просили"); + } + } + + [Test] + public void ArmyMath_TakeMoreThanExists_ReturnsNull() + { + Assert.IsNull(ArmyMath.TakeProportional(new[] { 1, 1, 0, 0 }, 3)); + } + + [Test] + public void ArmyMath_SplitSurvivors_ReturnsExactlySurvivors() + { + int[] back = ArmyMath.SplitSurvivors(new[] { 10, 5, 0, 1 }, 9); + int sum = 0; + foreach (var b in back) { Assert.GreaterOrEqual(b, 0); sum += b; } + Assert.AreEqual(9, sum); + } + + // ---------------- Боевая система ---------------- + + [Test] + public void Battle_MoreSoldiersWins_TieGoesToDefender() + { + var battle = new BattleSystem(); + Assert.IsTrue(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Victory); + Assert.IsFalse(battle.Resolve(new BattleRequest { Attackers = 20, Defenders = 20 }).Victory); + Assert.AreEqual(20, battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors); + } + + // ---------------- Глобальный мир: поход до конца, в том числе офлайн ---------------- + + [Test] + public void World_MarchResolvesAndReturns_EvenAfterLongOffline() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + MarchData march = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 30, 0, out string error); + Assert.IsNotNull(march, error); + + var events = new List(); + world.Advance(3_600_000, events); // игра была закрыта час + + Assert.AreEqual(2, events.Count, "бой и возвращение за один вызов"); + Assert.AreEqual(WorldEventType.BattleResolved, events[0].Type); + Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type); + Assert.AreEqual(0, world.Marches.Count); + } + + // ---------------- Ядро ---------------- + + [Test] + public void EventBus_FailingSubscriberDoesNotBreakOthers() + { + int delivered = 0; + Exception caught = null; + EventBus.OnHandlerError = e => caught = e; + EventBus.Subscribe(_ => throw new InvalidOperationException("boom")); + EventBus.Subscribe(_ => delivered++); + + EventBus.Publish(new MarchReturnedEvent { Survivors = 5 }); + + Assert.AreEqual(1, delivered); + Assert.IsNotNull(caught); + } + + [Test] + public void GameServices_MissingModule_ThrowsClearError() + { + Assert.Throws(() => GameServices.Get()); + Assert.IsFalse(GameServices.TryGet(out IArmy _)); + } +} diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs.meta b/Assets/Scripts/Modules/Tests/ModuleTests.cs.meta new file mode 100644 index 0000000..9756d68 --- /dev/null +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 66b121f098aa76e409a80df7d32263e1 \ No newline at end of file diff --git a/Assets/Scripts/Modules/World.meta b/Assets/Scripts/Modules/World.meta new file mode 100644 index 0000000..a5ce89e --- /dev/null +++ b/Assets/Scripts/Modules/World.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 540563c7e420f5f468c384bb7ae95d9f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/World/Bogatyri.World.asmdef b/Assets/Scripts/Modules/World/Bogatyri.World.asmdef new file mode 100644 index 0000000..e519748 --- /dev/null +++ b/Assets/Scripts/Modules/World/Bogatyri.World.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Bogatyri.World", + "rootNamespace": "", + "references": ["Bogatyri.Core", "Bogatyri.Core.Unity", "Bogatyri.World.Simulation"], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Scripts/Modules/World/Bogatyri.World.asmdef.meta b/Assets/Scripts/Modules/World/Bogatyri.World.asmdef.meta new file mode 100644 index 0000000..a324149 --- /dev/null +++ b/Assets/Scripts/Modules/World/Bogatyri.World.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e776a574d6819ac4c9c7ea1cd2ec14a0 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs new file mode 100644 index 0000000..65af13c --- /dev/null +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using Bogatyri.World; +using UnityEngine; + +// МОДУЛЬ ГЛОБАЛЬНОГО МИРА: карта и походы. Документ: Docs/Modules/World.md +// Живёт между сценами (создаётся сам при запуске игры), поэтому войско идёт и возвращается, +// даже пока игрок в городе. +// +// С другими модулями -- только через контракты и события: +// войска -> IArmy (забрать в поход, вернуть выживших) +// добыча -> IEconomy +// бой -> IBattleSystem (передаётся в WorldSimulation) +// сохранение -> сам ISaveParticipant, секция "world" +// наружу -> MarchStartedEvent, MarchBattleResolvedEvent, MarchReturnedEvent +// +// Точка расширения под мультиплеер: сейчас WorldSimulation крутится прямо здесь. +// Позже TryStartMarch будет отправлять запрос на сервер, а походы и их итоги -- приходить с сервера. +public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant +{ + public const string LocalPlayerId = "local_player"; + + public static MarchManager Instance { get; private set; } + + // События для интерфейса карты (с полными данными похода). Модули слушают события EventBus. + public event Action OnMarchStarted; + public event Action OnBattleResolved; + public event Action OnMarchReturned; + + // Время мира. В мультиплеере -- время сервера, а не телефона + public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + private readonly List events = new List(); + private WorldSimulation world; + private List pendingMarches; // загружены раньше, чем мир был создан + + // Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована + public WorldSimulation World + { + get + { + if (world == null) + { + world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get()); + if (pendingMarches != null) world.LoadMarches(pendingMarches); + pendingMarches = null; + } + return world; + } + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + static void CreateInstance() + { + var go = new GameObject("MarchManager"); + DontDestroyOnLoad(go); + go.AddComponent(); + } + + void Awake() + { + Instance = this; + GameServices.Register(this); + SaveRegistry.Register(this); + } + + void OnDestroy() + { + if (Instance != this) return; + GameServices.Unregister(this); + SaveRegistry.Unregister(this); + } + + void Update() + { + // Без армии и экономики некуда вернуть войско и добычу -- ждём, пока они появятся + if (!GameServices.Has() || !GameServices.Has()) return; + + events.Clear(); + World.Advance(NowMs, events); + + foreach (var e in events) + { + if (e.Type == WorldEventType.BattleResolved) HandleBattleResolved(e.March); + else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March); + } + + if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); + } + + // ---------------- Контракт IWorld ---------------- + + public int ActiveMarches => World.Marches.Count; + + public bool TryStartMarch(string targetId, int soldiers, out string error) + { + if (!GameServices.TryGet(out IArmy army)) + { + error = "Нет данных о войске -- запусти игру из города"; + return false; + } + if (army.SoldiersAtHome < soldiers) + { + error = "Не хватает воинов. Наймите их в Казармах"; + return false; + } + + MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error); + if (march == null) return false; + + // Ушедшие воины недоступны дома, пока не вернутся + army.TakeForMarch(march.id, soldiers); + if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); + + Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}"); + OnMarchStarted?.Invoke(march); + EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TargetId = targetId, Soldiers = soldiers }); + return true; + } + + void HandleBattleResolved(MarchData march) + { + MapNode target = World.GetNode(march.targetNodeId); + Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " + + $"{march.soldiers} против {march.defenders}, возвращаются {march.survivors}."); + OnBattleResolved?.Invoke(march); + EventBus.Publish(new MarchBattleResolvedEvent + { + MarchId = march.id, + TargetId = march.targetNodeId, + Victory = march.victory, + Soldiers = march.soldiers, + Survivors = march.survivors + }); + } + + // Солдаты и добыча зачисляются, только когда войско дошло до дома + void HandleMarchReturned(MarchData march) + { + GameServices.Get().ReturnFromMarch(march.id, march.survivors); + + IEconomy economy = GameServices.Get(); + foreach (var reward in march.loot) + { + if (Enum.TryParse(reward.resource, out ResourceType type)) + economy.Add(type, reward.amount); + else + Debug.LogWarning($"[ПОХОД] Неизвестный ресурс награды: {reward.resource}"); + } + + Debug.Log($"[ПОХОД] Войско вернулось: {march.survivors} солдат, добыча: {FormatLoot(march.loot)}"); + OnMarchReturned?.Invoke(march); + EventBus.Publish(new MarchReturnedEvent { MarchId = march.id, Victory = march.victory, Survivors = march.survivors }); + } + + // ---------------- Сохранение (секция "world") ---------------- + + [Serializable] + class WorldSaveData + { + public List marches = new List(); + } + + public string SaveKey => "world"; + + public string Capture() + { + return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave() }); + } + + public void Restore(string json) + { + LoadMarches(JsonUtility.FromJson(json)?.marches); + } + + // Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом) + public void ResetState() + { + pendingMarches = null; + if (world != null) world.LoadMarches(new List()); + } + + public List GetMarchesForSave() + { + return world != null ? new List(world.Marches) : new List(pendingMarches ?? new List()); + } + + // Загрузить походы (из сохранения или из старого progress.json). Мир может быть ещё не создан. + public void LoadMarches(List saved) + { + if (world != null) world.LoadMarches(saved); + else pendingMarches = saved; + } + + // ---------------- ТЕКСТ ДЛЯ UI ---------------- + + public static string FormatLoot(List loot) + { + if (loot == null || loot.Count == 0) return "нет"; + + var parts = new List(); + foreach (var r in loot) + { + parts.Add($"+{r.amount} {GetResourceName(r.resource)}"); + } + return string.Join(", ", parts); + } + + static string GetResourceName(string resource) + { + switch (resource) + { + case "Food": return "еды"; + case "Wood": return "дерева"; + case "Stone": return "камня"; + case "Iron": return "металла"; + case "Gold": return "золота"; + case "Gems": return "кристаллов"; + default: return resource; + } + } +} diff --git a/Assets/Scripts/WorldMap/MarchManager.cs.meta b/Assets/Scripts/Modules/World/MarchManager.cs.meta similarity index 100% rename from Assets/Scripts/WorldMap/MarchManager.cs.meta rename to Assets/Scripts/Modules/World/MarchManager.cs.meta diff --git a/Assets/Scripts/WorldMap/Simulation.meta b/Assets/Scripts/Modules/World/Simulation.meta similarity index 100% rename from Assets/Scripts/WorldMap/Simulation.meta rename to Assets/Scripts/Modules/World/Simulation.meta diff --git a/Assets/Scripts/WorldMap/Simulation/Bogatyri.World.Simulation.asmdef b/Assets/Scripts/Modules/World/Simulation/Bogatyri.World.Simulation.asmdef similarity index 87% rename from Assets/Scripts/WorldMap/Simulation/Bogatyri.World.Simulation.asmdef rename to Assets/Scripts/Modules/World/Simulation/Bogatyri.World.Simulation.asmdef index 15779dd..baa27c3 100644 --- a/Assets/Scripts/WorldMap/Simulation/Bogatyri.World.Simulation.asmdef +++ b/Assets/Scripts/Modules/World/Simulation/Bogatyri.World.Simulation.asmdef @@ -1,7 +1,9 @@ { "name": "Bogatyri.World.Simulation", "rootNamespace": "Bogatyri.World", - "references": [], + "references": [ + "Bogatyri.Core" + ], "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, @@ -11,4 +13,4 @@ "defineConstraints": [], "versionDefines": [], "noEngineReferences": true -} +} \ No newline at end of file diff --git a/Assets/Scripts/WorldMap/Simulation/Bogatyri.World.Simulation.asmdef.meta b/Assets/Scripts/Modules/World/Simulation/Bogatyri.World.Simulation.asmdef.meta similarity index 100% rename from Assets/Scripts/WorldMap/Simulation/Bogatyri.World.Simulation.asmdef.meta rename to Assets/Scripts/Modules/World/Simulation/Bogatyri.World.Simulation.asmdef.meta diff --git a/Assets/Scripts/WorldMap/Simulation/WorldMapLayout.cs b/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs similarity index 100% rename from Assets/Scripts/WorldMap/Simulation/WorldMapLayout.cs rename to Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs diff --git a/Assets/Scripts/WorldMap/Simulation/WorldMapLayout.cs.meta b/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs.meta similarity index 100% rename from Assets/Scripts/WorldMap/Simulation/WorldMapLayout.cs.meta rename to Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs.meta diff --git a/Assets/Scripts/WorldMap/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs similarity index 85% rename from Assets/Scripts/WorldMap/Simulation/WorldSimulation.cs rename to Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 56f1d7d..46011df 100644 --- a/Assets/Scripts/WorldMap/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -33,36 +33,6 @@ namespace Bogatyri.World public MarchState state; } - public struct BattleOutcome - { - public bool Victory; - public int AttackerLosses; - public int DefenderLosses; - public int Survivors; - } - - // Правила боя. Пока самые простые: побеждает тот, у кого больше солдат. - // Потом сюда добавятся типы войск, герой, технологии и т.д. -- остальной код менять не придётся. - public static class BattleResolver - { - public static BattleOutcome Resolve(int attackers, int defenders) - { - bool victory = attackers > defenders; // ничья -- в пользу защитника - - // Проигравший теряет половину своего войска, победитель -- столько же - int loserArmy = victory ? defenders : attackers; - int losses = (loserArmy + 1) / 2; - - return new BattleOutcome - { - Victory = victory, - AttackerLosses = losses, - DefenderLosses = losses, - Survivors = attackers - losses - }; - } - } - public static class MarchRules { // Скорость войска: 400 милли-единиц (0,4 клетки) в секунду. @@ -92,15 +62,18 @@ namespace Bogatyri.World public sealed class WorldSimulation { private readonly List nodes; + private readonly IBattleSystem battleSystem; private readonly List marches = new List(); private int nextMarchId = 1; public IReadOnlyList Nodes => nodes; public IReadOnlyList Marches => marches; - public WorldSimulation(List nodes) + // Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам + public WorldSimulation(List nodes, IBattleSystem battleSystem) { this.nodes = nodes; + this.battleSystem = battleSystem; } public MapNode GetNode(string id) @@ -178,7 +151,7 @@ namespace Bogatyri.World MapNode from = GetNode(m.fromNodeId); MapNode target = GetNode(m.targetNodeId); - BattleOutcome outcome = BattleResolver.Resolve(m.soldiers, target.garrison); + BattleOutcome outcome = battleSystem.Resolve(new BattleRequest { Attackers = m.soldiers, Defenders = target.garrison }); m.victory = outcome.Victory; m.survivors = outcome.Survivors; m.defenders = target.garrison; diff --git a/Assets/Scripts/WorldMap/Simulation/WorldSimulation.cs.meta b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs.meta similarity index 100% rename from Assets/Scripts/WorldMap/Simulation/WorldSimulation.cs.meta rename to Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs.meta diff --git a/Assets/Scripts/Other/SaveManager.cs b/Assets/Scripts/Other/SaveManager.cs deleted file mode 100644 index 250dca2..0000000 --- a/Assets/Scripts/Other/SaveManager.cs +++ /dev/null @@ -1,246 +0,0 @@ -using UnityEngine; -using System.Collections.Generic; -using System.IO; -using UnityEngine.SceneManagement; - -// Сохраняет и загружает: все ресурсы, уровень Ратуши, список всех построенных зданий -// (какой тип, где стоит, какого уровня). -// -// Файл сохранения лежит в Application.persistentDataPath -- это специальная папка, -// которая переживает переустановку/обновление игры на телефоне (в отличие от Assets). - -[System.Serializable] -public class ResourceSaveEntry -{ - public ResourceType type; - public float amount; -} - -[System.Serializable] -public class PlacedBuildingSaveEntry -{ - public string buildingName; // совпадает с BuildingData.buildingName - public float posX; - public float posY; - public int level; -} - -[System.Serializable] -public class SaveData -{ - public List resources = new List(); - public int townHallLevel = 1; - public List placedBuildings = new List(); -} - -public class SaveManager : MonoBehaviour -{ - public static SaveManager Instance { get; private set; } - - [Header("Автосохранение")] - [Tooltip("Как часто автоматически сохранять прогресс, в секундах")] - public float autoSaveInterval = 30f; - - private string SavePath => Path.Combine(Application.persistentDataPath, "save.json"); - - void Awake() - { - if (Instance != null && Instance != this) - { - Destroy(gameObject); - return; - } - Instance = this; - DontDestroyOnLoad(gameObject); - SceneManager.sceneLoaded += OnSceneLoaded; - } - - void OnDestroy() - { - if (Instance == this) SceneManager.sceneLoaded -= OnSceneLoaded; - } - - // [объединение с картой Саши] Первый вход в город загружает GameBootstrap. - // При возврате в город со сцены WorldMap GameBootstrap уничтожается вместе с дубликатом SaveManager, - // поэтому здания восстанавливаем здесь (из сохранения, сделанного прямо перед уходом из города). - bool cityLoadedOnce; - void OnSceneLoaded(Scene scene, LoadSceneMode mode) - { - if (BuildManager.Instance == null) return; // это не город - // ресурсы НЕ перечитываем: они живут в памяти между сценами (добыча с карты мира не должна пропасть) - if (cityLoadedOnce && HasSaveFile()) LoadGame(false); - cityLoadedOnce = true; - } - - void Start() - { - InvokeRepeating(nameof(SaveGame), autoSaveInterval, autoSaveInterval); - } - - // Мобильные ОС часто "убивают" приложение без вызова OnApplicationQuit, - // поэтому OnApplicationPause -- самый надёжный момент для сохранения на телефоне - // (вызывается, когда игрок сворачивает игру или переключается на другое приложение). - void OnApplicationPause(bool pauseStatus) - { - if (pauseStatus) - { - SaveGame(); - } - } - - void OnApplicationQuit() - { - SaveGame(); - } - - // ---------------- СОХРАНЕНИЕ ---------------- - - public void SaveGame() - { - // На карте мира города нет -- сохранение затёрло бы все здания. Город сохраняется перед уходом. - if (BuildManager.Instance == null) return; - cityLoadedOnce = true; - - SaveData data = new SaveData(); - - // Ресурсы - if (ResourceManager.Instance != null) - { - foreach (var kvp in ResourceManager.Instance.GetAllResources()) - { - data.resources.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value }); - } - } - - // Ратуша - if (TownHall.Instance != null) - { - data.townHallLevel = TownHall.Instance.currentLevel; - } - - // Все построенные здания на сцене (найдёт каждый объект с маркером PlacedBuildingId) - PlacedBuildingId[] allPlaced = Object.FindObjectsByType(FindObjectsSortMode.None); - foreach (var placed in allPlaced) - { - int level = 1; - ResourceCollector collector = placed.GetComponent(); - if (collector != null) level = collector.level; - - data.placedBuildings.Add(new PlacedBuildingSaveEntry - { - buildingName = placed.buildingName, - posX = placed.transform.position.x, - posY = placed.transform.position.y, - level = level - }); - } - - string json = JsonUtility.ToJson(data, true); - File.WriteAllText(SavePath, json); - - Debug.Log($"[СОХРАНЕНИЕ] Игра сохранена: {SavePath}"); - } - - // ---------------- ЗАГРУЗКА ---------------- - - public bool HasSaveFile() - { - return File.Exists(SavePath); - } - - public void LoadGame() { LoadGame(true); } - - public void LoadGame(bool includeResources) - { - BarracksBootstrap.Ensure(); // казармы должны быть в списке стройки до восстановления зданий - - if (!HasSaveFile()) - { - Debug.Log("[ЗАГРУЗКА] Файл сохранения не найден, начинаем новую игру."); - return; - } - - string json = File.ReadAllText(SavePath); - SaveData data = JsonUtility.FromJson(json); - - // Ресурсы - if (includeResources && ResourceManager.Instance != null) - { - foreach (var entry in data.resources) - { - ResourceManager.Instance.SetResource(entry.type, entry.amount); - } - } - - // Ратуша - if (TownHall.Instance != null) - { - TownHall.Instance.currentLevel = data.townHallLevel; - } - - // Сначала удаляем все здания, которые уже могли быть заспавнены на сцене - // (например, если LoadGame вызывается повторно, не хотим задваивать постройки) - PlacedBuildingId[] existing = Object.FindObjectsByType(FindObjectsSortMode.None); - foreach (var e in existing) - { - Destroy(e.gameObject); - } - - // Восстанавливаем здания из сохранения - foreach (var entry in data.placedBuildings) - { - BuildingData matchingData = FindBuildingDataByName(entry.buildingName); - if (matchingData == null) - { - Debug.LogWarning($"[ЗАГРУЗКА] Не найден BuildingData для '{entry.buildingName}', здание пропущено."); - continue; - } - - Vector3 pos = new Vector3(entry.posX, entry.posY, 0f); - GameObject built = Instantiate(matchingData.prefab, pos, Quaternion.identity); - if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным - - PlacedBuildingId idMarker = built.AddComponent(); - idMarker.buildingName = entry.buildingName; - - ResourceCollector collector = built.GetComponent(); - if (collector != null) collector.level = entry.level; - - // Помечаем соответствующий слот как занятый, чтобы на него нельзя было - // случайно поставить ещё одно здание поверх после загрузки - if (BuildManager.Instance != null) - { - BuildSlot slot = BuildManager.Instance.FindSlotAtPosition(pos); - if (slot != null) - { - slot.Occupy(built); - BuildingSlotLink link = built.AddComponent(); - link.linkedSlot = slot; - } - } - } - - Debug.Log($"[ЗАГРУЗКА] Игра загружена. Построек восстановлено: {data.placedBuildings.Count}"); - } - - BuildingData FindBuildingDataByName(string buildingName) - { - if (BuildManager.Instance == null) return null; - - foreach (var b in BuildManager.Instance.allBuildings) - { - if (b.buildingName == buildingName) return b; - } - return null; - } - - // Полностью стирает сохранение (например, для кнопки "Начать заново" в настройках) - public void DeleteSave() - { - if (File.Exists(SavePath)) - { - File.Delete(SavePath); - Debug.Log("[СОХРАНЕНИЕ] Файл сохранения удалён."); - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/WorldMap/ArmyBridge.cs b/Assets/Scripts/WorldMap/ArmyBridge.cs index 22faa00..74946ae 100644 --- a/Assets/Scripts/WorldMap/ArmyBridge.cs +++ b/Assets/Scripts/WorldMap/ArmyBridge.cs @@ -1,13 +1,17 @@ using System; -using System.Collections.Generic; using UnityEngine; -// МОСТ между картой мира Саши (армия = просто число "солдат") и нашей армией (4 типа войск в GameProgress). +// МОСТ между модулем Армии и данными войск, которые пока хранятся в GameProgress (4 типа войск). +// Реализует контракт IArmy: модуль Мира берёт и возвращает воинов только через него. +// Документ: Docs/Modules/Army.md +// // - "Солдаты дома" = все наши воины в городе. // - При отправке похода берём воинов из всех типов пропорционально и запоминаем состав похода. // - При возвращении выживших раздаём обратно по тем же типам в той же пропорции. -// Благодаря этому одна армия на всю игру: наняли в казармах -> можно отправить и с нашей карты (МИР), -// и с большой карты мира (сцена WorldMap). +// Сами правила деления -- в ArmyMath (модуль Армии, без Unity). +// +// Решение команды от 19.10.2026: не удалять этот мост, пока войска не переедут в модуль Армии +// вместе с миграцией сохранений (поле sashaMarchUnits в progress.json). public static class ArmyBridge { [Serializable] @@ -17,6 +21,9 @@ public static class ArmyBridge public int[] units = new int[4]; } + // Реализация контракта, регистрирует её App/GameModules + public static readonly IArmy Service = new ArmyService(); + static ProgressData D { get { return GameProgress.Instance.Data; } } public static int SoldiersAtHome { get { return GameProgress.Instance.TotalTroops; } } @@ -25,27 +32,8 @@ public static class ArmyBridge public static bool Take(int marchId, int count) { var troops = D.troops; - int total = 0; - foreach (var t in troops) total += t; - if (count <= 0 || count > total) return false; - - // пропорционально, остаток раздаём по самым многочисленным типам - var taken = new int[troops.Length]; - int given = 0; - var remainders = new List>(); - for (int i = 0; i < troops.Length; i++) - { - float exact = (float)count * troops[i] / total; - taken[i] = Mathf.Min(troops[i], Mathf.FloorToInt(exact)); - given += taken[i]; - remainders.Add(new KeyValuePair(i, exact - taken[i])); - } - remainders.Sort((a, b) => b.Value.CompareTo(a.Value)); - for (int k = 0; given < count && k < remainders.Count * 2; k++) - { - int i = remainders[k % remainders.Count].Key; - if (taken[i] < troops[i]) { taken[i]++; given++; } - } + int[] taken = ArmyMath.TakeProportional(troops, count); + if (taken == null) return false; for (int i = 0; i < troops.Length; i++) troops[i] -= taken[i]; var entry = new MarchUnits { marchId = marchId }; @@ -67,21 +55,17 @@ public static class ArmyBridge } else { - int sent = 0; - foreach (var u in entry.units) sent += u; - int back = 0; - for (int i = 0; i < entry.units.Length && i < troops.Length; i++) - { - int n = sent > 0 ? Mathf.FloorToInt((float)survivors * entry.units[i] / sent) : 0; - troops[i] += n; - back += n; - } - // остаток округления -- в самый многочисленный тип похода - int best = 0; - for (int i = 1; i < entry.units.Length; i++) if (entry.units[i] > entry.units[best]) best = i; - troops[best] += Mathf.Max(0, survivors - back); + int[] back = ArmyMath.SplitSurvivors(entry.units, survivors); + for (int i = 0; i < back.Length && i < troops.Length; i++) troops[i] += back[i]; D.sashaMarchUnits.Remove(entry); } GameProgress.Instance.MarkChanged(); } + + class ArmyService : IArmy + { + public int SoldiersAtHome => ArmyBridge.SoldiersAtHome; + public bool TakeForMarch(int marchId, int count) => Take(marchId, count); + public void ReturnFromMarch(int marchId, int survivors) => Return(marchId, survivors); + } } diff --git a/Assets/Scripts/WorldMap/MarchManager.cs b/Assets/Scripts/WorldMap/MarchManager.cs deleted file mode 100644 index c69e372..0000000 --- a/Assets/Scripts/WorldMap/MarchManager.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System; -using System.Collections.Generic; -using Bogatyri.World; -using UnityEngine; - -// Хранит мир и походы игрока. Живёт между сценами (создаётся сам при запуске игры), -// поэтому войско идёт и возвращается, даже пока игрок в городе. -// -// Точка расширения под мультиплеер: сейчас WorldSimulation крутится прямо здесь. -// Позже StartMarch будет отправлять запрос на сервер, а походы и их итоги -- приходить с сервера. -// WorldMapController при этом менять не придётся: он работает через события этого класса. -public class MarchManager : MonoBehaviour -{ - public const string LocalPlayerId = "local_player"; - - public static MarchManager Instance { get; private set; } - - public WorldSimulation World { get; private set; } - - public event Action OnMarchStarted; - public event Action OnBattleResolved; - public event Action OnMarchReturned; - - // Время мира. В мультиплеере -- время сервера, а не телефона - public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - private readonly List events = new List(); - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] - static void CreateInstance() - { - var go = new GameObject("MarchManager"); - DontDestroyOnLoad(go); - go.AddComponent(); - } - - void Awake() - { - Instance = this; - World = new WorldSimulation(WorldMapLayout.CreateDefault()); - // [объединение] походы хранятся в нашем progress.json (GameProgress), а не в SaveManager - var saved = GameProgress.Instance.Data.sashaMarches; - if (saved != null && saved.Count > 0) World.LoadMarches(saved); - } - - void SaveToProgress() - { - GameProgress.Instance.Data.sashaMarches = GetMarchesForSave(); - GameProgress.Instance.MarkChanged(); - } - - void Update() - { - // Без ResourceManager некуда зачислять вернувшихся солдат -- ждём, пока загрузится город - if (ResourceManager.Instance == null) return; - - events.Clear(); - World.Advance(NowMs, events); - - foreach (var e in events) - { - if (e.Type == WorldEventType.BattleResolved) HandleBattleResolved(e.March); - else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March); - } - - if (events.Count > 0) SaveToProgress(); - } - - public bool TryStartMarch(string targetId, int soldiers, out string error) - { - if (ResourceManager.Instance == null) - { - error = "Нет данных о войске -- запусти игру из города"; - return false; - } - // [объединение] солдаты = наши воины в городе (все типы войск) - if (ArmyBridge.SoldiersAtHome < soldiers) - { - error = "Не хватает воинов. Наймите их в Казармах"; - return false; - } - - MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error); - if (march == null) return false; - - // Ушедшие воины недоступны дома, пока не вернутся - ArmyBridge.Take(march.id, soldiers); - GameProgress.Instance.Data.marchesSent++; - GameProgress.Instance.Data.dailyMarches++; - SaveToProgress(); - - Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}"); - OnMarchStarted?.Invoke(march); - return true; - } - - void HandleBattleResolved(MarchData march) - { - MapNode target = World.GetNode(march.targetNodeId); - Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " + - $"{march.soldiers} против {target.garrison}, возвращаются {march.survivors}."); - OnBattleResolved?.Invoke(march); - } - - // Солдаты и добыча зачисляются, только когда войско дошло до дома - void HandleMarchReturned(MarchData march) - { - ResourceManager rm = ResourceManager.Instance; - ArmyBridge.Return(march.id, march.survivors); - if (march.victory) GameProgress.Instance.Data.worldWins++; - - foreach (var reward in march.loot) - { - if (Enum.TryParse(reward.resource, out ResourceType type)) - rm.AddResource(type, reward.amount); - else - Debug.LogWarning($"[ПОХОД] Неизвестный ресурс награды: {reward.resource}"); - } - - Debug.Log($"[ПОХОД] Войско вернулось: {march.survivors} солдат, добыча: {FormatLoot(march.loot)}"); - OnMarchReturned?.Invoke(march); - } - - // ---------------- СОХРАНЕНИЕ ---------------- - - public List GetMarchesForSave() - { - return new List(World.Marches); - } - - public void LoadMarches(List saved) - { - World.LoadMarches(saved); - } - - // ---------------- ТЕКСТ ДЛЯ UI ---------------- - - public static string FormatLoot(List loot) - { - if (loot == null || loot.Count == 0) return "нет"; - - var parts = new List(); - foreach (var r in loot) - { - parts.Add($"+{r.amount} {GetResourceName(r.resource)}"); - } - return string.Join(", ", parts); - } - - static string GetResourceName(string resource) - { - switch (resource) - { - case "Food": return "еды"; - case "Wood": return "дерева"; - case "Stone": return "камня"; - case "Iron": return "металла"; - case "Gold": return "золота"; - case "Gems": return "кристаллов"; - default: return resource; - } - } -} diff --git a/Docs/Modules/Army.md b/Docs/Modules/Army.md new file mode 100644 index 0000000..fa33c94 --- /dev/null +++ b/Docs/Modules/Army.md @@ -0,0 +1,56 @@ +# Армия + +Войска и их характеристики: сколько воинов дома, как они уходят в поход и возвращаются. + +- **Сборка:** `Bogatyri.Army` (без Unity). +- **Папка:** `Assets/Scripts/Modules/Army`. +- **Зависит от:** ядра. + +## Контракт `IArmy` + +| Член | Что делает | +| --- | --- | +| `SoldiersAtHome` | Воинов в городе (все типы). | +| `TakeForMarch(marchId, count)` | Забрать воинов в поход пропорционально составу армии и запомнить состав похода. | +| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам. | + +Получить: `GameServices.Get()`. + +## Где сейчас данные + +Войска пока хранятся в `GameProgress` (`progress.json`, поле `troops`, 4 типа). Поэтому контракт реализует мост `Assets/Scripts/WorldMap/ArmyBridge.cs` (`ArmyBridge.Service`), а модуль Армии содержит правила деления войск `ArmyMath`. + +**Решение команды от 19.10.2026:** `ArmyBridge` не удалять, пока войска не переедут в модуль Армии вместе с миграцией сохранений (поле `sashaMarchUnits`). + +## События + +- **Публикует:** пока ничего. +- **Слушает:** ничего (походы вызывают контракт напрямую). + +## Сохранение + +Пока через `GameProgress`. Когда войска переедут в модуль, у него появится своя секция `"army"` и перенос из `progress.json`. + +## Файлы + +| Файл | Что это | +| --- | --- | +| `ArmyMath.cs` | `TakeProportional` — кого взять в поход; `SplitSurvivors` — как вернуть выживших. Без Unity. | +| `../../WorldMap/ArmyBridge.cs` (слой приложения) | Реализация `IArmy` поверх `GameProgress`. | + +## Решения и план + +- **Этап 5 (план разработки):** солдат стоит 1 человека, 15 еды, 5 металла, 5 золота; содержание 0,2 еды в час; погибшие уменьшают население. +- **Решение команды от 19.10.2026:** + - войска набираются из населения с сохранением гражданского минимума; + - гарнизон поселения: часть солдат назначается защищать город; + - общая армия = гарнизон + доступные отряды + войска в походах, на точках и в гарнизоне Круга; **один солдат не может быть в двух местах**; + - гарнизон ест еду и участвует в защите; + - при голоде дебаффы действуют и на гарнизон, и на походы. +- **Следующий шаг модуля:** перенести `troops` и `sashaMarchUnits` из `GameProgress` в модуль (секция `"army"`), заменить `ArmyBridge` на реализацию внутри модуля. + +## Журнал изменений + +| Дата | Кто | Что | +| --- | --- | --- | +| 19.10.2026 | Claude | Модуль создан: `ArmyMath` (правила деления войск, без Unity, тесты на отсутствие двойного учёта). `ArmyBridge` сохранён и теперь реализует `IArmy` через `ArmyMath`; формат `sashaMarchUnits` не изменён. | diff --git a/Docs/Modules/Battle.md b/Docs/Modules/Battle.md new file mode 100644 index 0000000..eb292e1 --- /dev/null +++ b/Docs/Modules/Battle.md @@ -0,0 +1,44 @@ +# Боевая система + +Урон, потери, исход боя. Модуль только считает: кто победил и сколько погибло. Кто идёт в бой и что делать с итогом, решают другие модули. + +- **Сборка:** `Bogatyri.Battle` (без Unity — тот же код будет считать бои на сервере). +- **Папка:** `Assets/Scripts/Modules/Battle`. +- **Зависит от:** ядра. + +## Контракт `IBattleSystem` + +`BattleOutcome Resolve(BattleRequest request)` + +| `BattleRequest` | `BattleOutcome` | +| --- | --- | +| `Attackers` — атакующих | `Victory` — победил атакующий | +| `Defenders` — защитников | `AttackerLosses`, `DefenderLosses`, `Survivors` | + +Регистрирует `App/GameModules`. Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере. + +## Текущие правила + +- Побеждает тот, у кого больше солдат; ничья — победа защитника. +- Проигравший теряет половину своего войска, победитель — столько же. + +Пример: 30 против 20 — победа, погибает 10 атакующих и 10 защитников, домой идут 20. + +## События + +Нет: исход возвращается вызвавшему модулю. + +## Сохранение + +Нет данных. + +## Решения и план + +- **Этап 6:** формула с типами войск — кто кого контрит и как делятся потери. Сначала записать правила в документ экономики. +- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. В `BattleRequest` для этого появятся модификаторы силы сторон; проценты — из `EconomyConfig`. + +## Журнал изменений + +| Дата | Кто | Что | +| --- | --- | --- | +| 19.10.2026 | Claude | Модуль выделен: `BattleResolver` из `WorldSimulation.cs` стал `BattleSystem` с контрактом `IBattleSystem`; `BattleOutcome` перенесён в ядро. Правила не изменились. | diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md new file mode 100644 index 0000000..5b82e4c --- /dev/null +++ b/Docs/Modules/Core.md @@ -0,0 +1,54 @@ +# Игровое ядро + +Общие данные, игровые правила взаимодействия и связь модулей. Ядро не содержит игровой логики — только «язык», на котором модули говорят друг с другом. + +- **Сборки:** + - `Bogatyri.Core` — чистый C#, без Unity (его можно взять на сервер); + - `Bogatyri.Core.Unity` — тонкая прослойка к движку. +- **Папки:** `Assets/Scripts/Modules/Core`, `Assets/Scripts/Modules/Core.Unity`. +- **Зависимости:** нет. От ядра зависят все модули. + +## Что внутри + +| Файл | Что это | +| --- | --- | +| `Core/ResourceType.cs` | Перечисление ресурсов `ResourceType` и структура `ResourceAmount` (тип + количество). Числа enum не менять: они в сохранениях. | +| `Core/GameServices.cs` | Реестр модулей: `Register`, `Get`, `TryGet`, `Has`, `Unregister`. | +| `Core/EventBus.cs` | Шина событий: `Subscribe`, `Unsubscribe`, `Publish`. Ошибка одного подписчика не мешает остальным (уходит в `OnHandlerError`). | +| `Core/Contracts/ModuleContracts.cs` | Контракты всех модулей и `SaveRegistry` — см. таблицу ниже. | +| `Core/Events/GameEvents.cs` | Все события модулей. | +| `Core.Unity/CoreUnityBootstrap.cs` | При старте игры очищает реестр, шину и список сохраняемых, подключает ошибки шины к `Debug.LogException`. | +| `Core.Unity/UIBlocker.cs` | «Палец над интерфейсом?» — общая проверка для кликов по миру. | + +## Контракты + +| Контракт | Реализует | Что умеет | +| --- | --- | --- | +| `IEconomy` | Экономика (`ResourceManager`) | Узнать, добавить, потратить ресурс; проверить и списать цену целиком. | +| `ISettlement` | Поселение (`SettlementModule`) | Уровень Ратуши, число построек, открыт ли город. | +| `ILeveledBuilding` | Здания с уровнем (`ResourceCollector`) | Уровень здания — его сохраняет Поселение. | +| `IArmy` | Армия (`ArmyBridge.Service`) | Воины дома; забрать в поход; вернуть выживших. | +| `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. | +| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`. | +| `ISaveSystem` | Сохранения (`SaveManager`) | Есть ли сохранение; сохранить сейчас; стереть; сбросить всё (`ResetAll`). | +| `ISaveParticipant` | Любой модуль с данными | Секция сохранения: ключ, `Capture()`, `Restore(json)`, `ResetState()` — новая игра. | + +## Порядок запуска + +1. `SubsystemRegistration` — `CoreUnityBootstrap` очищает ядро. +2. `AfterAssembliesLoaded` — `App/GameModules` и `SettlementModule` регистрируют сервисы и подписки. +3. `BeforeSceneLoad` — создаётся `MarchManager` (Мир). +4. `Awake` объектов сцены — `ResourceManager`, `SaveManager` регистрируются сами. +5. `AfterSceneLoad` — перенос старых походов из `progress.json`. +6. `Start` — `GameBootstrap` загружает сохранение. + +## Решения и план + +- **Решение команды от 19.10.2026:** единый источник истины для населения, армии и ресурсов. Новые контракты для этапов 1, 2 и 5 — население, гарнизон, голод, лимиты хранения — добавляются сюда, когда начинается соответствующий этап. + +## Журнал изменений + +| Дата | Кто | Что | +| --- | --- | --- | +| 19.10.2026 | Claude | Сброс сохранений: `ISaveSystem.ResetAll()`, `ISaveParticipant.ResetState()`, событие `GameResetEvent`. | +| 19.10.2026 | Claude | Создано ядро: реестр, шина событий, контракты шести модулей, события, `SaveRegistry`. `ResourceType` перенесён сюда из `ResourceManager.cs`, `UIBlocker` — из `Meta/Core`. | diff --git a/Docs/Modules/Economy.md b/Docs/Modules/Economy.md new file mode 100644 index 0000000..1b9b164 --- /dev/null +++ b/Docs/Modules/Economy.md @@ -0,0 +1,56 @@ +# Экономика + +Ресурсы и производство: сколько у игрока дерева, еды, золота и остального, кто их добывает и тратит. + +- **Сборка:** `Bogatyri.Economy`. +- **Папка:** `Assets/Scripts/Modules/Economy`. +- **Зависит от:** ядра. + +## Контракт `IEconomy` + +| Метод | Что делает | +| --- | --- | +| `Get(type)` | Сколько ресурса сейчас. | +| `Has(type, amount)` | Хватает ли. | +| `Add(type, amount)` | Добавить (или отнять отрицательным числом; в минус не уходит). | +| `TrySpend(type, amount)` | Потратить, если хватает. | +| `CanAfford(cost)` / `TrySpend(cost)` | Проверить и списать цену из нескольких ресурсов — всё или ничего. | + +Получить: `GameServices.Get()`. + +## События + +- **Публикует:** `ResourceChangedEvent` — при любом изменении ресурса (тип и новое значение). +- **Слушает:** ничего. + +Для интерфейса остаётся C#-событие `ResourceManager.OnResourceChanged` (его слушает `ResourceUIText`). + +## Сохранение + +Секция `"economy"`: `{ "resources": [ { "type", "amount" } ] }` — имена полей как в старом `save.json`. + +Сброс (`ResetState`): стартовые значения из Inspector `ResourceManager` (`startWood` и т.д.). + +## Файлы + +| Файл | Что это | +| --- | --- | +| `ResourceManager.cs` | Хранилище ресурсов, реализует `IEconomy` и `ISaveParticipant`. Живёт между сценами. | +| `ResourceProducer.cs` | Здание, добывающее ресурс по таймеру. | +| `ResourceCollector.cs` | Временная добыча по клику. Реализует `ILeveledBuilding`. | + +Интерфейс экономики остаётся в слое приложения: `Assets/Scripts/Economy/ResourceUIText.cs`, `BuildingUI.cs`. + +## Решения и план + +- **Этап 1 (план разработки):** лимиты Склада и Амбара; `Gems` → `BlueCrystal`; `Power` убрать из ресурсов. +- **Решение команды от 19.10.2026:** у синего кристалла отдельный небольшой настраиваемый лимит хранения, не бесконечный. Переполнение — одно правило для наград и добычи. Число не утверждено. +- **Этап 3:** убрать кликер (`ResourceCollector`); производство = рабочие × норма × качество месторождения × бонус уровня. +- Все числа — из `EconomyConfig`, не в коде. + +## Журнал изменений + +| Дата | Кто | Что | +| --- | --- | --- | +| 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений ресурсы возвращаются к стартовым. | +| 19.10.2026 | Claude | Модуль выделен: `ResourceManager`, `ResourceProducer`, `ResourceCollector` перенесены в `Modules/Economy`. `ResourceManager` реализует `IEconomy`, публикует `ResourceChangedEvent`, сам сохраняет секцию `"economy"`. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md new file mode 100644 index 0000000..8201119 --- /dev/null +++ b/Docs/Modules/README.md @@ -0,0 +1,62 @@ +# Модульная система «Богатырей» + +Игра собрана из шести модулей вокруг общего **Игрового ядра**. Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется. + +``` + Игровое ядро (Bogatyri.Core) + контракты (IEconomy, IArmy...) · события (EventBus) · реестр (GameServices) + ┌───────────┬────────────┬───────────┬──────────────┬───────────────┬────────────┐ + Экономика Поселение Армия Глобальный мир Боевая система Сохранения + ресурсы, здания, войска, карта, урон, потери, игровое + производство развитие характеристики перемещения исход состояние + ▲ + App/GameModules — точка сборки: регистрирует модули и соединяет их события + со старыми системами (GameProgress, Казармы, панели UI) +``` + +| Модуль | Сборка | Папка | Документ | +| --- | --- | --- | --- | +| Игровое ядро | `Bogatyri.Core` (без Unity), `Bogatyri.Core.Unity` | `Assets/Scripts/Modules/Core`, `Core.Unity` | [Core.md](Core.md) | +| Экономика | `Bogatyri.Economy` | `Assets/Scripts/Modules/Economy` | [Economy.md](Economy.md) | +| Поселение | `Bogatyri.Settlement` | `Assets/Scripts/Modules/Settlement` | [Settlement.md](Settlement.md) | +| Армия | `Bogatyri.Army` (без Unity) | `Assets/Scripts/Modules/Army` | [Army.md](Army.md) | +| Глобальный мир | `Bogatyri.World`, `Bogatyri.World.Simulation` (без Unity) | `Assets/Scripts/Modules/World` | [World.md](World.md) | +| Боевая система | `Bogatyri.Battle` (без Unity) | `Assets/Scripts/Modules/Battle` | [Battle.md](Battle.md) | +| Сохранения | `Bogatyri.Save` | `Assets/Scripts/Modules/Save` | [Save.md](Save.md) | + +## Правила + +1. **Модуль ссылается только на ядро.** Нужен соседний модуль — получи его контракт: `GameServices.Get()`. Никаких `ResourceManager.Instance` внутри модулей. +2. **Факты — событиями.** Случилось что-то, о чём другим стоит знать, — `EventBus.Publish(new ...Event{...})`. Подписчик обязан отписаться в `OnDestroy`. +3. **Каждый модуль сам сохраняет свои данные** — реализует `ISaveParticipant` и регистрируется в `SaveRegistry`. Модуль Сохранений не знает, что внутри секций. +4. **Правила игры — без Unity и без `float`, где это возможно** (Армия, Бой, симуляция Мира). Этот код без изменений сможет работать на сервере. +5. **Слой приложения** (`Assets/Scripts`, вне `Modules`) — интерфейс, панели, `GameProgress`. Ему можно обращаться к модулям напрямую. Переносить оттуда логику в модули — постепенно, по плану. +6. **Вся «проводка» — в `App/GameModules.cs`.** Новую связь между модулем и остальной игрой добавляй туда, а не внутрь модуля. + +## Как вести документы модулей + +- **Изменил модуль — обнови его документ в том же коммите.** Новый метод контракта, событие, секция сохранения, файл — всё это в разделах документа. +- **В «Журнал изменений» добавляй строку сверху:** дата, кто, что изменилось. Так видно историю модуля без чтения кода. +- **Решение команды, которое касается модуля,** записывай в раздел «Решения и план» со ссылкой на источник (например, «план от 19.10.2026»). +- **Новый модуль** — новый файл по образцу `Economy.md` и строка в таблице выше. + +## Тесты + +EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск: **Window → General → Test Runner → EditMode → Run All**. + +Уже есть: деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей. + +Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026): + +- [ ] лимит жилья: 300 жителей при лимите 300 — прирост стоит, построен Дом — прирост идёт; +- [ ] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120; +- [ ] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30; +- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу; +- [ ] предел синих кристаллов: кристаллы не копятся бесконечно, сверх лимита — по общему правилу. + +## Журнал изменений + +| Дата | Кто | Что | +| --- | --- | --- | +| 19.10.2026 | Claude | Добавлен сброс сохранений (Настройки, меню Tools, `ISaveSystem.ResetAll`). Обновлены Core, Economy, Settlement, World, Save. | +| 19.10.2026 | Claude | Создана модульная система: ядро, шесть модулей, точка сборки `App/GameModules`, тесты, документы модулей. Ветка `feature/modules`. | diff --git a/Docs/Modules/Save.md b/Docs/Modules/Save.md new file mode 100644 index 0000000..72ab9a9 --- /dev/null +++ b/Docs/Modules/Save.md @@ -0,0 +1,101 @@ +# Сохранения + +Игровое состояние: запись и загрузка `save.json`. Модуль не знает, что хранят другие модули: каждый модуль сам отдаёт свою секцию. + +- **Сборка:** `Bogatyri.Save`. +- **Папка:** `Assets/Scripts/Modules/Save`. +- **Зависит от:** ядра. + +## Контракт `ISaveSystem` + +| Член | Что делает | +| --- | --- | +| `HasSave` | Есть ли файл сохранения. | +| `SaveNow()` | Сохранить сейчас. | +| `DeleteSave()` | Только стереть файл (состояние модулей в памяти не меняется). | +| `ResetAll()` | **Сброс сохранений**: все модули возвращаются к новой игре, файл сразу перезаписывается. | + +Для слоя приложения остаются `SaveManager.Instance.SaveGame()`, `LoadGame()`, `HasSaveFile()`, `DeleteSave()`. + +## Как модуль подключает свои данные + +```csharp +public class MyModule : ISaveParticipant +{ + public string SaveKey => "mymodule"; + public string Capture() => JsonUtility.ToJson(data); // null -- оставить то, что уже в файле + public void Restore(string json) { data = JsonUtility.FromJson(json); } + public void ResetState() { data = new MyData(); } // новая игра +} +SaveRegistry.Register(myModule); // при запуске модуля +``` + +| Секция | Модуль | +| --- | --- | +| `economy` | Экономика | +| `settlement` | Поселение | +| `world` | Глобальный мир | + +## Формат файла + +Версия 2: `{ "version": 2, "sections": [ { "key": "economy", "json": "..." } ] }`. + +Версия 1 (старый плоский `save.json`) читается сама: файл целиком отдаётся Экономике и Поселению, их секции берут поля по старым именам. + +## Когда сохраняется + +- **Автоматически:** + - автосохранение каждые 30 секунд; + - при сворачивании и закрытии игры; + - перед переходом между сценами (`SceneNavigator`); + - после событий походов. +- **Защиты:** + - **пока сохранение не загружено, запись пропускается** — иначе стартовые нули модулей затёрли бы файл; + - секция модуля, которого сейчас нет (например, город на карте мира), не пропадает. + +## Сброс сохранений + +| Откуда | Что происходит | +| --- | --- | +| Настройки → «Сбросить весь прогресс» | `GameProgress.ResetAll()`: удаляет `progress.json`, вызывает `ISaveSystem.ResetAll()`, создаёт новый прогресс (герой, задания, почта, войска). | +| `ISaveSystem.ResetAll()` | Стирает `save.json`, вызывает `ResetState()` у каждого модуля, сразу записывает новую игру, публикует `GameResetEvent`. | +| Tools → Сбросить сохранения (редактор) | В Play Mode — то же, что кнопка в Настройках. Вне Play Mode — удаляет `save.json` и `progress.json`. | +| Tools → Открыть папку сохранений | Открывает `Application.persistentDataPath`. | + +Что сбрасывает каждый модуль: + +- **Экономика** — стартовые ресурсы из Inspector `ResourceManager`; +- **Поселение** — Ратуша 1 уровня, постройки убраны, участки свободны; +- **Мир** — все походы отменены. + +Новый модуль с данными обязан реализовать `ResetState()`, иначе он не скомпилируется: метод входит в контракт. + +## События + +**Публикует:** + +- `GameLoadedEvent` — после загрузки; +- `GameSavedEvent` — после записи; +- `GameResetEvent` — после сброса сохранений. + +**Слушает:** ничего. + +## Файлы + +| Файл | Что это | +| --- | --- | +| `SaveManager.cs` | Запись и чтение секций, автосохранение, перенос версии 1. | +| `GameBootstrap.cs` | Загружает сохранение при старте сцены города. | +| `Assets/Editor/SaveResetMenu.cs` (редактор) | Меню Tools → Сбросить сохранения / Открыть папку сохранений. | + +## Решения и план + +- **Этап 0:** одно сохранение. `GameProgress` (`progress.json`: герой, задания, почта, войска) пока пишет свой файл. Его данные переводятся в секции по мере выноса в модули: первыми — войска (модуль Армии). +- Номер версии файла поднимать при каждом несовместимом изменении и дописывать перенос старой версии здесь. + +## Журнал изменений + +| Дата | Кто | Что | +| --- | --- | --- | +| 19.10.2026 | Claude | Сброс сохранений: `ISaveSystem.ResetAll()`, `ResetState()` у модулей, `GameResetEvent`, меню редактора. Кнопка в Настройках теперь сбрасывает и походы, и память Поселения. | +| 19.10.2026 | Claude | `SaveManager` переписан на секции модулей (`ISaveParticipant`), формат v2 с переносом v1. Запись до загрузки запрещена. Сохранение города вынесено в модуль Поселения, походов — в модуль Мира. | diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md new file mode 100644 index 0000000..f2d0389 --- /dev/null +++ b/Docs/Modules/Settlement.md @@ -0,0 +1,63 @@ +# Поселение + +Здания и развитие города: Ратуша, участки под застройку, постройка зданий, восстановление города из сохранения. + +- **Сборка:** `Bogatyri.Settlement`. +- **Папка:** `Assets/Scripts/Modules/Settlement`. +- **Зависит от:** ядра. Ресурсы на стройку берёт через `IEconomy`. + +## Контракт `ISettlement` + +| Свойство | Что значит | +| --- | --- | +| `TownHallLevel` | Уровень Ратуши (если город не открыт — из сохранения). | +| `BuildingCount` | Сколько построек. | +| `IsCityLoaded` | Открыта ли сейчас сцена города. | + +Получить: `GameServices.Get()`. + +## События + +**Публикует:** + +- `BuildingPlacedEvent` — здание построено (имя здания); +- `TownHallLevelChangedEvent` — Ратуша улучшена (новый уровень); +- `SettlementRestoringEvent` — город начинает восстанавливаться из сохранения. На него `App/GameModules` добавляет Казармы в список стройки. + +**Слушает:** ничего. + +## Сохранение + +Секция `"settlement"`: `{ "townHallLevel", "placedBuildings": [ { "buildingName", "posX", "posY", "level" } ] }`. + +Сброс (`ResetState`): Ратуша 1 уровня, постройки убраны, участки освобождены, память модуля очищена. Иначе при следующем входе в город вернулись бы старые здания. + +Модуль помнит последнее состояние города, поэтому сохранение на карте мира не стирает постройки. При каждом входе в город (`BuildManager.Start`) здания восстанавливаются один раз. Уровень здания берётся у любого компонента с `ILeveledBuilding`. + +## Файлы + +| Файл | Что это | +| --- | --- | +| `SettlementModule.cs` | Точка входа: `ISettlement`, сохранение города, восстановление на сцене. | +| `BuildManager.cs` | Режим стройки, список зданий, участки. | +| `BuildingData.cs` | Описание здания (ScriptableObject): префаб, цена, нужный уровень Ратуши. | +| `TownHall.cs` | Ратуша и её улучшение. Раньше файл назывался `NewEmptyCSharpScript.cs`. | +| `BuildSlot.cs` | Участок под здание и связь здания с участком. | +| `PlacedBuildingId.cs` | Метка построенного здания. Раньше файл назывался `PlaceBuilding.cs`. | + +В слое приложения остаются `BuildMenuUi.cs`, `Meta/Build/*` (Казармы, `BuildModeController`). + +## Решения и план + +- **Решение команды от 19.10.2026 (этап 2):** Ратуша даёт базовую вместимость жителей, новое здание «Дом» — дополнительные места. + - Лимит населения = база Ратуши + сумма Домов + бонусы, значения из `EconomyConfig`. + - Уровень поселения задаёт доступные уровни и количество жилья, а также минимальный гражданский резерв. +- **Этап 3:** рабочие места в зданиях (`BuildingData`), экран распределения рабочих. +- **Этап 4 (утверждён без изменений):** доступность зданий зависит от месторождений позиции. + +## Журнал изменений + +| Дата | Кто | Что | +| --- | --- | --- | +| 19.10.2026 | Claude | `ResetState()` для сброса сохранений. Убранные здания помечаются, чтобы запись в том же кадре их не сохранила. | +| 19.10.2026 | Claude | Модуль выделен. Стройка и Ратуша платят через `IEconomy`. Сохранение города перенесено из `SaveManager` в `SettlementModule` (секция `"settlement"`). Восстановление защищено от двойного запуска и от освобождения занятого участка. Файлы переименованы под имена классов. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md new file mode 100644 index 0000000..52b39a5 --- /dev/null +++ b/Docs/Modules/World.md @@ -0,0 +1,59 @@ +# Глобальный мир + +Карта и перемещения: замок с боссом, 20 соседних городов, походы по реальному времени (идут, даже когда игра закрыта). + +- **Сборки:** + - `Bogatyri.World.Simulation` — карта и походы, чистый C# без Unity (переедет на сервер); + - `Bogatyri.World` — `MarchManager`, Unity-часть. +- **Папка:** `Assets/Scripts/Modules/World`. +- **Зависит от:** ядра. Войска — через `IArmy`, добыча — через `IEconomy`, бой — через `IBattleSystem`. + +## Контракт `IWorld` + +| Член | Что делает | +| --- | --- | +| `ActiveMarches` | Сколько походов в пути. | +| `TryStartMarch(targetId, soldiers, out error)` | Отправить войско на цель. | + +Получить: `GameServices.Get()`. Интерфейсу карты (`WorldMapController`) можно обращаться к `MarchManager.Instance` и его C#-событиям: там полные данные похода. + +## События + +**Публикует:** + +- `MarchStartedEvent` — войско выступило; +- `MarchBattleResolvedEvent` — бой у цели прошёл; +- `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. + +**Слушает:** ничего. `App/GameModules` по этим событиям считает статистику для заданий (`marchesSent`, `dailyMarches`, `worldWins`). + +## Сохранение + +Секция `"world"`: `{ "marches": [ MarchData ] }`. + +Сброс (`ResetState`): все походы отменяются. Войска в походе не возвращаются: их сбрасывает `GameProgress` вместе с армией. + +Раньше походы лежали в `progress.json` (`sashaMarches`). При первом запуске `App/GameModules` переносит их в модуль Мира и удаляет из `progress.json` только после записи `save.json`. + +## Файлы + +| Файл | Что это | +| --- | --- | +| `MarchManager.cs` | Ведёт походы, реализует `IWorld` и `ISaveParticipant`. Создаётся сам при запуске игры. | +| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей из seed. | +| `Simulation/WorldSimulation.cs` | Походы: старт, бой по прибытии, возвращение. Бой получает через `IBattleSystem`. | + +В слое приложения остаются `WorldMap/WorldMapController.cs`, `MarchView.cs`, `MapCameraController.cs`, `SceneNavigator.cs` и вторая карта `Meta/World/*`. + +## Решения и план + +- **Этап 0:** в проекте две карты мира — эта и `Meta/World/WorldSystem`. Команда должна выбрать одну. По решению от 19.10.2026 вторую реализацию не удалять без проверки контрактов и миграции сохранений. +- **Этап 4 (утверждён без изменений):** у позиций на карте — месторождения и их качество. +- **Этап 6:** временные ресурсные точки и кристальные разломы; войска на точке недоступны дома. + +## Журнал изменений + +| Дата | Кто | Что | +| --- | --- | --- | +| 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений походы отменяются. | +| 19.10.2026 | Claude | Модуль выделен. `MarchManager` больше не обращается к `GameProgress` и `ResourceManager`: войска через `IArmy`, добыча через `IEconomy`, сохранение — своя секция `"world"`, статистика — через события. Формула боя вынесена в модуль Боя. |