diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index 48de6f2..faa7f6b 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -15,8 +15,12 @@ public static class GameModules // Боевая система -- чистый C#, сама зарегистрироваться не может GameServices.Register(new BattleSystem()); - // Армия: войска пока хранятся в GameProgress, контракт реализует мост - GameServices.Register(ArmyBridge.Service); + // Армия -- чистый C#, единственное место хранения войск (секция "army") + var army = new ArmyService(); + GameServices.Register(army); + SaveRegistry.Register(army); + EventBus.Subscribe(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); }); + EventBus.Subscribe(_ => MigrateLegacyArmy(army)); // Круг -- чистый C#, сам сохраняет, в каком Круге игрок (секция "circle") var circle = new CircleService(); @@ -32,6 +36,10 @@ public static class GameModules // Статистика для заданий (GameProgress) -- по событиям Мира EventBus.Subscribe(OnMarchStarted); EventBus.Subscribe(OnMarchReturned); + + // Окно "ВОЙСКО ВЕРНУЛОСЬ" в городе + MarchReturnPopup.Reset(); + EventBus.Subscribe(MarchReturnPopup.OnMarchReturned); } // Сцена загружена, Awake всех объектов прошли, Start -- ещё нет (сохранение грузится в Start) @@ -58,6 +66,37 @@ public static class GameModules Debug.Log("[МОДУЛИ] Походы перенесены из progress.json в модуль Мира."); } + // Раньше войска хранились в progress.json (GameProgress.Data.troops и sashaMarchUnits моста ArmyBridge). + // Теперь -- в модуле Армии, save.json, секция "army". Переносим один раз, после загрузки сохранения. + static void MigrateLegacyArmy(ArmyService army) + { + var data = GameProgress.Instance.Data; + if (data.armyInModule) return; + + if (!army.RestoredFromSave) + { + var legacyMarches = new System.Collections.Generic.List>(); + foreach (var m in data.sashaMarchUnits) + legacyMarches.Add(new System.Collections.Generic.KeyValuePair(m.marchId, m.units)); + army.ImportLegacy(data.troops, legacyMarches); + Debug.Log($"[МОДУЛИ] Войска перенесены из progress.json в модуль Армии: в городе {army.SoldiersAtHome}, в походах {army.TroopsInMarches}."); + } + + // Из progress.json убираем, только когда войска уже записаны в save.json -- иначе вылет игры их потеряет + if (army.RestoredFromSave) ClearLegacyArmy(default); + else EventBus.Subscribe(ClearLegacyArmy); + } + + static void ClearLegacyArmy(GameSavedEvent e) + { + EventBus.Unsubscribe(ClearLegacyArmy); + var data = GameProgress.Instance.Data; + data.armyInModule = true; + System.Array.Clear(data.troops, 0, data.troops.Length); + data.sashaMarchUnits.Clear(); + GameProgress.Instance.MarkChanged(); + } + static void OnMarchStarted(MarchStartedEvent e) { var data = GameProgress.Instance.Data; diff --git a/Assets/Scripts/Meta/Core/GameProgress.cs b/Assets/Scripts/Meta/Core/GameProgress.cs index 478c213..7cec5ca 100644 --- a/Assets/Scripts/Meta/Core/GameProgress.cs +++ b/Assets/Scripts/Meta/Core/GameProgress.cs @@ -11,6 +11,7 @@ using UnityEngine; public class GameProgress : MonoBehaviour { static GameProgress instance; + public static bool HasInstance { get { return instance != null; } } public static GameProgress Instance { get @@ -151,7 +152,7 @@ public class GameProgress : MonoBehaviour if (Data.eventClaimed == null) Data.eventClaimed = new List(); ReturnLegacyWorldMarches(); if (Data.sashaMarches == null) Data.sashaMarches = new List(); - if (Data.sashaMarchUnits == null) Data.sashaMarchUnits = new List(); + if (Data.sashaMarchUnits == null) Data.sashaMarchUnits = new List(); if (Data.level < 1) Data.level = 1; if (Data.heroLevel < 1) Data.heroLevel = 1; if (Data.battleStage < 1) Data.battleStage = 1; @@ -219,6 +220,7 @@ public class GameProgress : MonoBehaviour Data = new ProgressData(); FixData(); + Data.armyInModule = true; // войска сбросил модуль Армии, переносить из progress.json нечего Data.lastLoginDay = Today; Data.daysPlayed = 1; EnsureDailyAndWeekly(); SendStarterMail(); @@ -397,7 +399,7 @@ public class GameProgress : MonoBehaviour get { int p = 0; - for (int i = 0; i < GameData.Troops.Count; i++) p += Data.troops[i] * GameData.Troops[i].power; + for (int i = 0; i < GameData.Troops.Count; i++) p += TroopCount(i) * GameData.Troops[i].power; return p; } } @@ -617,9 +619,14 @@ public class GameProgress : MonoBehaviour // ВОЙСКА // ===================================================================== + // Войска хранит модуль Армии (Docs/Modules/Army.md) -- здесь только обращение к нему + static IArmy Army => GameServices.Get(); + + public int TroopCount(int type) { return Army.GetTroops(type); } + public int TotalTroops { - get { int n = 0; foreach (var t in Data.troops) n += t; return n; } + get { return Army.SoldiersAtHome; } } public bool TroopUnlocked(int index) { return TownHallLevel >= GameData.Troops[index].requiredTownHall; } @@ -674,7 +681,7 @@ public class GameProgress : MonoBehaviour { var o = Data.training[0]; Data.training.RemoveAt(0); - Data.troops[o.troopIndex] += o.count; + Army.AddTroops(o.troopIndex, o.count); Data.troopsTrained += o.count; Data.dailyTroops += o.count; Data.eventTroopRemainder += o.count; @@ -690,14 +697,7 @@ public class GameProgress : MonoBehaviour int LoseTroops(float fraction) { if (HasHeroSkill(3)) fraction *= 0.8f; - int lost = 0; - for (int i = 0; i < Data.troops.Length; i++) - { - int l = Mathf.FloorToInt(Data.troops[i] * fraction); - Data.troops[i] -= l; - lost += l; - } - return lost; + return Army.LoseFraction(fraction); } // ===================================================================== diff --git a/Assets/Scripts/Meta/Core/ProgressData.cs b/Assets/Scripts/Meta/Core/ProgressData.cs index 08c519d..cab244c 100644 --- a/Assets/Scripts/Meta/Core/ProgressData.cs +++ b/Assets/Scripts/Meta/Core/ProgressData.cs @@ -92,7 +92,10 @@ public class ProgressData public int heroLevel = 1; // --- Войска --- + // Войска теперь хранит модуль Армии (save.json, секция "army"). Это поле -- только для переноса + // из старых сохранений: при первой загрузке его читает App/GameModules, потом оно обнуляется. public int[] troops = new int[4]; + public bool armyInModule; // войска уже перенесены в модуль Армии public List training = new List(); // --- Бой (кампания) --- @@ -114,7 +117,7 @@ public class ProgressData // --- Карта мира (сцена WorldMap) --- public List sashaMarches = new List(); - public List sashaMarchUnits = new List(); + public List sashaMarchUnits = new List(); // только для переноса в модуль Армии // --- Событие --- public int eventWeek = -1; @@ -131,3 +134,11 @@ public class LegacyWorldMarch public int[] survivors = new int[4]; // вернутся домой public bool returning; } + +// Состав похода из старого progress.json (раньше хранил мост ArmyBridge) -- только для переноса в модуль Армии +[Serializable] +public class LegacyMarchUnits +{ + public int marchId; + public int[] units = new int[4]; +} diff --git a/Assets/Scripts/Meta/Panels/TroopsPanel.cs b/Assets/Scripts/Meta/Panels/TroopsPanel.cs index d2d0b39..86a152c 100644 --- a/Assets/Scripts/Meta/Panels/TroopsPanel.cs +++ b/Assets/Scripts/Meta/Panels/TroopsPanel.cs @@ -23,7 +23,7 @@ public class TroopsPanel : BasePanel var army = UIKit.Card(list, UIKit.Theme.CardDark); ProfilePanel.Header(army, "Армия"); ProfilePanel.KV(army, "Всего воинов в городе", GP.TotalTroops.ToString("N0")); - ProfilePanel.KV(army, "В походе на карте мира", ArmyBridge.TroopsInMarches.ToString("N0")); + ProfilePanel.KV(army, "В походе на карте мира", GameServices.Get().TroopsInMarches.ToString("N0")); ProfilePanel.KV(army, "Сила войск", GP.TroopsPower.ToString("N0")); ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0")); UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26); @@ -76,7 +76,7 @@ public class TroopsPanel : BasePanel UIKit.Icon(top, unlocked ? def.color : UIKit.Theme.Gray, def.name.Substring(0, 1), 110); var info = UIKit.Card(top, new Color(0, 0, 0, 0), 0, 2); UIKit.Size(info, -1, -1, 1); - UIKit.Text(info, def.name + " в армии: " + d.troops[i].ToString("N0") + "", 32).fontStyle = FontStyles.Bold; + UIKit.Text(info, def.name + " в армии: " + GP.TroopCount(i).ToString("N0") + "", 32).fontStyle = FontStyles.Bold; UIKit.Text(info, def.description, 22, UIKit.Theme.TextDim); UIKit.Text(info, "Сила " + def.power + " • " + def.secondsPerUnit + " сек/воин • " + GameData.RewardsText(def.cost), 22, UIKit.Theme.TextDim); diff --git a/Assets/Scripts/Modules/Army/ArmyService.cs b/Assets/Scripts/Modules/Army/ArmyService.cs new file mode 100644 index 0000000..9a798ae --- /dev/null +++ b/Assets/Scripts/Modules/Army/ArmyService.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +// МОДУЛЬ АРМИИ: единственное место, где хранятся войска. Документ: Docs/Modules/Army.md +// Без Unity: правила те же, что будут на сервере. +// +// Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches). +// Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. +public sealed class ArmyService : IArmy, ISaveParticipant +{ + public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops + + private class MarchUnits + { + public int marchId; + public int[] units = new int[Types]; + } + + private readonly int[] troops = new int[Types]; + private readonly List marches = new List(); + + // Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно + public bool RestoredFromSave { get; private set; } + + // ---------------- Контракт IArmy ---------------- + + public int TypeCount => Types; + + public int GetTroops(int type) + { + return type >= 0 && type < Types ? troops[type] : 0; + } + + public int SoldiersAtHome + { + get { int n = 0; foreach (var t in troops) n += t; return n; } + } + + public int TroopsInMarches + { + get + { + int n = 0; + foreach (var m in marches) foreach (var u in m.units) n += u; + return n; + } + } + + public void AddTroops(int type, int count) + { + if (type < 0 || type >= Types || count <= 0) return; + troops[type] += count; + Changed(); + } + + public int LoseFraction(double fraction) + { + if (fraction <= 0) return 0; + if (fraction > 1) fraction = 1; + + int lost = 0; + for (int i = 0; i < Types; i++) + { + int l = (int)Math.Floor(troops[i] * fraction); + troops[i] -= l; + lost += l; + } + if (lost > 0) Changed(); + return lost; + } + + public bool TakeForMarch(int marchId, int count) + { + int[] taken = ArmyMath.TakeProportional(troops, count); + if (taken == null) return false; + + for (int i = 0; i < Types; i++) troops[i] -= taken[i]; + marches.RemoveAll(m => m.marchId == marchId); + marches.Add(new MarchUnits { marchId = marchId, units = taken }); + Changed(); + return true; + } + + public void ReturnFromMarch(int marchId, int survivors) + { + MarchUnits entry = marches.Find(m => m.marchId == marchId); + if (entry == null) + { + troops[0] += Math.Max(0, survivors); // состав неизвестен (очень старое сохранение) -- считаем пехотой + } + else + { + int[] back = ArmyMath.SplitSurvivors(entry.units, survivors); + for (int i = 0; i < Types; i++) troops[i] += back[i]; + marches.Remove(entry); + } + Changed(); + } + + void Changed() + { + EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches }); + } + + // ---------------- Перенос из старого progress.json ---------------- + + // Войска раньше хранил GameProgress (troops) и мост ArmyBridge (sashaMarchUnits). + // Вызывается один раз, если в save.json ещё нет секции "army". + public void ImportLegacy(int[] legacyTroops, IEnumerable> legacyMarches) + { + if (legacyTroops != null) + for (int i = 0; i < Types && i < legacyTroops.Length; i++) troops[i] = Math.Max(0, legacyTroops[i]); + + marches.Clear(); + if (legacyMarches != null) + { + foreach (var m in legacyMarches) + { + var units = new int[Types]; + for (int i = 0; i < Types && m.Value != null && i < m.Value.Length; i++) units[i] = Math.Max(0, m.Value[i]); + marches.Add(new MarchUnits { marchId = m.Key, units = units }); + } + } + Changed(); + } + + // ---------------- Сохранение (секция "army") ---------------- + // Модуль без Unity, поэтому формат -- простые строки: + // troops=<пехота>,<лучники>,<кавалерия>,<осадные> + // march=:<пехота>,<лучники>,<кавалерия>,<осадные> + + public string SaveKey => "army"; + + public string Capture() + { + var sb = new StringBuilder(); + sb.Append("troops=").Append(Join(troops)).Append('\n'); + foreach (var m in marches) + sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n'); + return sb.ToString(); + } + + public void Restore(string data) + { + Array.Clear(troops, 0, Types); + marches.Clear(); + RestoredFromSave = true; + + if (!string.IsNullOrEmpty(data)) + { + foreach (var raw in data.Split('\n')) + { + string line = raw.Trim(); + if (line.StartsWith("troops=")) + { + int[] t = Parse(line.Substring("troops=".Length)); + Array.Copy(t, troops, Types); + } + else if (line.StartsWith("march=")) + { + string[] parts = line.Substring("march=".Length).Split(':'); + if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id)) + marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) }); + } + } + } + Changed(); + } + + // Новая игра: войск нет, походов нет + public void ResetState() + { + Array.Clear(troops, 0, Types); + marches.Clear(); + Changed(); + } + + static string Join(int[] values) + { + var parts = new string[values.Length]; + for (int i = 0; i < values.Length; i++) parts[i] = values[i].ToString(CultureInfo.InvariantCulture); + return string.Join(",", parts); + } + + static int[] Parse(string csv) + { + var result = new int[Types]; + string[] parts = csv.Split(','); + for (int i = 0; i < Types && i < parts.Length; i++) + { + if (int.TryParse(parts[i].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int v)) + result[i] = Math.Max(0, v); + } + return result; + } +} diff --git a/Assets/Scripts/Modules/Army/ArmyService.cs.meta b/Assets/Scripts/Modules/Army/ArmyService.cs.meta new file mode 100644 index 0000000..76d6b00 --- /dev/null +++ b/Assets/Scripts/Modules/Army/ArmyService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 933b37f69803d5b4eaf560fabe246137 \ No newline at end of file diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 639e44a..77962ae 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -35,7 +35,14 @@ public interface ILeveledBuilding // ---------------- Армия: войска ---------------- public interface IArmy { - int SoldiersAtHome { get; } + int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные) + int GetTroops(int type); // воинов этого типа в городе + int SoldiersAtHome { get; } // всего воинов в городе + int TroopsInMarches { get; } // воинов в походах + + void AddTroops(int type, int count); // обучены / вернулись + int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло + // Забрать воинов из города в поход (запоминает состав похода) bool TakeForMarch(int marchId, int count); // Вернуть выживших из похода по тем же типам войск diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index 53c6c1e..38c0807 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -24,6 +24,14 @@ public struct TownHallLevelChangedEvent // здания, которые появляются кодом (например, Казармы) public struct SettlementRestoringEvent { } +// ---------------- Армия ---------------- +// Число воинов изменилось (обучение, потери, поход, возвращение, загрузка) +public struct TroopsChangedEvent +{ + public int AtHome; + public int InMarches; +} + // ---------------- Глобальный мир ---------------- public struct MarchStartedEvent { @@ -46,6 +54,9 @@ public struct MarchReturnedEvent public int MarchId; public bool Victory; public int Survivors; + public int Sent; // сколько воинов уходило в поход + public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа) + public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет" } // ---------------- Чат ---------------- diff --git a/Assets/Scripts/Modules/Save/SaveManager.cs b/Assets/Scripts/Modules/Save/SaveManager.cs index a09ce7d..a14637d 100644 --- a/Assets/Scripts/Modules/Save/SaveManager.cs +++ b/Assets/Scripts/Modules/Save/SaveManager.cs @@ -122,6 +122,7 @@ public class SaveManager : MonoBehaviour, ISaveSystem if (!HasSaveFile()) { Debug.Log("[ЗАГРУЗКА] Файл сохранения не найден, начинаем новую игру."); + EventBus.Publish(new GameLoadedEvent()); // загрузка завершена, просто без файла return; } diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index e37ae14..e78b5fc 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -56,6 +56,67 @@ public class ModuleTests Assert.AreEqual(9, sum); } + [Test] + public void ArmyService_MarchNeverCreatesOrLosesSoldiers() + { + var army = new ArmyService(); + army.AddTroops(0, 20); army.AddTroops(1, 7); army.AddTroops(3, 3); + int total = army.SoldiersAtHome; + + Assert.IsTrue(army.TakeForMarch(1, 12)); + Assert.AreEqual(total, army.SoldiersAtHome + army.TroopsInMarches, "солдат либо дома, либо в походе"); + + army.ReturnFromMarch(1, 12); + Assert.AreEqual(total, army.SoldiersAtHome); + Assert.AreEqual(0, army.TroopsInMarches); + + Assert.IsFalse(army.TakeForMarch(2, total + 1), "нельзя отправить больше, чем есть"); + } + + [Test] + public void ArmyService_LoseFraction_FloorsPerType() + { + var army = new ArmyService(); + army.AddTroops(0, 10); army.AddTroops(1, 5); + int lost = army.LoseFraction(0.25); + Assert.AreEqual(2 + 1, lost); // floor(2.5) + floor(1.25) + Assert.AreEqual(8, army.GetTroops(0)); + Assert.AreEqual(4, army.GetTroops(1)); + } + + [Test] + public void ArmyService_SaveRestore_KeepsTroopsAndMarches() + { + var a = new ArmyService(); + a.AddTroops(0, 9); a.AddTroops(2, 4); + a.TakeForMarch(5, 6); + + var b = new ArmyService(); + b.Restore(a.Capture()); + Assert.IsTrue(b.RestoredFromSave); + Assert.AreEqual(a.SoldiersAtHome, b.SoldiersAtHome); + Assert.AreEqual(a.TroopsInMarches, b.TroopsInMarches); + + b.ReturnFromMarch(5, 6); + Assert.AreEqual(13, b.SoldiersAtHome, "состав похода сохранился"); + } + + [Test] + public void ArmyService_ImportLegacy_AndReset() + { + var army = new ArmyService(); + int events = 0; + EventBus.Subscribe(_ => events++); + + army.ImportLegacy(new[] { 5, 0, 2, 0 }, new[] { new KeyValuePair(3, new[] { 1, 1, 0, 0 }) }); + Assert.AreEqual(7, army.SoldiersAtHome); + Assert.AreEqual(2, army.TroopsInMarches); + Assert.Greater(events, 0, "изменение войск сообщается событием"); + + army.ResetState(); + Assert.AreEqual(0, army.SoldiersAtHome + army.TroopsInMarches); + } + // ---------------- Боевая система ---------------- [Test] diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index 65b13a1..53cba61 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -150,7 +150,15 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant Debug.Log($"[ПОХОД] Войско вернулось: {march.survivors} солдат, добыча: {FormatLoot(march.loot)}"); OnMarchReturned?.Invoke(march); - EventBus.Publish(new MarchReturnedEvent { MarchId = march.id, Victory = march.victory, Survivors = march.survivors }); + EventBus.Publish(new MarchReturnedEvent + { + MarchId = march.id, + Victory = march.victory, + Survivors = march.survivors, + Sent = march.soldiers, + TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "", + LootText = FormatLoot(march.loot) + }); } // ---------------- Сохранение (секция "world") ---------------- diff --git a/Assets/Scripts/UI/MarchReturnPopup.cs b/Assets/Scripts/UI/MarchReturnPopup.cs new file mode 100644 index 0000000..df15a34 --- /dev/null +++ b/Assets/Scripts/UI/MarchReturnPopup.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using UnityEngine; + +// Окно "ВОЙСКО ВЕРНУЛОСЬ" в городе (слой приложения): куда ходили, итог, сколько вернулось, добыча. +// Показывается по событию MarchReturnedEvent модуля Мира -- в том числе за походы, закончившиеся, +// пока игра была закрыта. Несколько возвращений -- окна по очереди. На карте мира своё окно (WorldMapController). +// Подключается в App/GameModules. Документ: Docs/Modules/World.md +public static class MarchReturnPopup +{ + static readonly Queue queue = new Queue(); + static bool showing; + + public static void Reset() + { + queue.Clear(); + showing = false; + } + + public static void OnMarchReturned(MarchReturnedEvent e) + { + // Только в городе: на карте мира возвращение уже показывает её собственное окно + if (!GameServices.TryGet(out ISettlement settlement) || !settlement.IsCityLoaded) return; + + queue.Enqueue(e); + if (!showing) ShowNext(); + } + + static void ShowNext() + { + if (queue.Count == 0) { showing = false; return; } + showing = true; + + MarchReturnedEvent e = queue.Dequeue(); + string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : $"Поход на «{e.TargetName}»"; + string result = e.Victory ? "победа" : "поражение"; + int lost = Mathf.Max(0, e.Sent - e.Survivors); + + string text = $"{target}: {result}\n" + + $"Вернулись {e.Survivors} из {e.Sent} воинов" + (lost > 0 ? $" (погибли {lost})" : "") + "\n" + + $"Добыча: {e.LootText}"; + + UIKit.Message("ВОЙСКО ВЕРНУЛОСЬ", text, "ОК", ShowNext); + } +} diff --git a/Assets/Scripts/UI/MarchReturnPopup.cs.meta b/Assets/Scripts/UI/MarchReturnPopup.cs.meta new file mode 100644 index 0000000..cdfb49f --- /dev/null +++ b/Assets/Scripts/UI/MarchReturnPopup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 98203b51a9434c518f09eaa9b2e58dd4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/WorldMap/ArmyBridge.cs b/Assets/Scripts/WorldMap/ArmyBridge.cs deleted file mode 100644 index 2da2904..0000000 --- a/Assets/Scripts/WorldMap/ArmyBridge.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using UnityEngine; - -// МОСТ между модулем Армии и данными войск, которые пока хранятся в GameProgress (4 типа войск). -// Реализует контракт IArmy: модуль Мира берёт и возвращает воинов только через него. -// Документ: Docs/Modules/Army.md -// -// - "Солдаты дома" = все наши воины в городе. -// - При отправке похода берём воинов из всех типов пропорционально и запоминаем состав похода. -// - При возвращении выживших раздаём обратно по тем же типам в той же пропорции. -// Сами правила деления -- в ArmyMath (модуль Армии, без Unity). -// -// Решение команды от 19.10.2026: не удалять этот мост, пока войска не переедут в модуль Армии -// вместе с миграцией сохранений (поле sashaMarchUnits в progress.json). -public static class ArmyBridge -{ - [Serializable] - public class MarchUnits - { - public int marchId; - 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; } } - - // Воинов сейчас в походах на карте мира - public static int TroopsInMarches - { - get - { - int n = 0; - foreach (var m in D.sashaMarchUnits) foreach (var u in m.units) n += u; - return n; - } - } - - // Забрать count воинов из города в поход marchId - public static bool Take(int marchId, int count) - { - var troops = D.troops; - 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 }; - for (int i = 0; i < 4 && i < taken.Length; i++) entry.units[i] = taken[i]; - D.sashaMarchUnits.RemoveAll(e => e.marchId == marchId); - D.sashaMarchUnits.Add(entry); - GameProgress.Instance.MarkChanged(); - return true; - } - - // Вернуть survivors выживших из похода marchId по типам войск - public static void Return(int marchId, int survivors) - { - var entry = D.sashaMarchUnits.Find(e => e.marchId == marchId); - var troops = D.troops; - if (entry == null) - { - troops[0] += Mathf.Max(0, survivors); // состав неизвестен (старое сохранение) -- считаем пехотой - } - else - { - 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/ArmyBridge.cs.meta b/Assets/Scripts/WorldMap/ArmyBridge.cs.meta deleted file mode 100644 index 53a7f01..0000000 --- a/Assets/Scripts/WorldMap/ArmyBridge.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d2859863e72b29c4a8630e5d956967e2 \ No newline at end of file diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index 87defb5..e71ea19 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -292,7 +292,7 @@ public class WorldMapController : MonoBehaviour static int GetSoldiersAtHome() { return ResourceManager.Instance != null - ? ArmyBridge.SoldiersAtHome // [объединение] наши воины всех типов + ? GameServices.Get().SoldiersAtHome // [объединение] наши воины всех типов : 0; } diff --git a/Docs/Modules/Army.md b/Docs/Modules/Army.md index 0dfa4c6..d6ec890 100644 --- a/Docs/Modules/Army.md +++ b/Docs/Modules/Army.md @@ -1,6 +1,6 @@ # Армия -Войска и их характеристики: сколько воинов дома, как они уходят в поход и возвращаются. +Войска и их характеристики: сколько воинов каждого типа в городе, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.** - **Сборка:** `Bogatyri.Army` (без Unity). - **Папка:** `Assets/Scripts/Modules/Army`. @@ -10,48 +10,65 @@ | Член | Что делает | | --- | --- | -| `SoldiersAtHome` | Воинов в городе (все типы). | -| `TakeForMarch(marchId, count)` | Забрать воинов в поход пропорционально составу армии и запомнить состав похода. | +| `TypeCount` | Типов войск — 4 (пехота, лучники, кавалерия, осадные; порядок как в `GameData.Troops`). | +| `GetTroops(type)` | Воинов этого типа в городе. | +| `SoldiersAtHome` | Всего воинов в городе. | +| `TroopsInMarches` | Воинов в походах. | +| `AddTroops(type, count)` | Добавить воинов (обучение в Казармах). | +| `LoseFraction(fraction)` | Потери в бою: от каждого типа погибает доля (с округлением вниз); возвращает, сколько погибло. | +| `TakeForMarch(marchId, count)` | Забрать воинов в поход пропорционально составу армии и запомнить состав. | | `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам. | -Получить: `GameServices.Get()`. +Получить: `GameServices.Get()`. Регистрирует `App/GameModules`. `GameProgress` (обучение, кампания, арена, сила армии) и панели работают с войсками только через этот контракт. -## Где сейчас данные +## Правило «один солдат в одном месте» -Войска пока хранятся в `GameProgress` (`progress.json`, поле `troops`, 4 типа). Поэтому контракт реализует мост `Assets/Scripts/WorldMap/ArmyBridge.cs` (`ArmyBridge.Service`), а модуль Армии содержит правила деления войск `ArmyMath`. - -**Решение команды от 19.10.2026:** `ArmyBridge` не удалять, пока войска не переедут в модуль Армии вместе с миграцией сохранений (поле `sashaMarchUnits`). +Воин либо в городе, либо в походе. Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. Тест `ArmyService_MarchNeverCreatesOrLosesSoldiers` проверяет, что «дома + в походах» всегда равно общему числу. ## События -- **Публикует:** пока ничего. -- **Слушает:** ничего (походы вызывают контракт напрямую). +- **Публикует:** `TroopsChangedEvent` (воинов в городе и в походах) — при обучении, потерях, походе, возвращении, загрузке, сбросе. По нему `App/GameModules` перерисовывает панели HUD. +- **Слушает:** ничего. ## Сохранение -Пока через `GameProgress`. Когда войска переедут в модуль, у него появится своя секция `"army"` и перенос из `progress.json`. +Секция `"army"`. Модуль без Unity, поэтому формат — простые строки: + +``` +troops=<пехота>,<лучники>,<кавалерия>,<осадные> +march=:<пехота>,<лучники>,<кавалерия>,<осадные> +``` + +Сброс (`ResetState`): войск и походов нет. + +**Перенос из старых сохранений:** +- Раньше войска лежали в `progress.json`: `troops` хранил `GameProgress`, `sashaMarchUnits` — мост `ArmyBridge`. +- Если в `save.json` нет секции `"army"`, после загрузки `App/GameModules` переносит их в модуль (`ImportLegacy`). +- Из `progress.json` они удаляются только после записи `save.json`. Флаг `armyInModule` отмечает, что перенос сделан. ## Файлы | Файл | Что это | | --- | --- | -| `ArmyMath.cs` | `TakeProportional` — кого взять в поход; `SplitSurvivors` — как вернуть выживших. Без Unity. | -| `../../WorldMap/ArmyBridge.cs` (слой приложения) | Реализация `IArmy` поверх `GameProgress`; `TroopsInMarches` — воинов в походах на карте мира. | +| `ArmyService.cs` | Хранилище войск, реализует `IArmy` и `ISaveParticipant`. | +| `ArmyMath.cs` | `TakeProportional` — кого взять в поход; `SplitSurvivors` — как вернуть выживших. | ## Решения и план -- **Этап 5 (план разработки):** солдат стоит 1 человека, 15 еды, 5 металла, 5 золота; содержание 0,2 еды в час; погибшие уменьшают население. +- **Этап 5 (план разработки):** солдат стоит 1 человека, 15 еды, 5 металла, 5 золота; содержание 0,2 еды в час; погибшие уменьшают население. Делается вместе с модулем «Население». - **Решение команды от 19.10.2026:** - войска набираются из населения с сохранением гражданского минимума; - гарнизон поселения: часть солдат назначается защищать город; - - общая армия = гарнизон + доступные отряды + войска в походах, на точках и в гарнизоне Круга; **один солдат не может быть в двух местах**; + - общая армия = гарнизон + доступные отряды + войска в походах, на точках и в гарнизоне Круга, **один солдат не может быть в двух местах**; - гарнизон ест еду и участвует в защите; - при голоде дебаффы действуют и на гарнизон, и на походы. -- **Следующий шаг модуля:** перенести `troops` и `sashaMarchUnits` из `GameProgress` в модуль (секция `"army"`), заменить `ArmyBridge` на реализацию внутри модуля. +- **Следующий шаг модуля:** гарнизон — третье место для солдата рядом с «дома» и «в походе». +- Описания типов войск (`GameData.Troops`: цена, сила, время обучения) пока в слое приложения; цены перейдут в `EconomyConfig`. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 23.10.2026 | Claude | Войска переехали в модуль: `ArmyService` — единственное хранилище войск (секция `"army"`), `IArmy` расширен (типы, обучение, потери, войска в походах), событие `TroopsChangedEvent`. Перенос из `progress.json` с удалением старых данных только после записи `save.json`. `ArmyBridge` удалён — контракты и перенос сохранений проверены тестами. | | 19.10.2026 | Claude | `ArmyBridge.TroopsInMarches` для панели «Войска». После удаления маленькой карты войска в походах учитываются только здесь (`sashaMarchUnits`). | | 19.10.2026 | Claude | Модуль создан: `ArmyMath` (правила деления войск, без Unity, тесты на отсутствие двойного учёта). `ArmyBridge` сохранён и теперь реализует `IArmy` через `ArmyMath`; формат `sashaMarchUnits` не изменён. | diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index 22842fc..1d8e98e 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -28,7 +28,7 @@ | `IEconomy` | Экономика (`ResourceManager`) | Узнать, добавить, потратить ресурс; проверить и списать цену целиком. | | `ISettlement` | Поселение (`SettlementModule`) | Уровень Ратуши, число построек, открыт ли город. | | `ILeveledBuilding` | Здания с уровнем (`ResourceCollector`) | Уровень здания — его сохраняет Поселение. | -| `IArmy` | Армия (`ArmyBridge.Service`) | Воины дома; забрать в поход; вернуть выживших. | +| `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. | | `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. | | `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`. | | `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. | @@ -53,6 +53,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 23.10.2026 | Claude | `IArmy` расширен (типы, `AddTroops`, `LoseFraction`, `TroopsInMarches`), событие `TroopsChangedEvent`. `GameLoadedEvent` публикуется и когда файла сохранения нет. | | 19.10.2026 | Claude | Контракт `ICircle` и типы `CircleInfo`, `CircleMember`, `CircleRole`; события `CircleJoinedEvent`, `CircleLeftEvent`; `IChat.CanWrite`. | | 19.10.2026 | Claude | Контракт `IChat`, типы `ChatChannel` и `ChatMessage`, событие `ChatMessageReceivedEvent`, `LocalPlayer`. | | 19.10.2026 | Claude | Сброс сохранений: `ISaveSystem.ResetAll()`, `ISaveParticipant.ResetState()`, событие `GameResetEvent`. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 497d2b8..b93be0e 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -46,13 +46,13 @@ 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; +- [ ] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (основа — тест «один солдат в одном месте» для походов уже есть); - [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу; - [ ] предел синих кристаллов: кристаллы не копятся бесконечно, сверх лимита — по общему правилу. @@ -60,6 +60,8 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 23.10.2026 | Claude | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе (`UI/MarchReturnPopup.cs`), обновлён World. | +| 23.10.2026 | Claude | Войска переехали в модуль Армии (`ArmyService`, секция `army`), `ArmyBridge` удалён. Обновлены Army, Core, Save. | | 19.10.2026 | Claude | Оставлена одна карта мира (сцена WorldMap). Маленькая карта `Meta/World` удалена с переносом солдат из её походов. Обновлены World и Army. | | 19.10.2026 | Claude | Добавлен модуль Круга (`Bogatyri.Circle`, [Circle.md](Circle.md)): основать, вступить, выйти, роли, окно «Круг» на карте мира. Канал «Круг» в чате привязан к Кругу игрока. | | 19.10.2026 | Claude | Добавлен модуль Чата (`Bogatyri.Chat`, [Chat.md](Chat.md)) с разворотом на весь экран. В сцене города кнопка «ПОЧТА» привязана к правому краю нижней панели — в безопасной зоне она уезжала за экран. | diff --git a/Docs/Modules/Save.md b/Docs/Modules/Save.md index 56c2930..36c3091 100644 --- a/Docs/Modules/Save.md +++ b/Docs/Modules/Save.md @@ -36,6 +36,7 @@ SaveRegistry.Register(myModule); // при запуске модуля | `settlement` | Поселение | | `world` | Глобальный мир | | `circle` | Круг (простые строки, не JSON — модуль без Unity) | +| `army` | Армия (простые строки: `troops=…`, `march=…`) | ## Формат файла @@ -68,7 +69,8 @@ SaveRegistry.Register(myModule); // при запуске модуля - **Экономика** — стартовые ресурсы из Inspector `ResourceManager`; - **Поселение** — Ратуша 1 уровня, постройки убраны, участки свободны; - **Мир** — все походы отменены; -- **Круг** — игрок ни в каком Круге, его Круги распущены. +- **Круг** — игрок ни в каком Круге, его Круги распущены; +- **Армия** — войск и походов нет. Новый модуль с данными обязан реализовать `ResetState()`, иначе он не скомпилируется: метод входит в контракт. @@ -76,7 +78,7 @@ SaveRegistry.Register(myModule); // при запуске модуля **Публикует:** -- `GameLoadedEvent` — после загрузки; +- `GameLoadedEvent` — после загрузки (и когда файла сохранения нет — загрузка завершена, новая игра); - `GameSavedEvent` — после записи; - `GameResetEvent` — после сброса сохранений. @@ -92,13 +94,14 @@ SaveRegistry.Register(myModule); // при запуске модуля ## Решения и план -- **Этап 0:** одно сохранение. `GameProgress` (`progress.json`: герой, задания, почта, войска) пока пишет свой файл. Его данные переводятся в секции по мере выноса в модули: первыми — войска (модуль Армии). +- **Этап 0:** одно сохранение. `GameProgress` (`progress.json`: герой, задания, почта, магазин, арена) пока пишет свой файл. Его данные переводятся в секции по мере выноса в модули. Войска уже перенесены — секция `army`. - Номер версии файла поднимать при каждом несовместимом изменении и дописывать перенос старой версии здесь. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 23.10.2026 | Claude | Новая секция `army` (модуль Армии). `GameLoadedEvent` публикуется и без файла сохранения. | | 19.10.2026 | Claude | Новая секция `circle` (модуль Круга). | | 19.10.2026 | Claude | Сброс сохранений: `ISaveSystem.ResetAll()`, `ResetState()` у модулей, `GameResetEvent`, меню редактора. Кнопка в Настройках теперь сбрасывает и походы, и память Поселения. | | 19.10.2026 | Claude | `SaveManager` переписан на секции модулей (`ISaveParticipant`), формат v2 с переносом v1. Запись до загрузки запрещена. Сохранение города вынесено в модуль Поселения, походов — в модуль Мира. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index 67122f5..a31971e 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -23,9 +23,16 @@ - `MarchStartedEvent` — войско выступило; - `MarchBattleResolvedEvent` — бой у цели прошёл; -- `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. +- `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. Поля: `MarchId`, `Victory`, `Survivors`, `Sent`, `TargetName`, `LootText`. -**Слушает:** ничего. `App/GameModules` по этим событиям считает статистику для заданий (`marchesSent`, `dailyMarches`, `worldWins`). +**Слушает:** ничего. + +По этим событиям `App/GameModules`: +- считает статистику для заданий (`marchesSent`, `dailyMarches`, `worldWins`); +- показывает окно «ВОЙСКО ВЕРНУЛОСЬ» в городе (`UI/MarchReturnPopup.cs`): куда ходили, победа или поражение, сколько вернулось из скольких, добыча. + - Окно появляется и за походы, закончившиеся, пока игра была закрыта: сразу после входа в город. + - Несколько возвращений — окна по очереди. + - На карте мира окно своё (`WorldMapController`). ## Сохранение @@ -57,6 +64,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 23.10.2026 | Claude | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе. В `MarchReturnedEvent` добавлены `Sent`, `TargetName`, `LootText`. | | 19.10.2026 | Claude | Маленькая карта мира (`Meta/World`: WorldSystem, WorldMapPanel, WorldData, WorldLine) удалена, окно `Panel_World` убрано из сцены города. Кнопка «МИР» открывает эту карту. Солдаты из походов старой карты возвращаются в город. | | 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений походы отменяются. | | 19.10.2026 | Claude | Модуль выделен. `MarchManager` больше не обращается к `GameProgress` и `ResourceManager`: войска через `IArmy`, добыча через `IEconomy`, сохранение — своя секция `"world"`, статистика — через события. Формула боя вынесена в модуль Боя. |