diff --git a/Assets/Resources/EconomyConfig.asset b/Assets/Resources/EconomyConfig.asset index fd480b4..21f0383 100644 --- a/Assets/Resources/EconomyConfig.asset +++ b/Assets/Resources/EconomyConfig.asset @@ -19,8 +19,9 @@ MonoBehaviour: foodPerCivilianPerHour: 0.1 foodPerSoldierPerHour: 0.2 growthPerHour: 2.5 - housingBase: 400 - housingPerTownHallLevel: 100 + housingBase: 300 + housingPerTownHallLevel: 50 + housingPerHouseLevel: 50 maxOfflineHours: 48 startResources: wood: 500 @@ -29,6 +30,16 @@ MonoBehaviour: iron: 100 gold: 100 gems: 0 + storage: + baseGoods: 1000 + baseFood: 1500 + perWarehouseLevel: 1000 + perGranaryLevel: 1500 + crystalCap: 50 + warehouseWood: 200 + warehouseStone: 100 + granaryWood: 150 + granaryStone: 50 production: woodcutterPerHour: 2 farmerPerHour: 2 @@ -36,6 +47,13 @@ MonoBehaviour: minerIronPerHour: 1 minerGoldPerHour: 0.4 workerSlotsT1: 30 + workerSlotsPerLevel: 10 + efficiencyPerLevel: 0.1 + upgrade: + baseWood: 150 + baseStone: 100 + multiplierPerLevel: 1.6 + maxLevel: 10 deposits: poor: 0.6 normal: 1 diff --git a/Assets/Scripts/App/BuildingWindow.cs b/Assets/Scripts/App/BuildingWindow.cs new file mode 100644 index 0000000..5309966 --- /dev/null +++ b/Assets/Scripts/App/BuildingWindow.cs @@ -0,0 +1,158 @@ +using TMPro; +using UnityEngine; + +// Окно здания и рабочих (этап 3). Открывается тапом по зданию или кнопкой "Рабочие" в панели DEV. +// - сверху: что это за здание, его уровень и кнопка "Улучшить" (больше мест и эффективность / больше лимит / больше жилья); +// - ниже: все профессии с "−" и "+", сколько рабочих стоит, сколько мест и сколько добывается в час; +// - внизу: "Свободно: N из M". +// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль добычи. +public static class BuildingWindow +{ + static GameObject root; + static GameObject focus; // здание, по которому тапнули (null -- просто список рабочих) + + public static void OnWorkplaceTapped(ResourceCollector building) { Open(building != null ? building.gameObject : null); } + public static void OnStorageTapped(StorageBuilding building) { Open(building != null ? building.gameObject : null); } + + public static void Open(GameObject building) + { + // В режиме стройки тап -- это выбор участка, а не здания + if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return; + focus = building; + Rebuild(); + } + + public static void Close() + { + if (root != null) Object.Destroy(root); + root = null; + focus = null; + } + + static void Rebuild() + { + if (root != null) Object.Destroy(root); + var card = UIKit.Modal(TitleFor(focus), out root); + + if (focus != null) BuildingSection(card, focus); + WorkersSection(card); + + UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28); + } + + static string TitleFor(GameObject building) + { + if (building == null) return "РАБОЧИЕ"; + var id = building.GetComponent(); + var leveled = building.GetComponent(); + string name = id != null && !string.IsNullOrEmpty(id.buildingName) ? id.buildingName : building.name; + return leveled != null ? $"{name.ToUpper()} — {leveled.Level} УР." : name.ToUpper(); + } + + // ---------------- Здание: что даёт и улучшение ---------------- + + static void BuildingSection(RectTransform card, GameObject building) + { + var config = EconomyConfig.Load(); + var rc = building.GetComponent(); + var storage = building.GetComponent(); + var leveled = building.GetComponent(); + string info; + + if (rc != null && WorkJobs.TryFromResource(rc.resourceType, out WorkJob job) && GameModules.Production != null) + { + var p = GameModules.Production; + info = $"Здание даёт {p.BuildingSlots(rc.level)} мест, норма ×{p.BuildingEfficiency(rc.level):0.##}.\n" + + $"Всего мест «{WorkJobs.Name(job)}» в городе: {p.Slots(job)}."; + } + else if (rc != null && rc.IsHouse) + { + int perLevel = config.population.housingPerHouseLevel; + string cap = GameServices.TryGet(out IPopulation pop) ? $"\nНаселение: {pop.Total} / {pop.HousingCap}" : ""; + info = $"Дом: +{perLevel * rc.level} к пределу жилья (+{perLevel} за уровень).{cap}"; + } + else if (rc != null && rc.resourceType == ResourceType.Gems) + { + info = "Синий кристалл добывается в кристальных разломах на карте мира."; + } + else if (storage != null) + { + var s = config.storage; + info = storage.kind == StorageBuilding.Kind.Warehouse + ? $"Склад: +{s.perWarehouseLevel * storage.level:0} к лимиту дерева, камня, металла и золота." + : $"Амбар: +{s.perGranaryLevel * storage.level:0} к лимиту еды."; + } + else info = ""; + + if (info.Length > 0) UIKit.Text(card, info, 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + + if (leveled == null || (rc != null && rc.resourceType == ResourceType.Gems)) return; + if (leveled.Level >= config.upgrade.maxLevel) + { + UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center); + return; + } + + var cost = config.UpgradeCost(leveled.Level); + bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost); + var b = UIKit.Button(card, $"Улучшить до {leveled.Level + 1} ур. ({CostText(cost)})", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () => + { + if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; } + leveled.Level = leveled.Level + 1; + if (CitySync.Instance != null) CitySync.Instance.SyncNow(); + UIKit.Toast($"Улучшено до {leveled.Level} уровня"); + Rebuild(); + }, -1, 80, 26); + b.interactable = canPay; + } + + static string CostText(ResourceAmount[] cost) + { + var parts = new string[cost.Length]; + for (int i = 0; i < cost.Length; i++) parts[i] = $"{cost[i].Amount:0} {GameData.ResName(cost[i].Type).ToLower()}"; + return string.Join(", ", parts); + } + + // ---------------- Рабочие по профессиям ---------------- + + static void WorkersSection(RectTransform card) + { + if (!GameServices.TryGet(out IPopulation pop) || !GameServices.TryGet(out IProduction prod)) + { + UIKit.Text(card, "Модули Населения и добычи не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center); + return; + } + + for (int j = 0; j < WorkJobs.Count; j++) + { + var job = (WorkJob)j; + int slots = prod.Slots(job); + int workers = pop.GetWorkers(job); + var row = UIKit.Row(card, 72, 8); + + string rate = slots > 0 ? $" +{prod.RatePerHour(WorkJobs.Resource(job)):0.#}/ч" : ""; + var label = UIKit.Text(row, slots > 0 ? $"{WorkJobs.Name(job)}: {workers} / {slots}{rate}" : $"{WorkJobs.Name(job)}: нет зданий", + 24, slots > 0 ? UIKit.Theme.Text : UIKit.Theme.TextDim); + label.enableAutoSizing = true; label.fontSizeMin = 14; label.fontSizeMax = 24; + UIKit.Size(label, -1, 72, 1); + + StepButton(row, "−5", job, -5, slots > 0 && workers > 0); + StepButton(row, "−1", job, -1, slots > 0 && workers > 0); + StepButton(row, "+1", job, +1, slots > 0 && workers < slots && pop.FreeWorkers > 0); + StepButton(row, "+5", job, +5, slots > 0 && workers < slots && pop.FreeWorkers > 0); + } + + UIKit.Text(card, $"Свободно: {pop.FreeWorkers} из {pop.Workforce} рабочих • остальные жители: {Mathf.Max(0, pop.Civilians - pop.Workforce)}", + 24, UIKit.Theme.Gold, TextAlignmentOptions.Center); + } + + static void StepButton(Transform row, string label, WorkJob job, int delta, bool enabled) + { + var b = UIKit.Button(row, label, delta > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => + { + if (GameServices.TryGet(out IPopulation pop)) pop.SetWorkers(job, pop.GetWorkers(job) + delta); + Rebuild(); + }, 86, 64, 24); + b.interactable = enabled; + } +} diff --git a/Assets/Scripts/App/BuildingWindow.cs.meta b/Assets/Scripts/App/BuildingWindow.cs.meta new file mode 100644 index 0000000..bc2c4f0 --- /dev/null +++ b/Assets/Scripts/App/BuildingWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 50195a725abc4fe9b74871cd8cc4b273 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/App/CitySync.cs b/Assets/Scripts/App/CitySync.cs new file mode 100644 index 0000000..ab7314c --- /dev/null +++ b/Assets/Scripts/App/CitySync.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using System.Text; +using UnityEngine; + +// Слой приложения: сообщает модулям, какие здания стоят в городе. +// - рабочие места (Лес, Еда, Камень, Металл, Золото) и их уровни -> модуль добычи (места и эффективность); +// - Дома (здание "Население") -> модуль Населения (предел жилья); +// - Склады и Амбары -> лимиты склада в Экономике. +// Модули сами здания не ищут: они без Unity и живут и на карте мира, где города нет. +// +// Проверяет город раз в полсекунды и применяет изменения, только когда картина устоялась +// (два одинаковых замера подряд) -- чтобы не поймать город посреди восстановления из сохранения. +public class CitySync : MonoBehaviour +{ + const float Interval = 0.5f; + + public static CitySync Instance { get; private set; } + + float next; + string pending; // замер, который ждёт подтверждения + string applied; // последний применённый замер + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Create() + { + if (Instance != null) return; + var go = new GameObject("CitySync"); + DontDestroyOnLoad(go); + Instance = go.AddComponent(); + } + + void OnDestroy() + { + if (Instance == this) Instance = null; + } + + void Update() + { + if (Time.unscaledTime < next) return; + next = Time.unscaledTime + Interval; + if (BuildManager.Instance == null) return; // город не открыт -- модули помнят последнее + + var city = Scan(); + if (city.Signature == applied) { pending = null; return; } + if (city.Signature != pending) { pending = city.Signature; return; } // ждём подтверждения + Apply(city); + } + + // Применить сразу (после улучшения здания в окне -- чтобы окно показало новые места) + public void SyncNow() + { + if (BuildManager.Instance == null) return; + Apply(Scan()); + } + + class City + { + public readonly List> Workplaces = new List>(); + public int HouseLevels, WarehouseLevels, GranaryLevels; + public string Signature; + } + + static City Scan() + { + var city = new City(); + var sig = new List(); + foreach (var placed in FindObjectsByType(FindObjectsSortMode.None)) + { + if (string.IsNullOrEmpty(placed.buildingName)) continue; // убирается в этом кадре + + var rc = placed.GetComponent(); + if (rc != null) + { + if (WorkJobs.TryFromResource(rc.resourceType, out WorkJob job)) + { + city.Workplaces.Add(new KeyValuePair(job, rc.level)); + sig.Add("w" + (int)job + ":" + rc.level); + } + else if (rc.IsHouse) + { + city.HouseLevels += Mathf.Max(1, rc.level); + sig.Add("h:" + rc.level); + } + } + + var sb = placed.GetComponent(); + if (sb != null) + { + if (sb.kind == StorageBuilding.Kind.Warehouse) city.WarehouseLevels += Mathf.Max(1, sb.level); + else city.GranaryLevels += Mathf.Max(1, sb.level); + sig.Add("s" + (int)sb.kind + ":" + sb.level); + } + } + sig.Sort(System.StringComparer.Ordinal); + var text = new StringBuilder(); + foreach (var s in sig) text.Append(s).Append(';'); + city.Signature = text.ToString(); + return city; + } + + void Apply(City city) + { + applied = city.Signature; + pending = null; + + var config = EconomyConfig.Load(); + + // Лимиты склада + var rm = ResourceManager.Instance; + if (rm != null) + { + ResourceType[] stored = { ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Food, ResourceType.Gems }; + foreach (var t in stored) rm.SetCapacity(t, config.GetCapacity(t, city.WarehouseLevels, city.GranaryLevels)); + } + + var production = GameModules.Production; + var population = GameModules.Population; + if (production == null || population == null) return; + + // Места для рабочих: где мест стало больше (построили, улучшили) -- ставим туда свободных + var before = new int[WorkJobs.Count]; + for (int j = 0; j < WorkJobs.Count; j++) before[j] = production.Slots((WorkJob)j); + production.SetBuildings(city.Workplaces); + population.SetHouseLevels(city.HouseLevels); + population.ClampJobs(); + + if (!population.JobsInitialized) + { + population.AutoFillAll(); // первый запуск после этапа 3 или новая игра + } + else + { + for (int j = 0; j < WorkJobs.Count; j++) + if (production.Slots((WorkJob)j) > before[j]) population.AutoFill((WorkJob)j); + } + } +} diff --git a/Assets/Scripts/App/CitySync.cs.meta b/Assets/Scripts/App/CitySync.cs.meta new file mode 100644 index 0000000..f479a92 --- /dev/null +++ b/Assets/Scripts/App/CitySync.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fbcccbb94e6f46c89d4c49bf589b9f19 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/App/DebugPanel.cs b/Assets/Scripts/App/DebugPanel.cs index d0a80dd..655089f 100644 --- a/Assets/Scripts/App/DebugPanel.cs +++ b/Assets/Scripts/App/DebugPanel.cs @@ -9,8 +9,8 @@ using UnityEngine.UI; // "+1 час" не имеет своей логики: он двигает GameClock, и модули досчитывают время по своим обычным правилам, // ровно как при возвращении игрока в игру. Поэтому проверяешь настоящие правила, а не кнопку. // -// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население). Походы и обучение -// войск пока идут по реальным часам. +// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население и походы). Обучение +// войск пока идёт по реальным часам. public class DebugPanel : MonoBehaviour { static DebugPanel instance; @@ -38,15 +38,23 @@ public class DebugPanel : MonoBehaviour void Build() { + // Панель -- внутри безопасной зоны, чтобы не уходила под вырез камеры + var safe = new GameObject("SafeArea", typeof(RectTransform)).GetComponent(); + safe.SetParent(transform, false); + safe.anchorMin = Vector2.zero; + safe.anchorMax = Vector2.one; + safe.offsetMin = safe.offsetMax = Vector2.zero; + safe.gameObject.AddComponent(); + // Кнопка-переключатель слева посередине: там нет кнопок HUD - var toggle = UIKit.Button(transform, "DEV", new Color(0.55f, 0.15f, 0.55f, 0.85f), Toggle, 110, 64, 26); + var toggle = UIKit.Button(transform, "DEV", new Color(0.8f, 0.2f, 0.8f, 1f), Toggle, 110, 64, 28); var trt = (RectTransform)toggle.transform; trt.anchorMin = trt.anchorMax = new Vector2(0, 0.5f); trt.pivot = new Vector2(0, 0.5f); trt.anchoredPosition = new Vector2(12, 0); trt.sizeDelta = new Vector2(110, 64); - var card = UIKit.Card(transform, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10); + var card = UIKit.Card(safe, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10); card.anchorMin = card.anchorMax = new Vector2(0, 0.5f); card.pivot = new Vector2(0, 0.5f); card.anchoredPosition = new Vector2(135, 0); @@ -71,6 +79,10 @@ public class DebugPanel : MonoBehaviour Btn(row3, "Еда = 0", UIKit.Theme.Red, ZeroFood); Btn(row3, "Сбросить перемотку", UIKit.Theme.Gray, () => { GameClock.ResetOffset(); Refresh(); }); + var row4 = UIKit.Row(card, 70, 10); + Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null)); + Btn(row4, "Закрыть", UIKit.Theme.Gray, () => panel.SetActive(false)); + panel.SetActive(false); } @@ -101,14 +113,19 @@ public class DebugPanel : MonoBehaviour if (GameServices.TryGet(out IPopulation p)) { sb.Append($"Население: {p.Total} / {p.HousingCap}\n"); - sb.Append($" жители {p.Civilians} (рабочие {p.Workers}, остальные {p.Idle}), воины {p.Soldiers}\n"); + sb.Append($" жители {p.Civilians}: на работе {p.Workers}, свободно {p.FreeWorkers} из {p.Workforce}; воины {p.Soldiers}\n"); sb.Append($"Еда в час: -{p.FoodPerHour:0.#} прирост в час: +{p.GrowthPerHour:0.#}\n"); if (p.IsStarving) sb.Append("ГОЛОД: еды не хватает, прирост остановлен\n"); } else sb.Append("Модуль Населения не зарегистрирован\n"); + if (GameServices.TryGet(out IProduction prod)) + sb.Append($"Добыча в час: еда +{prod.RatePerHour(ResourceType.Food):0.#}, дерево +{prod.RatePerHour(ResourceType.Wood):0.#}, " + + $"камень +{prod.RatePerHour(ResourceType.Stone):0.#}, металл +{prod.RatePerHour(ResourceType.Iron):0.#}, золото +{prod.RatePerHour(ResourceType.Gold):0.#}\n"); + if (GameServices.TryGet(out IEconomy e)) - sb.Append($"Еда: {e.Get(ResourceType.Food):0.#} дерево {e.Get(ResourceType.Wood):0} камень {e.Get(ResourceType.Stone):0}\n"); + sb.Append($"Склад: еда {e.Get(ResourceType.Food):0}/{Cap(e, ResourceType.Food)} дерево {e.Get(ResourceType.Wood):0}/{Cap(e, ResourceType.Wood)} " + + $"камень {e.Get(ResourceType.Stone):0}/{Cap(e, ResourceType.Stone)}\n"); else sb.Append("Экономика не найдена (нет ResourceManager на сцене)\n"); long off = GameClock.OffsetSeconds; @@ -116,6 +133,12 @@ public class DebugPanel : MonoBehaviour info.text = sb.ToString(); } + static string Cap(IEconomy e, ResourceType t) + { + float c = e.GetCapacity(t); + return float.IsInfinity(c) ? "∞" : c.ToString("0"); + } + void AddAllResources() { ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems }; diff --git a/Assets/Scripts/App/EconomyConfig.cs b/Assets/Scripts/App/EconomyConfig.cs index cd1408a..d4338e2 100644 --- a/Assets/Scripts/App/EconomyConfig.cs +++ b/Assets/Scripts/App/EconomyConfig.cs @@ -30,17 +30,19 @@ public class EconomyConfig : ScriptableObject [Tooltip("Прирост людей в час, если есть еда (документ: 2–3)")] public float growthPerHour = 2.5f; - [Tooltip("Предел населения на 1 уровне Ратуши. Не утверждено")] - public int housingBase = 400; + [Tooltip("Предел населения без Домов на 1 уровне Ратуши (решение команды от 19.10: 300 при 300 -- прирост стоит)")] + public int housingBase = 300; [Tooltip("Прибавка к пределу за каждый следующий уровень Ратуши. Не утверждено")] - public int housingPerTownHallLevel = 100; + public int housingPerTownHallLevel = 50; + [Tooltip("Прибавка к пределу за каждый уровень Дома (здание \"Население\"). Не утверждено")] + public int housingPerHouseLevel = 50; [Tooltip("Офлайн-прогресс считается не больше чем за столько часов. Не утверждено")] public int maxOfflineHours = 48; } [Serializable] - public class ProductionSettings // этап 3: производство через рабочих (пока не используется) + public class ProductionSettings // этап 3: производство через рабочих { [Tooltip("Лесоруб T1: дерева в час")] public float woodcutterPerHour = 2f; @@ -54,6 +56,43 @@ public class EconomyConfig : ScriptableObject public float minerGoldPerHour = 0.4f; [Tooltip("Мест для рабочих в здании T1 (Лесопилка и т.п.)")] public int workerSlotsT1 = 30; + [Tooltip("+ мест за каждый уровень здания выше первого. Не утверждено")] + public int workerSlotsPerLevel = 10; + [Tooltip("+ к норме за каждый уровень здания выше первого (0.1 = +10%). Не утверждено")] + public float efficiencyPerLevel = 0.1f; + } + + [Serializable] + public class StorageSettings // этап 1: лимиты хранения + { + [Tooltip("Лимит без Складов: дерево, камень, металл, золото. Не утверждено")] + public float baseGoods = 1000f; + [Tooltip("Лимит еды без Амбаров. Не утверждено")] + public float baseFood = 1500f; + [Tooltip("+ к лимиту дерева, камня, металла, золота за каждый уровень Склада. Не утверждено")] + public float perWarehouseLevel = 1000f; + [Tooltip("+ к лимиту еды за каждый уровень Амбара. Не утверждено")] + public float perGranaryLevel = 1500f; + [Tooltip("Лимит синего кристалла (решение команды от 19.10: небольшой, не бесконечный). Число не утверждено")] + public float crystalCap = 50f; + + [Tooltip("Цена Склада: дерево / камень. Не утверждено")] + public float warehouseWood = 200f; + public float warehouseStone = 100f; + [Tooltip("Цена Амбара: дерево / камень. Не утверждено")] + public float granaryWood = 150f; + public float granaryStone = 50f; + } + + [Serializable] + public class UpgradeSettings // улучшение зданий (рабочие места, Дома, Склады, Амбары) + { + [Tooltip("Цена улучшения с 1 на 2 уровень: дерево / камень. Не утверждено")] + public float baseWood = 150f; + public float baseStone = 100f; + [Tooltip("Во сколько раз дорожает каждый следующий уровень. Не утверждено")] + public float multiplierPerLevel = 1.6f; + public int maxLevel = 10; } [Serializable] @@ -93,9 +132,15 @@ public class EconomyConfig : ScriptableObject [Header("Стартовые ресурсы новой игры")] public StartResources startResources = new StartResources(); + [Header("Склады и лимиты (этап 1)")] + public StorageSettings storage = new StorageSettings(); + [Header("Производство T1 (этап 3)")] public ProductionSettings production = new ProductionSettings(); + [Header("Улучшение зданий")] + public UpgradeSettings upgrade = new UpgradeSettings(); + [Header("Месторождения (этап 4)")] public DepositSettings deposits = new DepositSettings(); @@ -119,6 +164,33 @@ public class EconomyConfig : ScriptableObject return cached; } + // Лимит склада для ресурса по уровням Складов и Амбаров. PositiveInfinity -- без лимита (люди, сила) + public float GetCapacity(ResourceType type, int warehouseLevels, int granaryLevels) + { + var s = storage; + switch (type) + { + case ResourceType.Wood: + case ResourceType.Stone: + case ResourceType.Iron: + case ResourceType.Gold: return s.baseGoods + s.perWarehouseLevel * warehouseLevels; + case ResourceType.Food: return s.baseFood + s.perGranaryLevel * granaryLevels; + case ResourceType.Gems: return s.crystalCap; + default: return float.PositiveInfinity; + } + } + + // Цена улучшения здания с уровня level на level + 1 + public ResourceAmount[] UpgradeCost(int level) + { + float m = Mathf.Pow(upgrade.multiplierPerLevel, Mathf.Max(0, level - 1)); + return new[] + { + new ResourceAmount(ResourceType.Wood, Mathf.Round(upgrade.baseWood * m)), + new ResourceAmount(ResourceType.Stone, Mathf.Round(upgrade.baseStone * m)), + }; + } + public float GetStartResource(ResourceType type) { var s = startResources; diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index 3d53f41..f424f28 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -8,12 +8,19 @@ using UnityEngine; // Модули друг о друге ничего не знают -- всю "проводку" видно в одном файле. public static class GameModules { + // Модули экономики для слоя приложения (окна, синхронизация города). Модули друг друга так не берут -- только через контракты. + public static PopulationService Population { get; private set; } + public static ProductionService Production { get; private set; } + // После SubsystemRegistration (там ядро очищается) и раньше любых Awake [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void Register() { // Боевая система -- чистый C#, сама зарегистрироваться не может - GameServices.Register(new BattleSystem()); + // Сила воина каждого типа -- из GameData.Troops, чтобы числа были в одном месте + var unitPower = new int[TroopType.Count]; + for (int i = 0; i < TroopType.Count && i < GameData.Troops.Count; i++) unitPower[i] = GameData.Troops[i].power; + GameServices.Register(new BattleSystem(unitPower)); // Армия -- чистый C#, единственное место хранения войск (секция "army") var army = new ArmyService(); @@ -22,11 +29,27 @@ public static class GameModules EventBus.Subscribe(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); }); EventBus.Subscribe(_ => MigrateLegacyArmy(army)); - // Население -- чистый C#: жители, еда, прирост (секция "population"). Числа -- из EconomyConfig. + // Население -- чистый C#: жители, рабочие, еда, прирост (секция "population"). Числа -- из EconomyConfig. var config = EconomyConfig.Load(); var population = new PopulationService(ToPopulationRules(config)); + Population = population; GameServices.Register(population); SaveRegistry.Register(population); + + // Добыча через рабочих -- чистый C# (секция "production"). Какие здания стоят, сообщает App/CitySync. + var production = new ProductionService(ToProductionRules(config)); + Production = production; + GameServices.Register(production); + SaveRegistry.Register(production); + + // Склад и Амбар появляются в списке стройки кодом -- до восстановления зданий из сохранения + EventBus.Subscribe(_ => StorageBootstrap.Ensure()); + + // Тап по зданию -> окно здания и рабочих (статические события переживают Play в редакторе -- сначала отписка) + ResourceCollector.Tapped -= BuildingWindow.OnWorkplaceTapped; + ResourceCollector.Tapped += BuildingWindow.OnWorkplaceTapped; + StorageBuilding.Tapped -= BuildingWindow.OnStorageTapped; + StorageBuilding.Tapped += BuildingWindow.OnStorageTapped; EventBus.Subscribe(_ => population.OnGameLoaded(GameClock.UnixNow)); // офлайн-прогресс EventBus.Subscribe(e => population.Tick(e.UnixNow)); // перемотка (отладка) EventBus.Subscribe(_ => population.Changed()); // воины -- тоже люди @@ -125,10 +148,24 @@ public static class GameModules GrowthPerHour = p.growthPerHour, HousingBase = p.housingBase, HousingPerTownHallLevel = p.housingPerTownHallLevel, + HousingPerHouseLevel = p.housingPerHouseLevel, MaxOfflineHours = p.maxOfflineHours }; } + static ProductionRules ToProductionRules(EconomyConfig config) + { + var p = config.production; + return new ProductionRules + { + // порядок как в WorkJob: лесоруб, фермер, каменотёс, шахтёр-металл, шахтёр-золото + PerWorkerPerHour = new double[] { p.woodcutterPerHour, p.farmerPerHour, p.stonecutterPerHour, p.minerIronPerHour, p.minerGoldPerHour }, + SlotsPerBuilding = p.workerSlotsT1, + SlotsPerLevel = p.workerSlotsPerLevel, + EfficiencyPerLevel = p.efficiencyPerLevel + }; + } + // Новая игра: стартовое войско (пехота) и стартовые ресурсы из EconomyConfig. Один раз на новую игру. static void GiveStartingKit(PopulationService population, ArmyService army, EconomyConfig config) { diff --git a/Assets/Scripts/App/StorageBootstrap.cs b/Assets/Scripts/App/StorageBootstrap.cs new file mode 100644 index 0000000..84217f8 --- /dev/null +++ b/Assets/Scripts/App/StorageBootstrap.cs @@ -0,0 +1,85 @@ +using UnityEngine; + +// Добавляет "Склад" и "Амбар" в список стройки кодом -- как Казармы (Meta/Build/BarracksBootstrap). +// Шаблон -- копия обычного здания без добычи; пока нет арта, отличаем цветом. +// Вызывается при загрузке сцены и перед восстановлением города из сохранения (SettlementRestoringEvent), +// иначе построенные Склады не нашлись бы при загрузке. +public static class StorageBootstrap +{ + public const string WarehouseName = "Склад"; + public const string GranaryName = "Амбар"; + + static GameObject warehouseTemplate; + static GameObject granaryTemplate; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Init() + { + Ensure(); + UnityEngine.SceneManagement.SceneManager.sceneLoaded += (s, m) => Ensure(); + } + + public static void Ensure() + { + var bm = BuildManager.Instance != null ? BuildManager.Instance : Object.FindAnyObjectByType(); + if (bm == null || bm.allBuildings == null) return; + + bool hasWarehouse = false, hasGranary = false; + BuildingData template = null; + foreach (var b in bm.allBuildings) + { + if (b == null) continue; + if (b.buildingName == WarehouseName) hasWarehouse = true; + if (b.buildingName == GranaryName) hasGranary = true; + if (template == null && b.prefab != null && b.prefab.GetComponent() != null) template = b; + } + if (hasWarehouse && hasGranary) return; + if (template == null) { Debug.LogWarning("[СКЛАДЫ] Нет здания-шаблона в BuildManager"); return; } + + var cfg = EconomyConfig.Load().storage; + if (!hasWarehouse) + { + if (warehouseTemplate == null) warehouseTemplate = MakeTemplate(template.prefab, "Warehouse", StorageBuilding.Kind.Warehouse, new Color(0.85f, 0.7f, 0.5f)); + bm.allBuildings.Add(MakeData(WarehouseName, template, warehouseTemplate, cfg.warehouseWood, cfg.warehouseStone)); + } + if (!hasGranary) + { + if (granaryTemplate == null) granaryTemplate = MakeTemplate(template.prefab, "Granary", StorageBuilding.Kind.Granary, new Color(1f, 0.92f, 0.55f)); + bm.allBuildings.Add(MakeData(GranaryName, template, granaryTemplate, cfg.granaryWood, cfg.granaryStone)); + } + Debug.Log("[СКЛАДЫ] Склад и Амбар добавлены в список стройки"); + } + + static GameObject MakeTemplate(GameObject source, string name, StorageBuilding.Kind kind, Color tint) + { + var t = Object.Instantiate(source); + t.name = name; + t.SetActive(false); + Object.DontDestroyOnLoad(t); + foreach (var c in t.GetComponents()) Object.DestroyImmediate(c); + foreach (var c in t.GetComponents()) Object.DestroyImmediate(c); + foreach (var c in t.GetComponents()) Object.DestroyImmediate(c); + for (int i = t.transform.childCount - 1; i >= 0; i--) + Object.DestroyImmediate(t.transform.GetChild(i).gameObject); // полоска над зданием не нужна + var sr = t.GetComponent(); + if (sr != null) sr.color = tint; + t.AddComponent().kind = kind; + return t; + } + + static BuildingData MakeData(string title, BuildingData template, GameObject prefab, float wood, float stone) + { + var data = ScriptableObject.CreateInstance(); + data.name = "Data_" + prefab.name + "_Runtime"; + data.buildingName = title; + data.icon = template.icon; + data.prefab = prefab; + data.requiredTownHallLevel = 1; + data.cost = new[] + { + new ResourceCost { type = ResourceType.Wood, amount = wood }, + new ResourceCost { type = ResourceType.Stone, amount = stone }, + }; + return data; + } +} diff --git a/Assets/Scripts/App/StorageBootstrap.cs.meta b/Assets/Scripts/App/StorageBootstrap.cs.meta new file mode 100644 index 0000000..c5b02cb --- /dev/null +++ b/Assets/Scripts/App/StorageBootstrap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 87b085cfbef5449b97456bea718c7466 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/App/StorageBuilding.cs b/Assets/Scripts/App/StorageBuilding.cs new file mode 100644 index 0000000..e2189bf --- /dev/null +++ b/Assets/Scripts/App/StorageBuilding.cs @@ -0,0 +1,35 @@ +using System; +using UnityEngine; + +// Склад или Амбар (этап 1): каждый уровень поднимает лимит хранения. +// Склад -- дерево, камень, металл, золото; Амбар -- еда. Числа -- EconomyConfig, раздел "Склады и лимиты". +// Здание появляется в списке стройки кодом (App/StorageBootstrap), отдельных префабов пока нет. +// Уровень сохраняет модуль Поселения (ILeveledBuilding), лимиты считает App/CitySync. +public class StorageBuilding : MonoBehaviour, ILeveledBuilding +{ + public enum Kind { Warehouse, Granary } + + public static event Action Tapped; + + public Kind kind; + public int level = 1; + + public int Level { get => level; set => level = Mathf.Max(1, value); } + + public string Title => kind == Kind.Warehouse ? StorageBootstrap.WarehouseName : StorageBootstrap.GranaryName; + + Vector3 downPos; + + void OnMouseDown() + { + downPos = Input.mousePosition; + } + + // Тап, а не перетаскивание камеры + void OnMouseUpAsButton() + { + if (UIBlocker.IsPointerOverUI()) return; + if ((Input.mousePosition - downPos).magnitude > 25f) return; + Tapped?.Invoke(this); + } +} diff --git a/Assets/Scripts/App/StorageBuilding.cs.meta b/Assets/Scripts/App/StorageBuilding.cs.meta new file mode 100644 index 0000000..84a6a11 --- /dev/null +++ b/Assets/Scripts/App/StorageBuilding.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bea97fb12da84c7daeb07f65d4947cae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Economy/BuildingUI.cs b/Assets/Scripts/Economy/BuildingUI.cs index 3cb1fa5..6fdf34d 100644 --- a/Assets/Scripts/Economy/BuildingUI.cs +++ b/Assets/Scripts/Economy/BuildingUI.cs @@ -2,6 +2,9 @@ using UnityEngine; using UnityEngine.UI; using TMPro; +// Полоска над зданием (этап 3): рабочие этой профессии / места в городе. +// Раньше показывала накопленный запас кликера -- кликер убран, добычу дают рабочие. +// У Дома и Кристалла рабочих нет -- там подпись уровня, полоска скрыта. public class BuildingUI : MonoBehaviour { [Header("Настройки")] @@ -9,6 +12,8 @@ public class BuildingUI : MonoBehaviour public TMP_Text stokText; public Slider progressBar; + float next; + void Start() { if (building == null) @@ -22,19 +27,25 @@ public class BuildingUI : MonoBehaviour void Update() { + if (Time.unscaledTime < next) return; + next = Time.unscaledTime + 0.5f; UpdateUI(); } void UpdateUI() { - if (stokText != null) + bool workplace = building.IsWorkplace; + if (progressBar != null && progressBar.gameObject.activeSelf != workplace) progressBar.gameObject.SetActive(workplace); + + if (!workplace) { - stokText.text = $"{building.GetCurrentStock():F1} / {building.GetMaxCapacity()}"; - } - if (progressBar != null) - { - float fill = building.GetCurrentStock() / building.GetMaxCapacity(); - progressBar.value = Mathf.Clamp01(fill); + if (stokText != null) stokText.text = building.IsHouse ? $"Дом {building.level} ур." : $"{building.level} ур."; + return; } + + float workers = building.GetCurrentStock(); + float slots = building.GetMaxCapacity(); + if (stokText != null) stokText.text = $"Рабочие {workers:0} / {slots:0}"; + if (progressBar != null) progressBar.value = slots > 0 ? Mathf.Clamp01(workers / slots) : 0f; } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Economy/ResourceUIText.cs b/Assets/Scripts/Economy/ResourceUIText.cs index 21e10e7..193ba16 100644 --- a/Assets/Scripts/Economy/ResourceUIText.cs +++ b/Assets/Scripts/Economy/ResourceUIText.cs @@ -1,71 +1,92 @@ using UnityEngine; using TMPro; - +// Ресурс на верхней панели (этап 1): "запас / лимит" и прирост в час. +// - Дерево, камень, металл, золото, еда, кристаллы: "820 / 1000" и строкой ниже "+50/ч" (у еды -- добыча минус расход); +// - Люди: "300 / 350" (население / предел жилья) и прирост людей в час. +// Числа без сокращений 1.2K / 1.5M: по документу нормальный масштаб -- сотни и тысячи. [RequireComponent(typeof(TMP_Text))] public class ResourceUIText : MonoBehaviour { public ResourceType resourceType; - [Tooltip("Сокращать большие числа (1200 -> 1.2K, 1500000 -> 1.5M), как на референсе HUD")] - public bool useShortFormat = true; + [Tooltip("Показывать лимит склада: \"820 / 1000\"")] + public bool showCapacity = true; + + [Tooltip("Показывать прирост в час второй строкой: \"+50/ч\"")] + public bool showRate = true; + + [Tooltip("Устарело (этап 1): сокращения 1.2K / 1.5M убраны")] + [HideInInspector] public bool useShortFormat; private TMP_Text label; + private float next; + private string lastText; void Awake() { label = GetComponent(); + + // В сцене на ячейках дерева, камня, металла, золота и кристалла по ошибке висит второй такой же компонент + // с типом "Люди" -- он перебивал число ресурса. Если рядом есть компонент с настоящим ресурсом -- выключаемся. + if (resourceType == ResourceType.Population) + { + foreach (var other in GetComponents()) + { + if (other != this && other.resourceType != ResourceType.Population) + { + enabled = false; + return; + } + } + } } void OnEnable() { - // Ждём, пока ResourceManager точно проинициализируется (Awake порядок не гарантирован) - if (ResourceManager.Instance != null) + next = 0f; + } + + void Update() + { + if (Time.unscaledTime < next) return; + next = Time.unscaledTime + 0.5f; + Refresh(); + } + + void Refresh() + { + string text = BuildText(); + if (text == lastText) return; + lastText = text; + label.text = text; + } + + string BuildText() + { + // Люди -- из модуля Населения + if (resourceType == ResourceType.Population && GameServices.TryGet(out IPopulation pop)) { - Subscribe(); - } - else - { - Invoke(nameof(TrySubscribeDelayed), 0.1f); + string value = showCapacity ? $"{pop.Total} / {pop.HousingCap}" : pop.Total.ToString(); + return showRate ? value + RateLine(pop.GrowthPerHour) : value; } + + if (!GameServices.TryGet(out IEconomy economy)) return "0"; + int amount = Mathf.FloorToInt(economy.Get(resourceType)); + float cap = economy.GetCapacity(resourceType); + string main = showCapacity && !float.IsInfinity(cap) ? $"{amount} / {Mathf.RoundToInt(cap)}" : amount.ToString(); + if (!showRate || resourceType == ResourceType.Power) return main; + + float rate = GameServices.TryGet(out IProduction production) ? production.RatePerHour(resourceType) : 0f; + if (resourceType == ResourceType.Food && GameServices.TryGet(out IPopulation p)) rate -= p.FoodPerHour; + if (resourceType == ResourceType.Gems) return main; // кристалл не добывается рабочими (разломы на карте мира) + return main + RateLine(rate); } - void TrySubscribeDelayed() + static string RateLine(float rate) { - if (ResourceManager.Instance != null) - Subscribe(); - } - - void Subscribe() - { - ResourceManager.Instance.OnResourceChanged += HandleResourceChanged; - // сразу показать текущее значение при старте, не дожидаясь первого изменения - UpdateText(ResourceManager.Instance.GetResource(resourceType)); - } - - void OnDisable() - { - if (ResourceManager.Instance != null) - ResourceManager.Instance.OnResourceChanged -= HandleResourceChanged; - } - - void HandleResourceChanged(ResourceType type, float newValue) - { - if (type != resourceType) return; // реагируем только на "свой" ресурс - UpdateText(newValue); - } - - void UpdateText(float value) - { - label.text = useShortFormat ? FormatShort(value) : Mathf.FloorToInt(value).ToString(); - } - - string FormatShort(float value) - { - if (value >= 1_000_000f) - return (value / 1_000_000f).ToString("0.#") + "M"; - if (value >= 1_000f) - return (value / 1_000f).ToString("0.#") + "K"; - return Mathf.FloorToInt(value).ToString(); + string color = rate < 0 ? "#ff8a80" : "#9be29b"; + string sign = rate > 0 ? "+" : ""; + return $"\n{sign}{rate:0.#}/ч"; } } diff --git a/Assets/Scripts/Meta/Panels/TroopsPanel.cs b/Assets/Scripts/Meta/Panels/TroopsPanel.cs index 86a152c..e2da516 100644 --- a/Assets/Scripts/Meta/Panels/TroopsPanel.cs +++ b/Assets/Scripts/Meta/Panels/TroopsPanel.cs @@ -28,6 +28,21 @@ public class TroopsPanel : BasePanel ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0")); UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26); + // --- Гарнизон: часть воинов охраняет город и в походы не уходит --- + IArmy troopsArmy = GameServices.Get(); + var garrison = UIKit.Card(list, UIKit.Theme.CardDark); + ProfilePanel.Header(garrison, "Гарнизон города"); + ProfilePanel.KV(garrison, "В гарнизоне", troopsArmy.GarrisonTotal.ToString("N0") + " из " + troopsArmy.SoldiersAtHome.ToString("N0")); + ProfilePanel.KV(garrison, "Свободны для походов", troopsArmy.AvailableForMarch.ToString("N0")); + UIKit.Text(garrison, "Гарнизон защищает город и не уходит в походы. Воины в нём не пропадают: всего воинов столько же.", 22, UIKit.Theme.TextDim); + var gRow = UIKit.Row(garrison, 80, 10); + GarrisonButton(gRow, troopsArmy, "0", 0); + GarrisonButton(gRow, troopsArmy, "-10", troopsArmy.GarrisonTotal - 10); + GarrisonButton(gRow, troopsArmy, "-1", troopsArmy.GarrisonTotal - 1); + GarrisonButton(gRow, troopsArmy, "+1", troopsArmy.GarrisonTotal + 1); + GarrisonButton(gRow, troopsArmy, "+10", troopsArmy.GarrisonTotal + 10); + GarrisonButton(gRow, troopsArmy, "Все", troopsArmy.SoldiersAtHome); + // --- Нет казарм -- нанимать негде --- if (Barracks.Count == 0) { @@ -97,6 +112,15 @@ public class TroopsPanel : BasePanel } } + // Окно перестраивается само: Армия публикует TroopsChangedEvent, GameProgress сообщает об изменении + static void GarrisonButton(Transform row, IArmy army, string label, int target) + { + target = Mathf.Clamp(target, 0, army.SoldiersAtHome); + bool can = target != army.GarrisonTotal; + var b = UIKit.Button(row, label, can ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { if (can) army.SetGarrison(target); }, -1, 80, 28); + UIKit.Size(b, -1, 80, 1); + } + protected override void Tick() { var d = GP.Data; diff --git a/Assets/Scripts/Meta/UI/BasePanel.cs b/Assets/Scripts/Meta/UI/BasePanel.cs index 5be6552..db37c33 100644 --- a/Assets/Scripts/Meta/UI/BasePanel.cs +++ b/Assets/Scripts/Meta/UI/BasePanel.cs @@ -64,11 +64,23 @@ public abstract class BasePanel : MonoBehaviour t = UIKit.Stretch(UIKit.Make("Content", transform), 24, 130, 24, 24); } content = (RectTransform)t; + WrapInSafeArea(content); } return content; } } + // Фон окна -- на весь экран, а содержимое -- в безопасной зоне: на iPhone левый край уходил под вырез камеры. + // Content переезжает в растянутый контейнер с SafeArea и сохраняет свои отступы. + void WrapInSafeArea(RectTransform rt) + { + if (rt.parent != transform) return; // уже обёрнут + var safe = UIKit.Stretch(UIKit.Make("SafeArea", transform)); + safe.SetSiblingIndex(rt.GetSiblingIndex()); + safe.gameObject.AddComponent().fitInsideParent = true; // окно -- не на весь экран + rt.SetParent(safe, false); + } + void Rebuild() { float pos = (scroll != null && !resetScroll) ? scroll.verticalNormalizedPosition : 1f; diff --git a/Assets/Scripts/Modules/Army/ArmyService.cs b/Assets/Scripts/Modules/Army/ArmyService.cs index 9a798ae..1b9ab42 100644 --- a/Assets/Scripts/Modules/Army/ArmyService.cs +++ b/Assets/Scripts/Modules/Army/ArmyService.cs @@ -8,6 +8,7 @@ using System.Text; // // Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches). // Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. +// Гарнизон (garrison) -- часть воинов города: они в troops, но в поход не уходят. garrison[i] <= troops[i] всегда. public sealed class ArmyService : IArmy, ISaveParticipant { public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops @@ -19,6 +20,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant } private readonly int[] troops = new int[Types]; + private readonly int[] garrison = new int[Types]; private readonly List marches = new List(); // Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно @@ -48,6 +50,28 @@ public sealed class ArmyService : IArmy, ISaveParticipant } } + public int GarrisonTotal + { + get { int n = 0; foreach (var g in garrison) n += g; return n; } + } + + public int GetGarrison(int type) + { + return type >= 0 && type < Types ? garrison[type] : 0; + } + + public int AvailableForMarch => SoldiersAtHome - GarrisonTotal; + + // Гарнизон набирается по составу армии в городе: 30 пехоты и 10 лучников, гарнизон 8 -> 6 пехоты и 2 лучника + public int SetGarrison(int count) + { + count = Math.Max(0, Math.Min(count, SoldiersAtHome)); + int[] units = count > 0 ? ArmyMath.TakeProportional(troops, count) : null; + for (int i = 0; i < Types; i++) garrison[i] = units != null ? units[i] : 0; + Changed(); + return count; + } + public void AddTroops(int type, int count) { if (type < 0 || type >= Types || count <= 0) return; @@ -67,41 +91,73 @@ public sealed class ArmyService : IArmy, ISaveParticipant troops[i] -= l; lost += l; } + ClampGarrison(); if (lost > 0) Changed(); return lost; } public bool TakeForMarch(int marchId, int count) { - int[] taken = ArmyMath.TakeProportional(troops, count); + // Поход с таким номером уже есть: перезапись потеряла бы его воинов + if (marches.Exists(m => m.marchId == marchId)) return false; + + // В поход -- только свободные воины, гарнизон остаётся в городе + var free = new int[Types]; + for (int i = 0; i < Types; i++) free[i] = troops[i] - garrison[i]; + int[] taken = ArmyMath.TakeProportional(free, 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) + // Воины возвращаются только из известного похода, и только один раз: запись похода удаляется. + // Неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого -- иначе солдаты + // появлялись бы из ниоткуда. Перенос старых сохранений -- отдельно, в ImportLegacy. + public bool 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); - } + if (entry == null) return false; + + int sent = 0; + foreach (var u in entry.units) sent += u; + int[] back = ArmyMath.SplitSurvivors(entry.units, Math.Min(survivors, sent)); // больше, чем ушло, не вернётся + for (int i = 0; i < Types; i++) troops[i] += back[i]; + marches.Remove(entry); Changed(); + return true; + } + + public int[] GetMarchUnits(int marchId) + { + MarchUnits entry = marches.Find(m => m.marchId == marchId); + return entry != null ? (int[])entry.units.Clone() : null; + } + + // Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может. + // Правила те же, что у ReturnFromMarch: только известный поход, только один раз. + public bool ReturnUnits(int marchId, int[] survivorUnits) + { + MarchUnits entry = marches.Find(m => m.marchId == marchId); + if (entry == null) return false; + + for (int i = 0; i < Types && survivorUnits != null && i < survivorUnits.Length; i++) + troops[i] += Math.Max(0, Math.Min(survivorUnits[i], entry.units[i])); + marches.Remove(entry); + Changed(); + return true; + } + + void ClampGarrison() + { + for (int i = 0; i < Types; i++) garrison[i] = Math.Max(0, Math.Min(garrison[i], troops[i])); } void Changed() { - EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches }); + EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal }); } // ---------------- Перенос из старого progress.json ---------------- @@ -112,12 +168,14 @@ public sealed class ArmyService : IArmy, ISaveParticipant { if (legacyTroops != null) for (int i = 0; i < Types && i < legacyTroops.Length; i++) troops[i] = Math.Max(0, legacyTroops[i]); + Array.Clear(garrison, 0, Types); marches.Clear(); if (legacyMarches != null) { foreach (var m in legacyMarches) { + if (marches.Exists(x => x.marchId == m.Key)) continue; // один поход -- одна запись 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 }); @@ -129,6 +187,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant // ---------------- Сохранение (секция "army") ---------------- // Модуль без Unity, поэтому формат -- простые строки: // troops=<пехота>,<лучники>,<кавалерия>,<осадные> + // garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет) // march=:<пехота>,<лучники>,<кавалерия>,<осадные> public string SaveKey => "army"; @@ -137,6 +196,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant { var sb = new StringBuilder(); sb.Append("troops=").Append(Join(troops)).Append('\n'); + sb.Append("garrison=").Append(Join(garrison)).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(); @@ -145,6 +205,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant public void Restore(string data) { Array.Clear(troops, 0, Types); + Array.Clear(garrison, 0, Types); marches.Clear(); RestoredFromSave = true; @@ -158,21 +219,29 @@ public sealed class ArmyService : IArmy, ISaveParticipant int[] t = Parse(line.Substring("troops=".Length)); Array.Copy(t, troops, Types); } + else if (line.StartsWith("garrison=")) + { + int[] g = Parse(line.Substring("garrison=".Length)); + Array.Copy(g, garrison, 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)) + if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id) + && !marches.Exists(x => x.marchId == id)) // один поход -- одна запись marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) }); } } } + ClampGarrison(); // гарнизон не больше войск, даже если файл правили руками Changed(); } - // Новая игра: войск нет, походов нет + // Новая игра: войск нет, гарнизона и походов нет public void ResetState() { Array.Clear(troops, 0, Types); + Array.Clear(garrison, 0, Types); marches.Clear(); Changed(); } diff --git a/Assets/Scripts/Modules/Battle/BattleSystem.cs b/Assets/Scripts/Modules/Battle/BattleSystem.cs index 180a967..0f6c727 100644 --- a/Assets/Scripts/Modules/Battle/BattleSystem.cs +++ b/Assets/Scripts/Modules/Battle/BattleSystem.cs @@ -1,26 +1,133 @@ +using System; + // МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md // Без Unity и без float: этот же код без изменений сможет считать бои на сервере. // -// Правила пока самые простые: побеждает тот, у кого больше солдат. -// Потом сюда добавятся типы войск, герой, технологии -- остальные модули менять не придётся. +// Правила (предложение, утверждает команда -- открытый вопрос этапа 6 плана): +// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) x (100% + бонусы стороны); +// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты (+25%); +// бонус типа против врага взвешивается по составу врага; +// - осадные: против стен +50%, в поле -25%; стены дают защитникам +20%; +// - побеждает тот, у кого сила больше; ничья -- защитнику; +// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой. public sealed class BattleSystem : IBattleSystem { + // Сила одного воина каждого типа. Берётся из GameData.Troops (App/GameModules), здесь -- запасные значения. + public static readonly int[] DefaultPower = { 4, 5, 9, 16 }; + + public const int CounterPercent = 25; + public const int SiegeVsWallsPercent = 50; + public const int SiegeInFieldPercent = -25; + public const int WallsPercent = 20; + public const int LoserLossPercent = 50; + + private readonly int[] power; + + public BattleSystem() : this(DefaultPower) { } + + public BattleSystem(int[] unitPower) + { + power = new int[TroopType.Count]; + for (int i = 0; i < TroopType.Count; i++) + power[i] = unitPower != null && i < unitPower.Length && unitPower[i] > 0 ? unitPower[i] : DefaultPower[i]; + } + + // Контра: насколько тип attackerType сильнее против defenderType, в процентах + public static int Counter(int attackerType, int defenderType) + { + if (attackerType == TroopType.Infantry && defenderType == TroopType.Cavalry) return CounterPercent; + if (attackerType == TroopType.Cavalry && defenderType == TroopType.Archers) return CounterPercent; + if (attackerType == TroopType.Archers && defenderType == TroopType.Infantry) return CounterPercent; + return 0; + } + public BattleOutcome Resolve(BattleRequest request) { - int attackers = request.Attackers; - int defenders = request.Defenders; - bool victory = attackers > defenders; // ничья -- в пользу защитника + int[] att = Units(request.AttackerUnits, request.Attackers); + int[] def = Units(request.DefenderUnits, request.Defenders); - // Проигравший теряет половину своего войска, победитель -- столько же - int loserArmy = victory ? defenders : attackers; - int losses = (loserArmy + 1) / 2; + long attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent); + long defPower = SidePower(def, att, attacking: false, request.DefenderFortified, + request.DefenderBonusPercent + (request.DefenderFortified ? WallsPercent : 0)); + + bool victory = attPower > defPower; // ничья -- в пользу защитника + + // Потери в процентах: проигравший -- половина; победитель -- половина x (сила проигравшего / сила победителя) + long stronger = Math.Max(attPower, defPower); + long weaker = Math.Min(attPower, defPower); + int[] attLost = Losses(att, weaker, stronger, isLoser: !victory); + int[] defLost = Losses(def, weaker, stronger, isLoser: victory); + + var survivors = new int[TroopType.Count]; + for (int i = 0; i < TroopType.Count; i++) survivors[i] = att[i] - attLost[i]; return new BattleOutcome { Victory = victory, - AttackerLosses = losses, - DefenderLosses = losses, - Survivors = attackers - losses + AttackerLosses = Sum(attLost), + DefenderLosses = Sum(defLost), + Survivors = Sum(survivors), + AttackerPower = attPower, + DefenderPower = defPower, + AttackerSurvivorUnits = survivors, + DefenderLossUnits = defLost }; } + + // Сила стороны в сотых долях (x100), чтобы проценты считались в целых числах + long SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent) + { + int enemyTotal = Sum(enemy); + long total = 0; + for (int i = 0; i < TroopType.Count; i++) + { + if (units[i] <= 0) continue; + + long counterSum = 0; + for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j]; + long counterPercent = enemyTotal > 0 ? counterSum / enemyTotal : 0; + + int siegePercent = 0; + if (i == TroopType.Siege) siegePercent = attacking && fortified ? SiegeVsWallsPercent : SiegeInFieldPercent; + + total += (long)units[i] * power[i] * (100 + counterPercent + siegePercent); + } + return Math.Max(0, total * (100 + bonusPercent) / 100); + } + + // Потери каждого типа: проигравший -- LoserLossPercent; победитель -- LoserLossPercent x ratioNum / ratioDen + static int[] Losses(int[] units, long ratioNum, long ratioDen, bool isLoser) + { + var lost = new int[TroopType.Count]; + for (int i = 0; i < TroopType.Count; i++) + { + if (units[i] <= 0) continue; + long l = isLoser + ? (long)units[i] * LoserLossPercent / 100 + : ratioDen > 0 ? (long)units[i] * LoserLossPercent * ratioNum / (100 * ratioDen) : 0; + lost[i] = (int)Math.Min(units[i], l); + } + return lost; + } + + static int[] Units(int[] units, int count) + { + var result = new int[TroopType.Count]; + if (units != null) + { + for (int i = 0; i < TroopType.Count && i < units.Length; i++) result[i] = Math.Max(0, units[i]); + } + else + { + result[TroopType.Infantry] = Math.Max(0, count); // состав неизвестен -- считаем пехотой + } + return result; + } + + static int Sum(int[] values) + { + int n = 0; + foreach (var v in values) n += v; + return n; + } } diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index b93e6e6..020ca62 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -16,6 +16,20 @@ public interface IEconomy bool CanAfford(IReadOnlyList cost); // Списывает всё или ничего bool TrySpend(IReadOnlyList cost); + + // Лимит склада для ресурса. float.PositiveInfinity -- без лимита (люди, сила). + // Сверх лимита ресурс не прибавляется (добыча, награды) -- лишнее пропадает. + float GetCapacity(ResourceType type); +} + +// Добыча через рабочих (этап 3). Реализует модуль Экономики (ProductionService). +public interface IProduction +{ + int Slots(WorkJob job); // мест для рабочих этой профессии во всех зданиях города + float Efficiency(WorkJob job); // множитель от уровня зданий (1 -- здания 1 уровня) + float RatePerHour(ResourceType type); // добыча ресурса в час сейчас + // Добыть за hours часов (вызывает модуль Населения в своём расчёте -- сначала добыли, потом съели) + void Produce(double hours); } // ---------------- Поселение: здания и развитие города ---------------- @@ -37,8 +51,10 @@ public interface IPopulation { int Total { get; } // все люди города: жители + воины (в городе и в походах) int Civilians { get; } // жители (не воины) - int Workers { get; } // жители на работе (этап 3: распределение по зданиям) + int Workers { get; } // жители на работе (все профессии вместе) int Idle { get; } // остальные жители: Civilians - Workers + int Workforce { get; } // сколько жителей вообще могут работать (взрослые; стартовый город -- 120) + int FreeWorkers { get; } // могут работать, но без работы: Workforce - Workers int Soldiers { get; } // воины -- их хранит модуль Армии int HousingCap { get; } // предел населения: выше него прирост не идёт @@ -48,6 +64,11 @@ public interface IPopulation // Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке) void Tick(long unixNow); + + // Рабочие по профессиям (этап 3) + int GetWorkers(WorkJob job); + // Поставить count рабочих на профессию (в пределах мест в зданиях и свободных рабочих). Возвращает, сколько стоит теперь. + int SetWorkers(WorkJob job, int count); } // ---------------- Армия: войска ---------------- @@ -55,16 +76,29 @@ public interface IArmy { int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные) int GetTroops(int type); // воинов этого типа в городе - int SoldiersAtHome { get; } // всего воинов в городе + int SoldiersAtHome { get; } // всего воинов в городе (вместе с гарнизоном) int TroopsInMarches { get; } // воинов в походах + // Гарнизон -- часть воинов в городе: защищает город и в походы не уходит. Всего воинов от этого не меняется. + int GarrisonTotal { get; } + int GetGarrison(int type); + int AvailableForMarch { get; } // можно отправить в поход: в городе минус гарнизон + int SetGarrison(int count); // назначить гарнизон (по составу армии); возвращает, сколько назначено + void AddTroops(int type, int count); // обучены / вернулись int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло - // Забрать воинов из города в поход (запоминает состав похода) + // Забрать свободных воинов в поход (запоминает состав похода). + // false -- ничего не взято: не хватает воинов или поход с таким номером уже есть. bool TakeForMarch(int marchId, int count); - // Вернуть выживших из похода по тем же типам войск - void ReturnFromMarch(int marchId, int survivors); + // Вернуть выживших из похода по тем же типам войск. + // Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен. + bool ReturnFromMarch(int marchId, int survivors); + + // Состав похода по типам (копия); null -- такого похода нет + int[] GetMarchUnits(int marchId); + // Вернуть выживших точно по типам (бой посчитал потери каждого типа). Правила те же, что у ReturnFromMarch. + bool ReturnUnits(int marchId, int[] survivorUnits); } // ---------------- Глобальный мир: карта и перемещения ---------------- @@ -75,10 +109,28 @@ public interface IWorld } // ---------------- Боевая система: урон, потери, исход ---------------- +// Типы войск в бою -- порядок как в GameData.Troops и в модуле Армии +public static class TroopType +{ + public const int Infantry = 0; // пехота + public const int Archers = 1; // лучники + public const int Cavalry = 2; // кавалерия + public const int Siege = 3; // осадные + public const int Count = 4; +} + public struct BattleRequest { + // Состав сторон по типам войск (TroopType). Если состав не задан (null) -- + // берётся число Attackers / Defenders, и все считаются пехотой. + public int[] AttackerUnits; + public int[] DefenderUnits; public int Attackers; public int Defenders; + + public bool DefenderFortified; // защитники за стенами (город, замок) + public int AttackerBonusPercent; // герой, технологии, голод (минус) -- +10 = на 10% сильнее + public int DefenderBonusPercent; } public struct BattleOutcome @@ -87,6 +139,11 @@ public struct BattleOutcome public int AttackerLosses; public int DefenderLosses; public int Survivors; // сколько атакующих осталось + + public long AttackerPower; // сила сторон с учётом контр, стен и бонусов + public long DefenderPower; + public int[] AttackerSurvivorUnits; // выжившие атакующие по типам + public int[] DefenderLossUnits; // потери защитников по типам } public interface IBattleSystem diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index 6e58efb..0d2004b 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -42,6 +42,13 @@ public struct PopulationChangedEvent public int HousingCap; } +// Рабочих переставили (окно "Рабочие", автоматическое заполнение новых мест) +public struct WorkersChangedEvent +{ + public int Workers; + public int FreeWorkers; +} + // Голод начался (IsStarving = true) или закончился public struct StarvationChangedEvent { @@ -54,6 +61,7 @@ public struct TroopsChangedEvent { public int AtHome; public int InMarches; + public int Garrison; // из AtHome -- в гарнизоне } // ---------------- Глобальный мир ---------------- @@ -79,6 +87,7 @@ public struct MarchReturnedEvent public bool Victory; public int Survivors; public int Sent; // сколько воинов уходило в поход + public bool TargetEmpty; // лагерь уже был разбит: боя не было, вернулись все public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа) public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет" } diff --git a/Assets/Scripts/Modules/Core/WorkJob.cs b/Assets/Scripts/Modules/Core/WorkJob.cs new file mode 100644 index 0000000..13c38b7 --- /dev/null +++ b/Assets/Scripts/Modules/Core/WorkJob.cs @@ -0,0 +1,53 @@ +// Профессии рабочих. Рабочего держит модуль Населения, добычу считает модуль Экономики. +// Числовые значения НЕ менять: они записаны в сохранениях. +public enum WorkJob +{ + Woodcutter = 0, // лесоруб -> дерево (Лесопилка) + Farmer = 1, // фермер -> еда (Ферма) + Stonecutter = 2, // каменотёс -> камень (Каменоломня) + MinerIron = 3, // шахтёр -> металл (Шахта) + MinerGold = 4 // шахтёр -> золото (Золотой рудник) +} + +public static class WorkJobs +{ + public const int Count = 5; + + public static ResourceType Resource(WorkJob job) + { + switch (job) + { + case WorkJob.Woodcutter: return ResourceType.Wood; + case WorkJob.Farmer: return ResourceType.Food; + case WorkJob.Stonecutter: return ResourceType.Stone; + case WorkJob.MinerIron: return ResourceType.Iron; + default: return ResourceType.Gold; + } + } + + // Какая профессия добывает этот ресурс. false -- ресурс не добывается рабочими (люди, кристаллы, сила) + public static bool TryFromResource(ResourceType type, out WorkJob job) + { + switch (type) + { + case ResourceType.Wood: job = WorkJob.Woodcutter; return true; + case ResourceType.Food: job = WorkJob.Farmer; return true; + case ResourceType.Stone: job = WorkJob.Stonecutter; return true; + case ResourceType.Iron: job = WorkJob.MinerIron; return true; + case ResourceType.Gold: job = WorkJob.MinerGold; return true; + default: job = WorkJob.Woodcutter; return false; + } + } + + public static string Name(WorkJob job) + { + switch (job) + { + case WorkJob.Woodcutter: return "Лесорубы"; + case WorkJob.Farmer: return "Фермеры"; + case WorkJob.Stonecutter: return "Каменотёсы"; + case WorkJob.MinerIron: return "Шахтёры (металл)"; + default: return "Шахтёры (золото)"; + } + } +} diff --git a/Assets/Scripts/Modules/Core/WorkJob.cs.meta b/Assets/Scripts/Modules/Core/WorkJob.cs.meta new file mode 100644 index 0000000..bf8b35e --- /dev/null +++ b/Assets/Scripts/Modules/Core/WorkJob.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eb271faca9634ee0b607250e9341b0ce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Economy/ResourceCollector.cs b/Assets/Scripts/Modules/Economy/ResourceCollector.cs index 4413aaa..22c4517 100644 --- a/Assets/Scripts/Modules/Economy/ResourceCollector.cs +++ b/Assets/Scripts/Modules/Economy/ResourceCollector.cs @@ -1,100 +1,71 @@ +using System; using UnityEngine; -// Модуль Экономики: временная добыча по клику (уйдёт в этапе 3 -- производство через рабочих). +// МОДУЛЬ ЭКОНОМИКИ: здание-рабочее место (этап 3). Висит на всех добывающих зданиях (префабы Лес, Еда, Камень...). +// +// Раньше это был кликер (тап -- забрать накопленное). Теперь ресурс производят люди: +// здание только даёт места для рабочих и эффективность уровня, а добычу считает ProductionService. +// Имя класса оставлено старым, чтобы не перенастраивать префабы. +// +// resourceType = Wood/Food/Stone/Iron/Gold -- рабочее место этой профессии; +// resourceType = Population -- Дом: прибавляет места к пределу жилья; +// resourceType = Gems -- Кристалл: рабочих нет (синий кристалл добывается в разломах на карте мира). +// +// Тап по зданию открывает окно рабочих -- его рисует слой приложения (подписка на Tapped в App/GameModules). public class ResourceCollector : MonoBehaviour, ILeveledBuilding { - // Уровень здания для Поселения (оно сохраняет уровни, не зная, что это за здание) - public int Level { get => level; set => level = value; } + // Тап по зданию: интерфейс откроет окно "Рабочие" (модуль про интерфейс не знает) + public static event Action Tapped; - [Header("Что производит это здание")] + // Уровень здания для Поселения (оно сохраняет уровни, не зная, что это за здание) + public int Level { get => level; set => level = Mathf.Max(1, value); } + + [Header("Что добывают рабочие этого здания")] public ResourceType resourceType; - [Tooltip("База награды за клик (используется для расчета множителя уровня)")] - public float amountPerClick = 10f; - - [Header("Параметры хранилища")] - [Tooltip("Максимальная вместимость")] - public float maxCapacity = 100f; - - [Tooltip("Текущий запас (меняется автоматически)")] - public float currentStock = 0f; - - [Header("Уровень здания")] + [Header("Уровень здания (больше мест и эффективность)")] public int level = 1; - public float perLevelMultiplier = 1.2f; - private ResourceManager _manager; - private bool isRecharging = false; + [Header("Устарело (этап 3): кликер убран, поля оставлены для старых префабов")] + [HideInInspector] public float amountPerClick = 10f; + [HideInInspector] public float maxCapacity = 100f; + [HideInInspector] public float currentStock = 0f; + [HideInInspector] public float perLevelMultiplier = 1.2f; - void Awake() - { - _manager = ResourceManager.Instance; - if (_manager == null) - { - Debug.LogError($"{gameObject.name}: ResourceManager не найден!"); - enabled = false; - return; - } - currentStock = 0f; - } + public bool IsWorkplace => WorkJobs.TryFromResource(resourceType, out _); + public bool IsHouse => resourceType == ResourceType.Population; + // Для полоски над зданием (BuildingUI): рабочие этой профессии / места public float GetCurrentStock() { - return currentStock; + if (!WorkJobs.TryFromResource(resourceType, out WorkJob job)) return 0f; + return GameServices.TryGet(out IPopulation population) ? population.GetWorkers(job) : 0f; } public float GetMaxCapacity() { - return maxCapacity; + if (!WorkJobs.TryFromResource(resourceType, out WorkJob job)) return 0f; + return GameServices.TryGet(out IProduction production) ? production.Slots(job) : 0f; } + Vector3 downPos; + void OnMouseDown() { - if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен собирать ресурс под ней - if (isRecharging) - { - Debug.Log($"[КЛИК] {gameObject.name}: уже идёт восстановление, ждём."); - return; - } - - if (currentStock > 0) - { - float reward = currentStock; // берём всё, что накопилось - _manager.AddResource(resourceType, reward); - - currentStock = 0f; - Debug.Log($"[СБОР] Получено {reward:F1} {resourceType}. Запас сброшен в 0."); - - StartCoroutine(RechargeRoutine()); - } - else - { - Debug.Log($"[КЛИК] {gameObject.name} пуст, запускаем восстановление."); - StartCoroutine(RechargeRoutine()); - } + downPos = Input.mousePosition; } - System.Collections.IEnumerator RechargeRoutine() + // Отпустил палец на здании, почти не сдвинув (а не тащил камеру) -- окно рабочих + void OnMouseUpAsButton() { - if (isRecharging) yield break; - isRecharging = true; - - float rechargeRate = 5f; // единиц в секунду - - while (currentStock < maxCapacity) - { - currentStock += rechargeRate * Time.deltaTime; - if (currentStock > maxCapacity) currentStock = maxCapacity; - yield return null; - } - - isRecharging = false; - Debug.Log($"[ГОТОВО] {gameObject.name} полностью заполнен!"); + if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен проходить на здание под ней + if ((Input.mousePosition - downPos).magnitude > 25f) return; + Tapped?.Invoke(this); } public void Upgrade() { level++; - Debug.Log($"[АПГРЕЙД] Уровень {level}"); + Debug.Log($"[ЗДАНИЕ] {name}: уровень {level}"); } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Modules/Economy/ResourceManager.cs b/Assets/Scripts/Modules/Economy/ResourceManager.cs index cde6960..0e7382a 100644 --- a/Assets/Scripts/Modules/Economy/ResourceManager.cs +++ b/Assets/Scripts/Modules/Economy/ResourceManager.cs @@ -26,6 +26,13 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant private Dictionary resources = new Dictionary(); + // Лимиты склада (этап 1). Нет записи -- лимита нет. Задаёт слой приложения (App/CitySync) по Складам и Амбарам, + // запоминаются в сохранении: на карте мира город не открыт, а лимиты действуют. + private Dictionary capacities = new Dictionary(); + + // Лимит изменился (для интерфейса: "запас / лимит") + public event Action OnCapacityChanged; + void Awake() { // Классический паттерн Singleton: если уже есть экземпляр -- уничтожаем дубликат @@ -50,14 +57,21 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant 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; // ресурс не уходит в минус + float current = resources[type]; + float next = current + amount; + if (amount > 0f && capacities.TryGetValue(type, out float cap)) + next = Mathf.Min(next, Mathf.Max(current, cap)); + if (next < 0f) next = 0f; // ресурс не уходит в минус + if (next == current) return; // ничего не изменилось (например, склад полон) -- не будим интерфейс + resources[type] = next; NotifyChanged(type); } @@ -81,6 +95,19 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant return resources.TryGetValue(type, out float value) ? value : 0f; } + // Лимит склада: float.PositiveInfinity -- без лимита + public float GetCapacity(ResourceType type) + { + return capacities.TryGetValue(type, out float cap) ? cap : float.PositiveInfinity; + } + + public void SetCapacity(ResourceType type, float capacity) + { + if (capacities.TryGetValue(type, out float old) && Mathf.Approximately(old, capacity)) return; + capacities[type] = Mathf.Max(0f, capacity); + OnCapacityChanged?.Invoke(type, capacities[type]); + } + // Устанавливает значение напрямую, БЕЗ проверок и без учёта прошлого значения. // Используется только при загрузке сохранения -- обычная игровая логика должна // использовать AddResource/TrySpend, а не этот метод. @@ -127,6 +154,7 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant // ---------------- Контракт IEconomy ---------------- float IEconomy.Get(ResourceType type) => GetResource(type); + float IEconomy.GetCapacity(ResourceType type) => GetCapacity(type); bool IEconomy.Has(ResourceType type, float amount) => HasEnough(type, amount); void IEconomy.Add(ResourceType type, float amount) => AddResource(type, amount); @@ -160,6 +188,7 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant class EconomySaveData { public List resources = new List(); + public List capacities = new List(); // лимиты склада (этап 1) } public string SaveKey => "economy"; @@ -169,6 +198,8 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant var data = new EconomySaveData(); foreach (var kvp in resources) data.resources.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value }); + foreach (var kvp in capacities) + data.capacities.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value }); return JsonUtility.ToJson(data); } @@ -176,6 +207,8 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant { var data = JsonUtility.FromJson(json); if (data?.resources == null) return; + if (data.capacities != null) + foreach (var entry in data.capacities) SetCapacity(entry.type, entry.amount); foreach (var entry in data.resources) SetResource(entry.type, entry.amount); } diff --git a/Assets/Scripts/Modules/Economy/ResourceProducer.cs b/Assets/Scripts/Modules/Economy/ResourceProducer.cs index 5203100..ff5fd6c 100644 --- a/Assets/Scripts/Modules/Economy/ResourceProducer.cs +++ b/Assets/Scripts/Modules/Economy/ResourceProducer.cs @@ -1,53 +1,21 @@ using UnityEngine; -// Вешается на ЛЮБОЕ здание (лесопилка, каменоломня, шахта) -- настраиваешь -// в Inspector, какой ресурс и сколько оно производит. Один скрипт на все типы построек. -// -// ПРИМЕР НАСТРОЙКИ: -// Лесопилка: resourceType = Wood, amountPerTick = 5, tickInterval = 3 -// Каменоломня: resourceType = Stone, amountPerTick = 4, tickInterval = 3 -// Шахта: resourceType = Iron, amountPerTick = 2, tickInterval = 4 (руда добывается медленнее) - +// УСТАРЕЛО (этап 3). Раньше здание само добывало ресурс по таймеру (5 единиц раз в 3 секунды ~ 6000 в час). +// Теперь добычу дают только рабочие (ProductionService), этот компонент ничего не делает. +// Класс оставлен, чтобы на старых префабах не появилось "Missing Script". Новым зданиям не вешать. public class ResourceProducer : MonoBehaviour { - [Header("Что производит это здание")] public ResourceType resourceType; public float amountPerTick = 1f; - public float tickInterval = 3f; // секунд между "тиками" производства - - [Header("Уровень здания (для будущего апгрейда)")] + public float tickInterval = 3f; public int level = 1; - [Tooltip("Множитель производства за каждый уровень выше первого")] public float perLevelMultiplier = 1.2f; - private float timer; - - void Update() + void Awake() { - timer += Time.deltaTime; - - if (timer >= tickInterval) - { - timer -= tickInterval; - Produce(); - } + enabled = false; } - void Produce() - { - if (ResourceManager.Instance == null) - { - Debug.LogWarning($"{gameObject.name}: ResourceManager.Instance не найден. " + - "Убедись, что на сцене есть объект с компонентом ResourceManager."); - return; - } - - float finalAmount = amountPerTick * Mathf.Pow(perLevelMultiplier, level - 1); - ResourceManager.Instance.AddResource(resourceType, finalAmount); - Debug.Log($"{gameObject.name} произвел {finalAmount} {resourceType}. Итого: {ResourceManager.Instance.GetResource(resourceType)}"); - } - - // Вызывай при апгрейде здания (кнопка "улучшить" повесишь позже) public void Upgrade() { level++; diff --git a/Assets/Scripts/Modules/Economy/Rules.meta b/Assets/Scripts/Modules/Economy/Rules.meta new file mode 100644 index 0000000..e046850 --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2f9b52d13f334e20be4bad9f8a86c74b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Economy/Rules/Bogatyri.Economy.Rules.asmdef b/Assets/Scripts/Modules/Economy/Rules/Bogatyri.Economy.Rules.asmdef new file mode 100644 index 0000000..d6b249a --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules/Bogatyri.Economy.Rules.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Bogatyri.Economy.Rules", + "rootNamespace": "", + "references": ["Bogatyri.Core"], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/Assets/Scripts/Modules/Economy/Rules/Bogatyri.Economy.Rules.asmdef.meta b/Assets/Scripts/Modules/Economy/Rules/Bogatyri.Economy.Rules.asmdef.meta new file mode 100644 index 0000000..3548db2 --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules/Bogatyri.Economy.Rules.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9a5b231268f445349a58134b71372e9b +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Economy/Rules/ProductionService.cs b/Assets/Scripts/Modules/Economy/Rules/ProductionService.cs new file mode 100644 index 0000000..32e1f76 --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules/ProductionService.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +// Правила добычи -- только числа (заполняются из EconomyConfig в App/GameModules). +public sealed class ProductionRules +{ + // Норма T1 в час на одного рабочего, по профессиям (порядок как в WorkJob) + public double[] PerWorkerPerHour = { 2.0, 2.0, 1.5, 1.0, 0.4 }; + + public int SlotsPerBuilding = 30; // мест в здании 1 уровня (Лесопилка T1 -- 30) + public int SlotsPerLevel = 10; // + мест за каждый уровень выше первого. Не утверждено + public double EfficiencyPerLevel = 0.1; // + к норме за каждый уровень выше первого (×1.1, ×1.2...). Не утверждено +} + +// МОДУЛЬ ЭКОНОМИКИ: добыча через рабочих (этап 3). Документ: Docs/Modules/Economy.md +// Без Unity: правила те же, что будут на сервере. +// +// Ресурс производят люди, здание даёт только места и эффективность: +// добыча в час = min(рабочие, места) × норма × эффективность уровня зданий. +// Какие здания стоят в городе, сообщает слой приложения (App/CitySync) -- модуль их не ищет. +// Места запоминаются в сохранении: на карте мира и офлайн город не открыт, а добыча идёт. +// +// Produce вызывает модуль Населения в своём расчёте, поэтому в игре, офлайн и при перемотке правила одни. +// На склад идут только целые единицы, доли копятся здесь (склад хранит float). +public sealed class ProductionService : IProduction, ISaveParticipant +{ + const double Epsilon = 1e-9; + + readonly ProductionRules rules; + readonly int[] slots = new int[WorkJobs.Count]; + readonly double[] efficiency = new double[WorkJobs.Count]; + readonly double[] pending = new double[WorkJobs.Count]; // добыто, но ещё не отдано на склад (< 1) + + public ProductionService(ProductionRules rules) + { + this.rules = rules ?? new ProductionRules(); + ResetState(); + } + + public ProductionRules Rules => rules; + + // ---------------- Здания города ---------------- + + // Здания-рабочие места: (профессия, уровень здания). Вызывает App/CitySync, когда город изменился. + public void SetBuildings(IEnumerable> buildings) + { + var weighted = new double[WorkJobs.Count]; + Array.Clear(slots, 0, slots.Length); + foreach (var b in buildings) + { + int j = (int)b.Key; + if (j < 0 || j >= WorkJobs.Count) continue; + int level = Math.Max(1, b.Value); + int s = BuildingSlots(level); + slots[j] += s; + weighted[j] += s * BuildingEfficiency(level); + } + for (int j = 0; j < WorkJobs.Count; j++) + efficiency[j] = slots[j] > 0 ? weighted[j] / slots[j] : 1.0; + } + + public int BuildingSlots(int level) => rules.SlotsPerBuilding + rules.SlotsPerLevel * (Math.Max(1, level) - 1); + public double BuildingEfficiency(int level) => 1.0 + rules.EfficiencyPerLevel * (Math.Max(1, level) - 1); + + // ---------------- Контракт IProduction ---------------- + + public int Slots(WorkJob job) => slots[(int)job]; + public float Efficiency(WorkJob job) => (float)efficiency[(int)job]; + + public float RatePerHour(ResourceType type) + { + if (!WorkJobs.TryFromResource(type, out WorkJob job)) return 0f; + return (float)JobRate(job); + } + + double JobRate(WorkJob job) + { + int j = (int)job; + if (!GameServices.TryGet(out IPopulation population)) return 0; + int working = Math.Min(population.GetWorkers(job), slots[j]); + return working * rules.PerWorkerPerHour[j] * efficiency[j]; + } + + public void Produce(double hours) + { + if (hours <= 0 || !GameServices.TryGet(out IEconomy economy)) return; + for (int j = 0; j < WorkJobs.Count; j++) + { + var job = (WorkJob)j; + pending[j] += JobRate(job) * hours; + int whole = (int)Math.Floor(pending[j] + Epsilon); + if (whole <= 0) continue; + pending[j] = Math.Max(0, pending[j] - whole); + economy.Add(WorkJobs.Resource(job), whole); // сверх лимита склада Экономика не прибавит + } + } + + // ---------------- Сохранение (секция "production") ---------------- + // slots=<места по профессиям> eff=<эффективность> pending=<доли> + + public string SaveKey => "production"; + + public string Capture() + { + var sb = new StringBuilder(); + sb.Append("slots=").Append(Join(slots)).Append('\n'); + sb.Append("eff=").Append(Join(efficiency)).Append('\n'); + sb.Append("pending=").Append(Join(pending)).Append('\n'); + return sb.ToString(); + } + + public void Restore(string data) + { + ResetState(); + if (string.IsNullOrEmpty(data)) return; + foreach (var raw in data.Split('\n')) + { + string line = raw.Trim(); + if (line.StartsWith("slots=")) ParseInto(line.Substring(6), v => Math.Max(0, (int)v), slots); + else if (line.StartsWith("eff=")) ParseInto(line.Substring(4), v => Math.Max(0.1, v), efficiency); + else if (line.StartsWith("pending=")) ParseInto(line.Substring(8), v => Math.Max(0, Math.Min(0.999, v)), pending); + } + } + + public void ResetState() + { + Array.Clear(slots, 0, slots.Length); + Array.Clear(pending, 0, pending.Length); + for (int j = 0; j < WorkJobs.Count; j++) efficiency[j] = 1.0; + } + + static string Join(int[] a) + { + var parts = new string[a.Length]; + for (int i = 0; i < a.Length; i++) parts[i] = a[i].ToString(CultureInfo.InvariantCulture); + return string.Join(",", parts); + } + + static string Join(double[] a) + { + var parts = new string[a.Length]; + for (int i = 0; i < a.Length; i++) parts[i] = a[i].ToString("R", CultureInfo.InvariantCulture); + return string.Join(",", parts); + } + + static void ParseInto(string csv, Func conv, int[] target) + { + string[] parts = csv.Split(','); + for (int i = 0; i < target.Length && i < parts.Length; i++) + if (double.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out double v)) target[i] = conv(v); + } + + static void ParseInto(string csv, Func conv, double[] target) + { + string[] parts = csv.Split(','); + for (int i = 0; i < target.Length && i < parts.Length; i++) + if (double.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out double v)) target[i] = conv(v); + } +} diff --git a/Assets/Scripts/Modules/Economy/Rules/ProductionService.cs.meta b/Assets/Scripts/Modules/Economy/Rules/ProductionService.cs.meta new file mode 100644 index 0000000..e2639c0 --- /dev/null +++ b/Assets/Scripts/Modules/Economy/Rules/ProductionService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 040af6a37da54abcad0b6ebf2037adb0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Population/PopulationRules.cs b/Assets/Scripts/Modules/Population/PopulationRules.cs index 4a82b00..8dae766 100644 --- a/Assets/Scripts/Modules/Population/PopulationRules.cs +++ b/Assets/Scripts/Modules/Population/PopulationRules.cs @@ -2,7 +2,8 @@ // в тестах -- напрямую. Значения по умолчанию -- из дизайн-документа (этап 2 плана разработки). public sealed class PopulationRules { - // Стартовый город T1: 300 человек = 120 рабочих + 30 воинов + 150 остальных + // Стартовый город T1: 300 человек = 120 рабочих + 30 воинов + 150 остальных. + // Доля способных работать (120 из 270 жителей) сохраняется и при росте города. public int StartTotal = 300; public int StartWorkers = 120; public int StartSoldiers = 30; @@ -14,9 +15,11 @@ public sealed class PopulationRules // Прирост +2–3 человека в час, если есть еда public double GrowthPerHour = 2.5; - // Предел населения (жильё). Растёт с уровнем Ратуши. Числа НЕ утверждены командой. - public int HousingBase = 400; - public int HousingPerTownHallLevel = 100; + // Предел населения (жильё). Решение команды от 19.10.2026: 300 жителей при пределе 300 -- прирост стоит, + // построен Дом -- прирост идёт. Прибавки за Дом и Ратушу НЕ утверждены командой. + public int HousingBase = 300; + public int HousingPerTownHallLevel = 50; + public int HousingPerHouseLevel = 50; // каждый уровень Дома (здание "Население") // Офлайн-прогресс досчитывается не больше чем за столько часов. Число НЕ утверждено. public int MaxOfflineHours = 48; diff --git a/Assets/Scripts/Modules/Population/PopulationService.cs b/Assets/Scripts/Modules/Population/PopulationService.cs index 511d1ee..91b6c39 100644 --- a/Assets/Scripts/Modules/Population/PopulationService.cs +++ b/Assets/Scripts/Modules/Population/PopulationService.cs @@ -2,14 +2,16 @@ using System; using System.Globalization; using System.Text; -// МОДУЛЬ НАСЕЛЕНИЯ: сколько в городе людей, сколько они едят и как прирастают. Документ: Docs/Modules/Population.md -// Без Unity: правила те же, что будут на сервере. +// МОДУЛЬ НАСЕЛЕНИЯ: сколько в городе людей, кто где работает, сколько едят и как прирастают. +// Документ: Docs/Modules/Population.md. Без Unity: правила те же, что будут на сервере. // // Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час) // и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах. +// Жители: часть может работать (Workforce, стартовый город -- 120 из 270), из них рабочие стоят на профессиях. // // Время -- только из GameClock. Расчёт идёт кусками по 10 минут: раз в несколько секунд в игре, // при входе в игру (офлайн-прогресс) и при перемотке в отладочной панели -- правила одни и те же. +// В каждом куске: сначала добыча (IProduction), потом еда, потом прирост. public sealed class PopulationService : IPopulation, ISaveParticipant { const long SecondsPerHour = 3600; @@ -19,7 +21,9 @@ public sealed class PopulationService : IPopulation, ISaveParticipant readonly PopulationRules rules; int civilians; - int workers; + readonly int[] jobs = new int[WorkJobs.Count]; // рабочих по профессиям + bool jobsInitialized; // рабочих хоть раз расставляли (старому сохранению расставим сами) + int houseLevels; // сумма уровней Домов в городе (сообщает App/CitySync) double growthProgress; // накопленная доля следующего человека (0..1) double foodOwed; // съедено, но ещё не списано со склада (меньше 1 еды) long lastTickUnix; // до какого момента население уже посчитано; 0 -- ещё ни разу @@ -35,12 +39,32 @@ public sealed class PopulationService : IPopulation, ISaveParticipant public PopulationRules Rules => rules; public long LastTickUnix => lastTickUnix; public bool RestoredFromSave { get; private set; } + public bool JobsInitialized => jobsInitialized; + public int HouseLevels => houseLevels; // ---------------- Контракт IPopulation ---------------- public int Civilians => civilians; - public int Workers => Math.Min(workers, civilians); - public int Idle => civilians - Workers; + + public int Workers + { + get { int n = 0; foreach (var j in jobs) n += j; return n; } + } + + public int Idle => Math.Max(0, civilians - Workers); + + // Доля жителей, способных работать: 120 из 270 в стартовом городе + public int Workforce + { + get + { + int startCiv = Math.Max(1, rules.StartCivilians); + double share = Math.Min(1.0, rules.StartWorkers / (double)startCiv); + return (int)Math.Floor(civilians * share + Epsilon); + } + } + + public int FreeWorkers => Math.Max(0, Workforce - Workers); public int Soldiers { @@ -58,7 +82,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant get { int level = GameServices.TryGet(out ISettlement settlement) ? Math.Max(1, settlement.TownHallLevel) : 1; - return rules.HousingBase + rules.HousingPerTownHallLevel * (level - 1); + return rules.HousingBase + rules.HousingPerTownHallLevel * (level - 1) + rules.HousingPerHouseLevel * houseLevels; } } @@ -71,6 +95,36 @@ public sealed class PopulationService : IPopulation, ISaveParticipant return civilians * rules.FoodPerCivilianPerHour + soldiers * rules.FoodPerSoldierPerHour; } + public int GetWorkers(WorkJob job) + { + int j = (int)job; + return j >= 0 && j < WorkJobs.Count ? jobs[j] : 0; + } + + public int SetWorkers(WorkJob job, int count) + { + int j = (int)job; + if (j < 0 || j >= WorkJobs.Count) return 0; + + int limit = jobs[j] + FreeWorkers; // больше, чем есть свободных, не поставить + int slots = SlotsFor(job); + if (slots >= 0) limit = Math.Min(limit, slots); // и больше, чем мест в зданиях + int value = Math.Max(0, Math.Min(count, limit)); + if (value != jobs[j]) + { + jobs[j] = value; + jobsInitialized = true; + WorkersChanged(); + } + return jobs[j]; + } + + // Мест для профессии: -1 -- модуль добычи не подключён (тесты без него), ограничения нет + static int SlotsFor(WorkJob job) + { + return GameServices.TryGet(out IProduction production) ? production.Slots(job) : -1; + } + public void Tick(long unixNow) { if (lastTickUnix == 0) @@ -100,7 +154,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant void Simulate(long seconds) { - if (!GameServices.TryGet(out IEconomy economy)) return; // без склада еды считать нечего + if (!GameServices.TryGet(out IEconomy economy)) return; // без склада считать нечего + GameServices.TryGet(out IProduction production); int totalBefore = Total; bool starvingBefore = starving; @@ -113,6 +168,9 @@ public sealed class PopulationService : IPopulation, ISaveParticipant remaining -= step; double hours = step / (double)SecondsPerHour; + // 0. Добыча: рабочие приносят ресурсы (в том числе еду) -- до того, как город поест + production?.Produce(hours); + // 1. Едим. Со склада списываются только целые единицы, доли копятся здесь (foodOwed): // склад хранит float, и тысячи мелких списаний по 0.02 давали бы ошибку округления. foodOwed += FoodPerHourExact(soldiers) * hours; @@ -163,6 +221,71 @@ public sealed class PopulationService : IPopulation, ISaveParticipant if (Total != totalBefore) Changed(); } + // ---------------- Рабочие: расстановка ---------------- + + // Поставить свободных рабочих на пустые места профессии (новое здание, улучшение). Возвращает, сколько поставлено. + public int AutoFill(WorkJob job) + { + int slots = SlotsFor(job); + int room = slots < 0 ? FreeWorkers : Math.Min(FreeWorkers, slots - jobs[(int)job]); + if (room <= 0) return 0; + SetWorkers(job, jobs[(int)job] + room); + return room; + } + + // Расставить свободных по всем профессиям поровну (старое сохранение, первый запуск после этапа 3) + public void AutoFillAll() + { + bool changed = false; + bool progress = true; + while (FreeWorkers > 0 && progress) + { + progress = false; + for (int j = 0; j < WorkJobs.Count && FreeWorkers > 0; j++) + { + int slots = SlotsFor((WorkJob)j); + if (slots >= 0 && jobs[j] >= slots) continue; + jobs[j]++; + progress = changed = true; + } + } + jobsInitialized = true; + if (changed) WorkersChanged(); + } + + // Мест стало меньше или работников меньше (призыв в будущем) -- снять лишних + public void ClampJobs() + { + bool changed = false; + for (int j = 0; j < WorkJobs.Count; j++) + { + int slots = SlotsFor((WorkJob)j); + if (slots >= 0 && jobs[j] > slots) { jobs[j] = slots; changed = true; } + } + int over = Workers - Workforce; + for (int j = WorkJobs.Count - 1; j >= 0 && over > 0; j--) + { + int take = Math.Min(over, jobs[j]); + jobs[j] -= take; over -= take; + if (take > 0) changed = true; + } + if (changed) WorkersChanged(); + } + + // Сумма уровней Домов (каждый уровень прибавляет к пределу жилья) + public void SetHouseLevels(int levels) + { + levels = Math.Max(0, levels); + if (levels == houseLevels) return; + houseLevels = levels; + Changed(); + } + + void WorkersChanged() + { + EventBus.Publish(new WorkersChangedEvent { Workers = Workers, FreeWorkers = FreeWorkers }); + } + // ---------------- Связь с остальной игрой (вызывает App/GameModules) ---------------- // Сохранение загружено: досчитать офлайн-время. Старое сохранение без секции населения -- стартовый набор не выдаём. @@ -182,7 +305,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant return true; } - // Изменилась Армия или Ратуша -- пересчитать общее число людей для интерфейса + // Изменилась Армия, Ратуша или Дома -- пересчитать общее число людей для интерфейса public void Changed() { EventBus.Publish(new PopulationChangedEvent @@ -197,7 +320,9 @@ public sealed class PopulationService : IPopulation, ISaveParticipant void SetStartState() { civilians = Math.Max(0, rules.StartCivilians); - workers = Math.Min(rules.StartWorkers, civilians); + Array.Clear(jobs, 0, jobs.Length); + jobsInitialized = false; + houseLevels = 0; growthProgress = 0; foodOwed = 0; lastTickUnix = 0; @@ -214,7 +339,11 @@ public sealed class PopulationService : IPopulation, ISaveParticipant { var sb = new StringBuilder(); sb.Append("civilians=").Append(civilians.ToString(CultureInfo.InvariantCulture)).Append('\n'); - sb.Append("workers=").Append(workers.ToString(CultureInfo.InvariantCulture)).Append('\n'); + var parts = new string[jobs.Length]; + for (int i = 0; i < jobs.Length; i++) parts[i] = jobs[i].ToString(CultureInfo.InvariantCulture); + sb.Append("jobs=").Append(string.Join(",", parts)).Append('\n'); + sb.Append("jobsInit=").Append(jobsInitialized ? "1" : "0").Append('\n'); + sb.Append("houses=").Append(houseLevels.ToString(CultureInfo.InvariantCulture)).Append('\n'); sb.Append("growth=").Append(growthProgress.ToString("R", CultureInfo.InvariantCulture)).Append('\n'); sb.Append("food=").Append(foodOwed.ToString("R", CultureInfo.InvariantCulture)).Append('\n'); sb.Append("lastTick=").Append(lastTickUnix.ToString(CultureInfo.InvariantCulture)).Append('\n'); @@ -240,7 +369,12 @@ public sealed class PopulationService : IPopulation, ISaveParticipant switch (key) { case "civilians": civilians = Math.Max(0, ParseInt(value, civilians)); break; - case "workers": workers = Math.Max(0, ParseInt(value, workers)); break; + case "jobs": + string[] p = value.Split(','); + for (int i = 0; i < jobs.Length && i < p.Length; i++) jobs[i] = Math.Max(0, ParseInt(p[i].Trim(), 0)); + break; + case "jobsInit": jobsInitialized = value == "1"; break; + case "houses": houseLevels = Math.Max(0, ParseInt(value, 0)); break; case "growth": if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double g)) growthProgress = Math.Max(0, Math.Min(0.999, g)); break; @@ -261,6 +395,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant { SetStartState(); Changed(); + WorkersChanged(); } static int ParseInt(string s, int fallback) diff --git a/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef b/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef index e5279db..f9caf85 100644 --- a/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef +++ b/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef @@ -7,6 +7,7 @@ "Bogatyri.Core", "Bogatyri.Army", "Bogatyri.Population", + "Bogatyri.Economy.Rules", "Bogatyri.Battle", "Bogatyri.World.Simulation", "Bogatyri.Chat", diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index e78b5fc..41b643b 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -117,6 +117,96 @@ public class ModuleTests Assert.AreEqual(0, army.SoldiersAtHome + army.TroopsInMarches); } + // Решение команды от 19.10.2026: один солдат -- в одном месте. 30 солдат, 10 в гарнизон -> 20 доступны, всего 30. + [Test] + public void ArmyService_Garrison_OneSoldierInOnePlace() + { + var army = new ArmyService(); + army.AddTroops(0, 20); army.AddTroops(1, 10); + + Assert.AreEqual(10, army.SetGarrison(10)); + Assert.AreEqual(10, army.GarrisonTotal); + Assert.AreEqual(20, army.AvailableForMarch); + Assert.AreEqual(30, army.SoldiersAtHome, "всего воинов столько же"); + Assert.AreEqual(7, army.GetGarrison(0), "гарнизон по составу: 2/3 пехоты"); + Assert.AreEqual(3, army.GetGarrison(1), "и 1/3 лучников"); + + Assert.IsFalse(army.TakeForMarch(1, 21), "гарнизон в поход не уходит"); + Assert.IsTrue(army.TakeForMarch(1, 20)); + Assert.AreEqual(0, army.AvailableForMarch); + Assert.AreEqual(10, army.GarrisonTotal, "гарнизон остался в городе"); + Assert.AreEqual(30, army.SoldiersAtHome + army.TroopsInMarches); + + Assert.AreEqual(10, army.SetGarrison(99), "гарнизон не больше воинов в городе"); + + army.LoseFraction(0.5); + Assert.LessOrEqual(army.GarrisonTotal, army.SoldiersAtHome, "после потерь гарнизон не больше войск"); + for (int i = 0; i < 4; i++) Assert.LessOrEqual(army.GetGarrison(i), army.GetTroops(i)); + + var loaded = new ArmyService(); + loaded.Restore(army.Capture()); + Assert.AreEqual(army.GarrisonTotal, loaded.GarrisonTotal, "гарнизон сохраняется"); + + loaded.Restore("troops=5,0,0,0\n"); // старое сохранение без строки гарнизона + Assert.AreEqual(0, loaded.GarrisonTotal); + Assert.AreEqual(5, loaded.AvailableForMarch); + } + + // ---------------- Армия: безопасная отправка и возвращение (ревью 27.10.2026, п. 2.1-2.2) ---------------- + + [Test] + public void ArmyService_UnknownMarch_ReturnsNobody() + { + var army = new ArmyService(); + army.AddTroops(0, 5); + + Assert.IsFalse(army.ReturnFromMarch(42, 10), "неизвестный поход"); + Assert.IsFalse(army.ReturnUnits(42, new[] { 3, 3, 3, 3 }), "неизвестный поход"); + Assert.AreEqual(5, army.SoldiersAtHome, "из ниоткуда никто не появился"); + } + + [Test] + public void ArmyService_SameMarch_IsCreditedOnlyOnce() + { + var army = new ArmyService(); + army.AddTroops(0, 10); army.AddTroops(1, 10); + + Assert.IsTrue(army.TakeForMarch(1, 10)); + Assert.IsTrue(army.ReturnUnits(1, army.GetMarchUnits(1))); + Assert.IsFalse(army.ReturnUnits(1, new[] { 5, 5, 0, 0 }), "повторное событие возвращения"); + Assert.IsFalse(army.ReturnFromMarch(1, 10), "и по другому пути -- тоже нет"); + Assert.AreEqual(20, army.SoldiersAtHome + army.TroopsInMarches); + + Assert.IsTrue(army.TakeForMarch(2, 10)); + Assert.IsTrue(army.ReturnFromMarch(2, 99), "вернулось больше, чем ушло"); + Assert.AreEqual(20, army.SoldiersAtHome, "больше, чем ушло, не вернётся"); + } + + [Test] + public void ArmyService_TakeForMarch_SameIdTwice_IsRejected() + { + var army = new ArmyService(); + army.AddTroops(0, 20); + + Assert.IsTrue(army.TakeForMarch(7, 5)); + Assert.IsFalse(army.TakeForMarch(7, 5), "второй поход с тем же номером затёр бы первый"); + Assert.AreEqual(15, army.SoldiersAtHome); + Assert.AreEqual(5, army.TroopsInMarches); + } + + [Test] + public void World_CancelMarch_OnlyBeforeBattle() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, 0, out _); + Assert.IsTrue(world.CancelMarch(m.id)); + Assert.AreEqual(0, world.Marches.Count); + + MarchData fought = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, 0, out _); + world.Advance(fought.arriveMs, new List()); // бой прошёл, войско идёт домой + Assert.IsFalse(world.CancelMarch(fought.id), "после боя поход не отменяется"); + } + // ---------------- Боевая система ---------------- [Test] @@ -128,6 +218,179 @@ public class ModuleTests Assert.AreEqual(20, battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors); } + [Test] + public void Battle_Counters_ArchersBeatInfantry_InfantryBeatsCavalry() + { + var battle = new BattleSystem(); + // Одинаковая "сырая" сила: 25 лучников x5 = 125 против ~31 пехоты x4 = 124 + var archersVsInfantry = battle.Resolve(new BattleRequest + { AttackerUnits = new[] { 0, 25, 0, 0 }, DefenderUnits = new[] { 32, 0, 0, 0 } }); + Assert.IsTrue(archersVsInfantry.Victory, "лучники с контрой бьют чуть более сильную пехоту"); + + var cavalryVsInfantry = battle.Resolve(new BattleRequest + { AttackerUnits = new[] { 0, 0, 14, 0 }, DefenderUnits = new[] { 30, 0, 0, 0 } }); + Assert.IsFalse(cavalryVsInfantry.Victory, "пехота с контрой держит чуть более сильную кавалерию"); + } + + [Test] + public void Battle_WallsHelpDefenders_SiegeBreaksWalls() + { + var battle = new BattleSystem(); + var field = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } }); + var walls = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true }); + Assert.IsTrue(field.Victory); + Assert.IsFalse(walls.Victory, "те же войска за стенами устояли"); + + var siege = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true }); + var siegeField = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 } }); + Assert.Greater(siege.AttackerPower, siegeField.AttackerPower, "осадные сильнее против стен, чем в поле"); + } + + [Test] + public void Battle_LossesPerType_NeverExceedUnits_AndSumUp() + { + var battle = new BattleSystem(); + var rnd = new Random(7); + for (int run = 0; run < 500; run++) + { + var att = new int[4]; var def = new int[4]; + for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 30); def[i] = rnd.Next(0, 30); } + var o = battle.Resolve(new BattleRequest { AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0 }); + + int survivors = 0, defLost = 0; + for (int i = 0; i < 4; i++) + { + Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i])); + Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i])); + survivors += o.AttackerSurvivorUnits[i]; defLost += o.DefenderLossUnits[i]; + } + Assert.AreEqual(o.Survivors, survivors); + Assert.AreEqual(o.DefenderLosses, defLost); + Assert.AreEqual(att[0] + att[1] + att[2] + att[3] - o.AttackerLosses, o.Survivors); + } + } + + // Малые отряды, пустые стороны, ничья, предельные бонусы (ревью 27.10.2026, п. 2.4). + // Проверяем только то, что не зависит от будущей формулы потерь: исход и арифметику. + // Сколько теряет одиночка-проигравший -- решение команды (правило округления), тест его не фиксирует. + [Test] + public void Battle_EdgeCases_OutcomeAndArithmetic() + { + var b = new BattleSystem(); + var cases = new[] + { + new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 } }, // 1 x 1 + new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 } }, // 1 x 10 + new BattleRequest { AttackerUnits = new[] { 10, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 } }, // 10 x 1 + new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 0 }, DefenderUnits = new[] { 0, 0, 0, 0 } }, // 0 x 0 + new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 } }, // 0 x 5 + new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 0, 0, 0, 0 } }, // 5 x 0 + new BattleRequest { AttackerUnits = new[] { 3, 2, 1, 1 }, DefenderUnits = new[] { 2, 2, 2, 1 }, DefenderFortified = true }, + new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 }, AttackerBonusPercent = -100 }, + new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 }, AttackerBonusPercent = -500, DefenderBonusPercent = -500 }, + new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 50, 50, 50, 50 }, AttackerBonusPercent = 100000 }, + new BattleRequest { Attackers = 7, Defenders = 7 }, // старый бой числом + }; + + foreach (var r in cases) + { + BattleOutcome o = b.Resolve(r); + int[] att = r.AttackerUnits ?? new[] { r.Attackers, 0, 0, 0 }; + int[] def = r.DefenderUnits ?? new[] { r.Defenders, 0, 0, 0 }; + + Assert.GreaterOrEqual(o.AttackerPower, 0); + Assert.GreaterOrEqual(o.DefenderPower, 0); + Assert.AreEqual(o.AttackerPower > o.DefenderPower, o.Victory, "побеждает большая сила, ничья -- защитнику"); + + int survivors = 0, defLost = 0; + for (int i = 0; i < 4; i++) + { + Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i]), "выжившие атакующего в пределах состава"); + Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i]), "потери защитника в пределах состава"); + survivors += o.AttackerSurvivorUnits[i]; + defLost += o.DefenderLossUnits[i]; + } + Assert.AreEqual(o.Survivors, survivors); + Assert.AreEqual(o.DefenderLosses, defLost); + Assert.AreEqual(att[0] + att[1] + att[2] + att[3], o.Survivors + o.AttackerLosses, "выжившие + погибшие = ушедшие"); + } + + // Ничья 1 x 1 -- победа защитника; пустая сторона никого не теряет + Assert.IsFalse(b.Resolve(cases[0]).Victory); + Assert.IsFalse(b.Resolve(cases[3]).Victory); + Assert.AreEqual(0, b.Resolve(cases[3]).AttackerLosses + b.Resolve(cases[3]).DefenderLosses); + Assert.IsTrue(b.Resolve(cases[5]).Victory, "на пустую цель -- победа"); + Assert.AreEqual(0, b.Resolve(cases[5]).AttackerLosses, "на пустую цель -- без потерь"); + Assert.IsFalse(b.Resolve(cases[8]).Victory, "обе силы обнулились -- ничья, защитнику"); + } + + [Test] + public void World_GarrisonComposition_SumsToGarrison_AndIsStable() + { + foreach (var node in WorldMapLayout.CreateDefault()) + { + int[] a = WorldMapLayout.GarrisonUnits(node); + int[] b = WorldMapLayout.GarrisonUnits(node); + int sum = 0; + for (int i = 0; i < 4; i++) { Assert.GreaterOrEqual(a[i], 0); Assert.AreEqual(a[i], b[i]); sum += a[i]; } + Assert.AreEqual(node.garrison, sum, node.displayName); + } + } + + // ---------------- Глобальный мир: лагеря разбойников ---------------- + + [Test] + public void World_Camps_AreSpreadOut_FightInField_WithoutSiege() + { + var nodes = WorldMapLayout.CreateDefault(); + int camps = 0; + foreach (var camp in nodes) + { + if (camp.type != MapNodeType.Camp) continue; + camps++; + Assert.IsFalse(WorldMapLayout.IsFortified(camp), "лагерь -- бой в поле"); + Assert.AreEqual(0, WorldMapLayout.GarrisonUnits(camp)[TroopType.Siege], "у разбойников нет осадных"); + Assert.That(camp.level, Is.InRange(1, WorldMapLayout.CampLevels)); + foreach (var other in nodes) + { + if (other == camp) continue; + int min = other.type == MapNodeType.BossCastle ? 4500 : 2600; + Assert.GreaterOrEqual(WorldMapLayout.Distance(camp, other), min - 1, camp.id + " слишком близко к " + other.id); + } + } + Assert.AreEqual(WorldMapLayout.CampCount, camps); + } + + [Test] + public void World_CampCleared_SecondMarchFindsItEmpty_ThenItRespawns() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + const string camp = "camp_1"; // 1 уровень, 6..9 разбойников + MarchData first = world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 0, out string e1); + MarchData second = world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 1, out string e2); + Assert.IsNotNull(first, e1); + Assert.IsNotNull(second, e2); + + world.Advance(10 * 60_000, new List()); // через 10 минут оба похода закончились + Assert.IsTrue(first.victory); + Assert.IsNotEmpty(first.loot); + Assert.IsTrue(second.targetEmpty, "второе войско пришло к уже разбитому лагерю"); + Assert.AreEqual(30, second.survivors); + Assert.IsEmpty(second.loot); + + Assert.IsTrue(world.IsCampCleared(camp, 10 * 60_000)); + Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 10 * 60_000, out _), "разбитый лагерь не атакуется"); + + // Сохранение: другой мир с теми же данными помнит разбитый лагерь + var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + loaded.LoadCampStates(world.GetCampStates()); + Assert.IsTrue(loaded.IsCampCleared(camp, 10 * 60_000)); + + long respawn = first.arriveMs + CampRules.RespawnMs; + Assert.IsFalse(world.IsCampCleared(camp, respawn), "лагерь появился снова"); + Assert.IsNotNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, respawn, out _)); + } + // ---------------- Глобальный мир: поход до конца, в том числе офлайн ---------------- [Test] diff --git a/Assets/Scripts/Modules/Tests/PopulationTests.cs b/Assets/Scripts/Modules/Tests/PopulationTests.cs index 599b7e7..2a4f097 100644 --- a/Assets/Scripts/Modules/Tests/PopulationTests.cs +++ b/Assets/Scripts/Modules/Tests/PopulationTests.cs @@ -36,6 +36,22 @@ public class PopulationTests GameClock.ResetOffset(); } + // Предел жилья с запасом -- для тестов прироста (по умолчанию 300: стартовый город упирается в предел) + static PopulationRules Roomy() => new PopulationRules { HousingBase = 400 }; + + [Test] + public void StartCity_AtDefaultHousingCap_DoesNotGrow_HouseLetsItGrow() + { + economy.Set(ResourceType.Food, 10000); + var p = StartCity(); // решение команды от 19.10: 300 жителей при пределе 300 -- прирост стоит + p.Tick(T0 + 10 * Hour); + Assert.AreEqual(300, p.Total); + + p.SetHouseLevels(1); // построен Дом -- +50 мест + p.Tick(T0 + 20 * Hour); + Assert.AreEqual(325, p.Total, "построен Дом -- прирост идёт"); + } + // Стартовый город: 270 жителей + 30 воинов в Армии PopulationService StartCity(PopulationRules rules = null) { @@ -53,8 +69,9 @@ public class PopulationTests Assert.AreEqual(300, p.Total); Assert.AreEqual(270, p.Civilians); - Assert.AreEqual(120, p.Workers); - Assert.AreEqual(150, p.Idle); + Assert.AreEqual(120, p.Workforce, "могут работать 120"); + Assert.AreEqual(0, p.Workers, "рабочих расставляет игрок (или CitySync по зданиям)"); + Assert.AreEqual(120, p.FreeWorkers); Assert.AreEqual(30, p.Soldiers); Assert.AreEqual(33f, p.FoodPerHour, 0.001f); @@ -66,7 +83,7 @@ public class PopulationTests public void WithFood_GrowsTwoAndHalfPerHour() { economy.Set(ResourceType.Food, 10000); - var p = StartCity(); + var p = StartCity(Roomy()); p.Tick(T0 + 10 * Hour); Assert.AreEqual(300 + 25, p.Total, "за 10 часов +25 человек"); @@ -105,7 +122,7 @@ public class PopulationTests public void FoodReturns_StarvationEnds_GrowthResumes() { economy.Set(ResourceType.Food, 0); - var p = StartCity(); + var p = StartCity(Roomy()); p.Tick(T0 + Hour); Assert.IsTrue(p.IsStarving); @@ -157,14 +174,14 @@ public class PopulationTests public void ManySmallTicks_EqualOneBigTick() { economy.Set(ResourceType.Food, 10000); - var a = StartCity(); + var a = StartCity(Roomy()); for (long t = T0 + 2; t <= T0 + 10 * Hour; t += 2) a.Tick(t); // как в игре: раз в 2 секунды float foodA = economy.Get(ResourceType.Food); int totalA = a.Total; SetUp(); economy.Set(ResourceType.Food, 10000); - var b = StartCity(); + var b = StartCity(Roomy()); b.Tick(T0 + 10 * Hour); // как офлайн-прогресс Assert.AreEqual(totalA, b.Total); Assert.AreEqual(foodA, economy.Get(ResourceType.Food), 3.0, "игра и офлайн считают одинаково (разница -- доли часа, когда родился человек)"); @@ -206,7 +223,9 @@ public class PopulationTests public void SaveAndRestore_KeepsEverything() { economy.Set(ResourceType.Food, 10000); - var p = StartCity(); + var p = StartCity(Roomy()); + p.SetWorkers(WorkJob.Farmer, 40); + p.SetHouseLevels(2); p.Tick(T0 + 3 * Hour); // +7 человек и доля восьмого string saved = p.Capture(); @@ -215,6 +234,8 @@ public class PopulationTests Assert.IsTrue(q.RestoredFromSave); Assert.AreEqual(p.Civilians, q.Civilians); Assert.AreEqual(p.Workers, q.Workers); + Assert.AreEqual(40, q.GetWorkers(WorkJob.Farmer)); + Assert.AreEqual(2, q.HouseLevels); Assert.AreEqual(p.LastTickUnix, q.LastTickUnix); } @@ -247,19 +268,27 @@ public class PopulationTests // ---------------- Заглушки модулей ---------------- - class FakeEconomy : IEconomy + public class FakeEconomy : IEconomy { readonly Dictionary res = new Dictionary(); + readonly Dictionary caps = new Dictionary(); public void Set(ResourceType t, float v) { res[t] = v; } + public void SetCapacity(ResourceType t, float v) { caps[t] = v; } + public float GetCapacity(ResourceType type) => caps.TryGetValue(type, out float c) ? c : float.PositiveInfinity; public float Get(ResourceType type) => res.TryGetValue(type, out float v) ? v : 0f; public bool Has(ResourceType type, float amount) => Get(type) >= amount; - public void Add(ResourceType type, float amount) { float v = Get(type) + amount; res[type] = v < 0 ? 0 : v; } + public void Add(ResourceType type, float amount) + { + float cur = Get(type), v = cur + amount; + if (amount > 0) v = System.Math.Min(v, System.Math.Max(cur, GetCapacity(type))); // как ResourceManager + res[type] = v < 0 ? 0 : v; + } public bool TrySpend(ResourceType type, float amount) { if (!Has(type, amount)) return false; Add(type, -amount); return true; } public bool CanAfford(IReadOnlyList cost) { foreach (var c in cost) if (!Has(c.Type, c.Amount)) return false; return true; } public bool TrySpend(IReadOnlyList cost) { if (!CanAfford(cost)) return false; foreach (var c in cost) Add(c.Type, -c.Amount); return true; } } - class FakeSettlement : ISettlement + public class FakeSettlement : ISettlement { public int TownHallLevel { get; set; } public int BuildingCount => 0; diff --git a/Assets/Scripts/Modules/Tests/ProductionTests.cs b/Assets/Scripts/Modules/Tests/ProductionTests.cs new file mode 100644 index 0000000..30220a2 --- /dev/null +++ b/Assets/Scripts/Modules/Tests/ProductionTests.cs @@ -0,0 +1,196 @@ +using System.Collections.Generic; +using NUnit.Framework; + +// Тесты добычи через рабочих (этап 3) и лимитов склада (этап 1). +// Запуск: Window -> General -> Test Runner -> EditMode -> Run All. +// Проверка из дизайн-документа: сбалансированный город даёт 90 еды, 50 дерева, 22.5 камня и 15 металла в час. +public class ProductionTests +{ + const long T0 = 1_800_000_000; + const long Hour = 3600; + + PopulationTests.FakeEconomy economy; + ArmyService army; + PopulationService population; + ProductionService production; + + [SetUp] + public void SetUp() + { + GameServices.Clear(); + EventBus.Clear(); + SaveRegistry.Clear(); + GameClock.ResetOffset(); + + economy = new PopulationTests.FakeEconomy(); + army = new ArmyService(); + population = new PopulationService(new PopulationRules()); + production = new ProductionService(new ProductionRules()); + GameServices.Register(economy); + GameServices.Register(new PopulationTests.FakeSettlement { TownHallLevel = 1 }); + GameServices.Register(army); + GameServices.Register(population); + GameServices.Register(production); + + army.AddTroops(0, 30); + population.Tick(T0); + } + + // По одному зданию 1 уровня на каждую профессию, кроме золота (нужно 2 фермы для 45 фермеров) + static List> Buildings(params (WorkJob job, int level)[] list) + { + var result = new List>(); + foreach (var b in list) result.Add(new KeyValuePair(b.job, b.level)); + return result; + } + + [Test] + public void BalancedCity_Gives90Food_50Wood_22_5Stone_15Iron() + { + production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1), + (WorkJob.Stonecutter, 1), (WorkJob.MinerIron, 1))); + population.SetWorkers(WorkJob.Farmer, 45); + population.SetWorkers(WorkJob.Woodcutter, 25); + population.SetWorkers(WorkJob.Stonecutter, 15); + population.SetWorkers(WorkJob.MinerIron, 15); + + Assert.AreEqual(90f, production.RatePerHour(ResourceType.Food), 0.001f); + Assert.AreEqual(50f, production.RatePerHour(ResourceType.Wood), 0.001f); + Assert.AreEqual(22.5f, production.RatePerHour(ResourceType.Stone), 0.001f); + Assert.AreEqual(15f, production.RatePerHour(ResourceType.Iron), 0.001f); + Assert.AreEqual(100, population.Workers); + Assert.AreEqual(20, population.FreeWorkers); + } + + [Test] + public void OneHour_AddsProduction_AndFoodIsNetOfEating() + { + economy.Set(ResourceType.Food, 100); + production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1))); + population.SetWorkers(WorkJob.Farmer, 45); + population.SetWorkers(WorkJob.Woodcutter, 25); + + population.Tick(T0 + Hour); + Assert.AreEqual(100 + 90 - 33, economy.Get(ResourceType.Food), 0.01, "+90 добыли, -33 съели"); + Assert.AreEqual(50, economy.Get(ResourceType.Wood), 0.01); + } + + [Test] + public void ProductionComesBeforeEating_NoFalseStarvationOffline() + { + economy.Set(ResourceType.Food, 0); // склад пуст, но фермы кормят город + production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1))); + population.SetWorkers(WorkJob.Farmer, 45); + + population.Tick(T0 + 10 * Hour); + Assert.IsFalse(population.IsStarving, "сначала добыли, потом съели"); + Assert.AreEqual((90 - 33) * 10, economy.Get(ResourceType.Food), 1.0); + } + + [Test] + public void Workers_LimitedBySlots_AndByFreeWorkers() + { + production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); // 30 мест + Assert.AreEqual(30, population.SetWorkers(WorkJob.Woodcutter, 50), "больше мест не поставить"); + + production.SetBuildings(Buildings((WorkJob.Woodcutter, 1), (WorkJob.Farmer, 1), (WorkJob.Farmer, 1), + (WorkJob.Farmer, 1), (WorkJob.Farmer, 1))); + Assert.AreEqual(90, population.SetWorkers(WorkJob.Farmer, 120), "свободных осталось 90 из 120"); + Assert.AreEqual(0, population.FreeWorkers); + } + + [Test] + public void NoBuilding_NoWorkers() + { + Assert.AreEqual(0, population.SetWorkers(WorkJob.MinerGold, 10), "нет Золотого рудника -- нет мест"); + Assert.AreEqual(0f, production.RatePerHour(ResourceType.Gold)); + } + + [Test] + public void HigherLevel_MoreSlots_AndEfficiency() + { + production.SetBuildings(Buildings((WorkJob.Woodcutter, 3))); // 30 + 2×10 мест, норма ×1.2 + Assert.AreEqual(50, production.Slots(WorkJob.Woodcutter)); + Assert.AreEqual(1.2f, production.Efficiency(WorkJob.Woodcutter), 0.0001f); + + population.SetWorkers(WorkJob.Woodcutter, 50); + Assert.AreEqual(50 * 2 * 1.2f, production.RatePerHour(ResourceType.Wood), 0.001f); + } + + [Test] + public void StorageCap_OverflowIsLost() + { + economy.SetCapacity(ResourceType.Wood, 1000); + economy.Set(ResourceType.Wood, 990); + production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); + population.SetWorkers(WorkJob.Woodcutter, 30); // 60 в час + + population.Tick(T0 + Hour); + Assert.AreEqual(1000, economy.Get(ResourceType.Wood), 0.0001, "склад полон -- лишнее пропало"); + } + + [Test] + public void AutoFillAll_SpreadsWorkersEvenly() + { + production.SetBuildings(Buildings((WorkJob.Woodcutter, 1), (WorkJob.Farmer, 1), (WorkJob.Stonecutter, 1))); + population.AutoFillAll(); + Assert.AreEqual(30, population.GetWorkers(WorkJob.Woodcutter)); + Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer)); + Assert.AreEqual(30, population.GetWorkers(WorkJob.Stonecutter)); + Assert.AreEqual(30, population.FreeWorkers); + Assert.IsTrue(population.JobsInitialized); + } + + [Test] + public void AutoFill_NewBuilding_TakesFreeWorkers() + { + production.SetBuildings(Buildings((WorkJob.Farmer, 1))); + Assert.AreEqual(30, population.AutoFill(WorkJob.Farmer)); + production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1))); + Assert.AreEqual(30, population.AutoFill(WorkJob.Farmer), "вторая ферма -- ещё 30"); + Assert.AreEqual(60, population.GetWorkers(WorkJob.Farmer)); + } + + [Test] + public void FewerSlots_ClampJobs_RemovesExtraWorkers() + { + production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1))); + population.SetWorkers(WorkJob.Farmer, 60); + production.SetBuildings(Buildings((WorkJob.Farmer, 1))); + population.ClampJobs(); + Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer)); + Assert.AreEqual(90, population.FreeWorkers); + } + + [Test] + public void ManySmallTicks_EqualOneBigTick_WithProduction() + { + economy.Set(ResourceType.Food, 500); + production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1))); + population.SetWorkers(WorkJob.Farmer, 30); + population.SetWorkers(WorkJob.Woodcutter, 30); + for (long t = T0 + 2; t <= T0 + 5 * Hour; t += 2) population.Tick(t); + float foodA = economy.Get(ResourceType.Food), woodA = economy.Get(ResourceType.Wood); + + SetUp(); + economy.Set(ResourceType.Food, 500); + production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1))); + population.SetWorkers(WorkJob.Farmer, 30); + population.SetWorkers(WorkJob.Woodcutter, 30); + population.Tick(T0 + 5 * Hour); + + Assert.AreEqual(foodA, economy.Get(ResourceType.Food), 1.0); + Assert.AreEqual(woodA, economy.Get(ResourceType.Wood), 1.0); + Assert.AreEqual(300, woodA, 1.0, "30 лесорубов × 2 × 5 часов"); + } + + [Test] + public void SaveAndRestore_KeepsSlots() + { + production.SetBuildings(Buildings((WorkJob.Stonecutter, 2))); + var copy = new ProductionService(new ProductionRules()); + copy.Restore(production.Capture()); + Assert.AreEqual(40, copy.Slots(WorkJob.Stonecutter)); + Assert.AreEqual(1.1f, copy.Efficiency(WorkJob.Stonecutter), 0.0001f); + } +} diff --git a/Assets/Scripts/Modules/Tests/ProductionTests.cs.meta b/Assets/Scripts/Modules/Tests/ProductionTests.cs.meta new file mode 100644 index 0000000..75d26c9 --- /dev/null +++ b/Assets/Scripts/Modules/Tests/ProductionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 46ff65caf6ef4e93aaf2db576d6b6997 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index 53cba61..ebee70b 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -27,12 +27,14 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant public event Action OnBattleResolved; public event Action OnMarchReturned; - // Время мира. В мультиплеере -- время сервера, а не телефона - public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + // Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы. + // Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера. + public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + GameClock.OffsetSeconds * 1000; private readonly List events = new List(); private WorldSimulation world; private List pendingMarches; // загружены раньше, чем мир был создан + private List pendingCamps; // Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована public WorldSimulation World @@ -43,7 +45,9 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant { world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get()); if (pendingMarches != null) world.LoadMarches(pendingMarches); + if (pendingCamps != null) world.LoadCampStates(pendingCamps); pendingMarches = null; + pendingCamps = null; } return world; } @@ -99,17 +103,26 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant error = "Нет данных о войске -- запусти игру из города"; return false; } - if (army.SoldiersAtHome < soldiers) + if (army.AvailableForMarch < soldiers) { - error = "Не хватает воинов. Наймите их в Казармах"; + error = army.GarrisonTotal > 0 + ? $"Не хватает свободных воинов: {army.GarrisonTotal} стоят в гарнизоне. Гарнизон меняется в окне «Войска»" + : "Не хватает воинов. Наймите их в Казармах"; 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 (!army.TakeForMarch(march.id, soldiers)) + { + World.CancelMarch(march.id); + error = "Не удалось собрать войско в поход"; + return false; + } + march.units = army.GetMarchUnits(march.id); // состав похода -- для боя с типами войск if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}"); @@ -121,7 +134,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant void HandleBattleResolved(MarchData march) { MapNode target = World.GetNode(march.targetNodeId); - Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " + + if (march.targetEmpty) Debug.Log($"[ПОХОД] {target.displayName} уже разбит -- войско возвращается без боя"); + else Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " + $"{march.soldiers} против {march.defenders}, возвращаются {march.survivors}."); OnBattleResolved?.Invoke(march); EventBus.Publish(new MarchBattleResolvedEvent @@ -137,7 +151,17 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant // Солдаты и добыча зачисляются, только когда войско дошло до дома void HandleMarchReturned(MarchData march) { - GameServices.Get().ReturnFromMarch(march.id, march.survivors); + IArmy army = GameServices.Get(); + bool returned = march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count + ? army.ReturnUnits(march.id, march.survivorUnits) // бой посчитал потери по типам + : army.ReturnFromMarch(march.id, march.survivors); // старый поход без состава -- по пропорции + + // Армия не знает этот поход (уже зачислен или испорченное сохранение): ни воинов, ни добычи + if (!returned) + { + Debug.LogWarning($"[ПОХОД] Поход {march.id} неизвестен Армии -- воины и добыча не зачислены"); + return; + } IEconomy economy = GameServices.Get(); foreach (var reward in march.loot) @@ -156,6 +180,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant Victory = march.victory, Survivors = march.survivors, Sent = march.soldiers, + TargetEmpty = march.targetEmpty, TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "", LootText = FormatLoot(march.loot) }); @@ -167,25 +192,35 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant class WorldSaveData { public List marches = new List(); + public List camps = new List(); // разбитые лагеря и когда они появятся } public string SaveKey => "world"; public string Capture() { - return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave() }); + var camps = world != null ? world.GetCampStates() : new List(pendingCamps ?? new List()); + return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps }); } public void Restore(string json) { - LoadMarches(JsonUtility.FromJson(json)?.marches); + WorldSaveData data = JsonUtility.FromJson(json); + LoadMarches(data?.marches); + if (world != null) world.LoadCampStates(data?.camps); + else pendingCamps = data?.camps; } - // Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом) + // Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом), лагеря на месте public void ResetState() { pendingMarches = null; - if (world != null) world.LoadMarches(new List()); + pendingCamps = null; + if (world != null) + { + world.LoadMarches(new List()); + world.LoadCampStates(null); + } } public List GetMarchesForSave() diff --git a/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs b/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs index cebc2e5..485e88a 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs @@ -7,7 +7,8 @@ namespace Bogatyri.World { PlayerCity, // город игрока NeighborCity, // соседний город -- другой игрок - BossCastle // Большой замок с боссом в центре карты + BossCastle, // Большой замок с боссом в центре карты + Camp // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова } [Serializable] @@ -31,6 +32,7 @@ namespace Bogatyri.World public bool canBeAttacked; public int garrison; // солдат в обороне + public int level; // уровень лагеря (1..3), у остальных точек 0 public List rewards = new List(); } @@ -54,6 +56,22 @@ namespace Bogatyri.World const int MinCitySpacing = 3600; // минимальное расстояние между городами const int MaxPlacementAttempts = 1000; + // Лагеря разбойников (PvE). Ставятся отдельным генератором после городов -- карта городов не изменилась. + public const int CampCount = 12; + public const int CampLevels = 3; + const int CampSpacing = 2600; // лагерь может стоять ближе к городу, чем город к городу + const int CampSeedSalt = 0x5A17C0DE; + + // Требует балансировки. Небольшие числа -- по концепции экономики. + static readonly string[] CampNames = { "Стан разбойников", "Логово лиходеев", "Становище Соловья-разбойника" }; + static readonly int[] CampGarrison = { 6, 14, 26 }; // + 0..3 случайно + static readonly int[][] CampRewards = // еда, дерево, золото, металл + { + new[] { 15, 15, 0, 0 }, + new[] { 25, 25, 8, 0 }, + new[] { 40, 40, 15, 8 } + }; + // Пока соседи -- заглушки с именами. Потом это будут ники реальных игроков. static readonly string[] NeighborNames = { @@ -128,11 +146,45 @@ namespace Bogatyri.World nodes.Add(city); } + AddCamps(nodes, new DeterministicRandom(seed ^ CampSeedSalt), CampCount); return nodes; } - // Случайная точка, не слишком близко к замку и к другим городам - static bool TryFindFreeSpot(DeterministicRandom rng, List nodes, out int x, out int y) + static void AddCamps(List nodes, DeterministicRandom rng, int count) + { + string[] rewardTypes = { "Food", "Wood", "Gold", "Iron" }; + for (int i = 0; i < count; i++) + { + if (!TryFindFreeSpot(rng, nodes, out int x, out int y, CampSpacing)) break; + + int level = i % CampLevels + 1; // поровну лагерей каждого уровня + var camp = new MapNode + { + id = $"camp_{i + 1}", + displayName = CampNames[level - 1], + type = MapNodeType.Camp, + x = x, + y = y, + canBeAttacked = true, + level = level, + garrison = CampGarrison[level - 1] + rng.Range(0, 4) + }; + int[] amounts = CampRewards[level - 1]; + for (int r = 0; r < amounts.Length; r++) + if (amounts[r] > 0) camp.rewards.Add(new RewardEntry { resource = rewardTypes[r], amount = amounts[r] }); + + nodes.Add(camp); + } + } + + // Защитники за стенами: замок и города. Лагерь -- бой в поле. + public static bool IsFortified(MapNode node) + { + return node != null && (node.type == MapNodeType.BossCastle || node.type == MapNodeType.NeighborCity); + } + + // Случайная точка, не слишком близко к замку и к другим точкам + static bool TryFindFreeSpot(DeterministicRandom rng, List nodes, out int x, out int y, int spacing = MinCitySpacing) { for (int attempt = 0; attempt < MaxPlacementAttempts; attempt++) { @@ -142,7 +194,7 @@ namespace Bogatyri.World bool free = true; foreach (var n in nodes) { - int minDistance = n.type == MapNodeType.BossCastle ? CastleClearRadius : MinCitySpacing; + int minDistance = n.type == MapNodeType.BossCastle ? CastleClearRadius : spacing; if (DistanceSquared(n.x, n.y, x, y) < (long)minDistance * minDistance) { free = false; @@ -157,6 +209,47 @@ namespace Bogatyri.World return false; } + // Состав гарнизона по типам войск (TroopType). Вычисляется из id цели, а не из генератора карты: + // так карта (координаты, гарнизоны) осталась прежней, а состав всегда одинаков у клиента и сервера. + public static int[] GarrisonUnits(MapNode node) + { + var units = new int[TroopType.Count]; + if (node == null || node.garrison <= 0) return units; + + int[] percent; + if (node.type == MapNodeType.BossCastle) + { + percent = new[] { 40, 30, 20, 10 }; // Змей Горыныч держит всех понемногу + } + else + { + uint h = StableHash(node.id); + int infantry = 30 + (int)(h % 31); // 30..60% + int archers = 10 + (int)(h / 31 % 26); // 10..35% + int cavalry = 5 + (int)(h / 806 % 21); // 5..25% + int siege = Math.Max(0, 100 - infantry - archers - cavalry); + if (node.type == MapNodeType.Camp) { infantry += siege; siege = 0; } // у разбойников нет осадных машин + percent = new[] { infantry, archers, cavalry, siege }; + } + + int given = 0; + for (int i = 0; i < TroopType.Count; i++) + { + units[i] = node.garrison * percent[i] / 100; + given += units[i]; + } + units[TroopType.Infantry] += node.garrison - given; // остаток округления -- пехоте + return units; + } + + // FNV-1a: одинаков на любой платформе (string.GetHashCode в .NET меняется между запусками) + static uint StableHash(string s) + { + uint hash = 2166136261; + foreach (char c in s ?? "") hash = (hash ^ c) * 16777619; + return hash; + } + public static MapNode Find(List nodes, string id) { foreach (var n in nodes) diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 46011df..de645fb 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -23,8 +23,11 @@ namespace Bogatyri.World public int soldiers; // сколько ушло public int survivors; // сколько возвращается (известно после боя) public bool victory; - public int defenders; // гарнизон цели в момент боя + public bool targetEmpty; // лагерь уже был разбит, когда войско пришло: боя нет, все возвращаются + public int defenders; // гарнизон цели в момент боя public int defenderLosses; // сколько потерял защитник + public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой + public int[] survivorUnits; // выжившие по типам (известно после боя) public List loot = new List(); public long departMs; @@ -45,6 +48,20 @@ namespace Bogatyri.World } } + public static class CampRules + { + // Разбитый лагерь появляется снова через 30 минут (требует балансировки) + public const long RespawnMs = 30L * 60 * 1000; + } + + // Разбитый лагерь: до какого момента его нет на карте. Хранится в сохранении мира. + [Serializable] + public class CampState + { + public string id; + public long respawnMs; + } + public enum WorldEventType { BattleResolved, // войско дошло, бой прошёл @@ -64,6 +81,8 @@ namespace Bogatyri.World private readonly List nodes; private readonly IBattleSystem battleSystem; private readonly List marches = new List(); + private readonly Dictionary campRespawn = new Dictionary(); // id лагеря -> когда появится + private readonly List arrivals = new List(); private int nextMarchId = 1; public IReadOnlyList Nodes => nodes; @@ -97,6 +116,36 @@ namespace Bogatyri.World } } + // ---------------- Лагеря ---------------- + + public bool IsCampCleared(string campId, long nowMs) + { + return campRespawn.TryGetValue(campId, out long at) && nowMs < at; + } + + // Сколько миллисекунд до появления лагеря (0 -- лагерь на месте) + public long CampRespawnInMs(string campId, long nowMs) + { + return campRespawn.TryGetValue(campId, out long at) && nowMs < at ? at - nowMs : 0; + } + + public List GetCampStates() + { + var list = new List(); + foreach (var c in campRespawn) list.Add(new CampState { id = c.Key, respawnMs = c.Value }); + return list; + } + + public void LoadCampStates(List saved) + { + campRespawn.Clear(); + if (saved == null) return; + foreach (var c in saved) + if (c != null && GetNode(c.id) != null) campRespawn[c.id] = c.respawnMs; + } + + // ---------------- Походы ---------------- + public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error) { MapNode from = GetNode(fromId); @@ -104,6 +153,11 @@ namespace Bogatyri.World if (from == null || target == null) { error = "Неизвестная точка на карте"; return null; } if (target == from || !target.canBeAttacked) { error = $"На {target.displayName} нападать нельзя"; return null; } + if (target.type == MapNodeType.Camp && IsCampCleared(targetId, nowMs)) + { + error = $"{target.displayName} уже разбит. Появится снова через {(CampRespawnInMs(targetId, nowMs) + 59_999) / 60_000} мин"; + return null; + } if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; } var march = new MarchData @@ -124,18 +178,33 @@ namespace Bogatyri.World return march; } + // Откат только что созданного похода: войска для него не удалось зарезервировать. + // Отменить можно только поход, который ещё не дошёл до цели (боя не было). + public bool CancelMarch(int marchId) + { + MarchData m = marches.Find(x => x.id == marchId); + if (m == null || m.state != MarchState.Outbound) return false; + marches.Remove(m); + return true; + } + // Продвинуть мир до момента nowMs. Если игра была закрыта долго, за один вызов поход // может и дойти, и вернуться -- события придут в правильном порядке. public void Advance(long nowMs, List events) { + // Бои -- в порядке прихода войск: кто первым дошёл до лагеря, тот его и разбил + arrivals.Clear(); + foreach (var m in marches) + if (m.state == MarchState.Outbound && nowMs >= m.arriveMs) arrivals.Add(m); + arrivals.Sort((a, b) => a.arriveMs != b.arriveMs ? a.arriveMs.CompareTo(b.arriveMs) : a.id.CompareTo(b.id)); + foreach (var m in arrivals) + { + ResolveBattle(m); + events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m }); + } + foreach (var m in marches) { - if (m.state == MarchState.Outbound && nowMs >= m.arriveMs) - { - ResolveBattle(m); - events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m }); - } - if (m.state == MarchState.Returning && nowMs >= m.returnMs) { m.state = MarchState.Finished; @@ -144,30 +213,69 @@ namespace Bogatyri.World } marches.RemoveAll(m => m.state == MarchState.Finished); + + // Лагеря, которые уже появились снова, больше не нужно помнить + expiredCamps.Clear(); + foreach (var c in campRespawn) if (nowMs >= c.Value) expiredCamps.Add(c.Key); + foreach (var id in expiredCamps) campRespawn.Remove(id); } + private readonly List expiredCamps = new List(); + void ResolveBattle(MarchData m) { MapNode from = GetNode(m.fromNodeId); MapNode target = GetNode(m.targetNodeId); - BattleOutcome outcome = battleSystem.Resolve(new BattleRequest { Attackers = m.soldiers, Defenders = target.garrison }); + // Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна + m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from); + m.state = MarchState.Returning; + m.loot = new List(); + + // Лагерь уже разбит (другим войском): боя нет, все идут домой + if (target.type == MapNodeType.Camp && IsCampCleared(target.id, m.arriveMs)) + { + m.targetEmpty = true; + m.victory = false; + m.survivors = m.soldiers; + m.survivorUnits = HasUnits(m) ? (int[])m.units.Clone() : null; + m.defenders = 0; + m.defenderLosses = 0; + return; + } + + BattleOutcome outcome = battleSystem.Resolve(new BattleRequest + { + AttackerUnits = HasUnits(m) ? m.units : null, + Attackers = m.soldiers, + DefenderUnits = WorldMapLayout.GarrisonUnits(target), + Defenders = target.garrison, + DefenderFortified = WorldMapLayout.IsFortified(target) // замок и города -- за стенами, лагерь -- в поле + }); m.victory = outcome.Victory; m.survivors = outcome.Survivors; + m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null; m.defenders = target.garrison; m.defenderLosses = outcome.DefenderLosses; // Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками, // их потери будет списывать сервер из армии того игрока. - m.loot = new List(); if (outcome.Victory) { foreach (var r in target.rewards) m.loot.Add(new RewardEntry { resource = r.resource, amount = r.amount }); - } - // Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна - m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from); - m.state = MarchState.Returning; + // Разбитый лагерь пропадает с карты и появляется снова -- отсчёт от момента боя + if (target.type == MapNodeType.Camp) campRespawn[target.id] = m.arriveMs + CampRules.RespawnMs; + } + } + + // Состав похода известен и сходится с числом воинов (у старых походов его нет) + static bool HasUnits(MarchData m) + { + if (m.units == null || m.units.Length != TroopType.Count) return false; + int n = 0; + foreach (var u in m.units) n += u; + return n == m.soldiers && n > 0; } // Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка. diff --git a/Assets/Scripts/UI/MarchReturnPopup.cs b/Assets/Scripts/UI/MarchReturnPopup.cs index df15a34..5820030 100644 --- a/Assets/Scripts/UI/MarchReturnPopup.cs +++ b/Assets/Scripts/UI/MarchReturnPopup.cs @@ -32,7 +32,8 @@ public static class MarchReturnPopup MarchReturnedEvent e = queue.Dequeue(); string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : $"Поход на «{e.TargetName}»"; - string result = e.Victory ? "победа" : "поражение"; + string result = e.TargetEmpty ? "лагерь уже был разбит, боя не было" + : e.Victory ? "победа" : "поражение"; int lost = Mathf.Max(0, e.Sent - e.Survivors); string text = $"{target}: {result}\n" + diff --git a/Assets/Scripts/UI/SafeArea.cs b/Assets/Scripts/UI/SafeArea.cs index b3c712f..ec6e933 100644 --- a/Assets/Scripts/UI/SafeArea.cs +++ b/Assets/Scripts/UI/SafeArea.cs @@ -2,13 +2,19 @@ using UnityEngine; // Подгоняет RectTransform под безопасную зону экрана (Screen.safeArea): вырез камеры, скруглённые углы, // полоска "домой" на iPhone. Следит за поворотом и сменой разрешения (и в Device Simulator). -// Вешать на растянутый на весь Canvas объект; всё, что внутри, не залезет под вырез. +// Вешать на растянутый объект; всё, что внутри, не залезет под вырез. +// - fitInsideParent = false: объект растянут на весь Canvas (HUD); +// - fitInsideParent = true: родитель не на весь экран (окно между верхней и нижней панелью) -- +// зона пересчитывается в координаты родителя, отступ появляется только там, где родитель заходит под вырез. [RequireComponent(typeof(RectTransform))] public class SafeArea : MonoBehaviour { + public bool fitInsideParent; + private RectTransform rect; private Rect appliedArea; private Vector2Int appliedScreen; + private Rect appliedParent; void Awake() { @@ -18,7 +24,8 @@ public class SafeArea : MonoBehaviour void Update() { - if (Screen.safeArea != appliedArea || Screen.width != appliedScreen.x || Screen.height != appliedScreen.y) + if (Screen.safeArea != appliedArea || Screen.width != appliedScreen.x || Screen.height != appliedScreen.y + || (fitInsideParent && ParentScreenRect() != appliedParent)) Apply(); } @@ -29,10 +36,41 @@ public class SafeArea : MonoBehaviour appliedScreen = new Vector2Int(Screen.width, Screen.height); if (Screen.width <= 0 || Screen.height <= 0) return; - // Якоря в долях экрана: Canvas в режиме Screen Space Overlay покрывает экран целиком - rect.anchorMin = new Vector2(area.xMin / Screen.width, area.yMin / Screen.height); - rect.anchorMax = new Vector2(area.xMax / Screen.width, area.yMax / Screen.height); + if (!fitInsideParent) + { + // Якоря в долях экрана: Canvas в режиме Screen Space Overlay покрывает экран целиком + rect.anchorMin = new Vector2(area.xMin / Screen.width, area.yMin / Screen.height); + rect.anchorMax = new Vector2(area.xMax / Screen.width, area.yMax / Screen.height); + } + else + { + Rect parent = ParentScreenRect(); + appliedParent = parent; + if (parent.width <= 0 || parent.height <= 0) return; + + // Пересечение родителя с безопасной зоной -- в долях родителя + float xMin = Mathf.Max(parent.xMin, area.xMin), xMax = Mathf.Min(parent.xMax, area.xMax); + float yMin = Mathf.Max(parent.yMin, area.yMin), yMax = Mathf.Min(parent.yMax, area.yMax); + if (xMax <= xMin || yMax <= yMin) return; + rect.anchorMin = new Vector2((xMin - parent.xMin) / parent.width, (yMin - parent.yMin) / parent.height); + rect.anchorMax = new Vector2((xMax - parent.xMin) / parent.width, (yMax - parent.yMin) / parent.height); + } rect.offsetMin = Vector2.zero; rect.offsetMax = Vector2.zero; } + + // Прямоугольник родителя в пикселях экрана + Rect ParentScreenRect() + { + var parent = transform.parent as RectTransform; + if (parent == null) return new Rect(0, 0, Screen.width, Screen.height); + + var corners = new Vector3[4]; + parent.GetWorldCorners(corners); + Canvas canvas = parent.GetComponentInParent(); + Camera cam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay ? canvas.worldCamera : null; + Vector2 min = RectTransformUtility.WorldToScreenPoint(cam, corners[0]); + Vector2 max = RectTransformUtility.WorldToScreenPoint(cam, corners[2]); + return Rect.MinMaxRect(min.x, min.y, max.x, max.y); + } } diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index e71ea19..e268b4b 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -44,15 +44,18 @@ public class WorldMapController : MonoBehaviour public TMP_Text popupBodyText; public Button popupOkButton; - const string DefaultHint = "Нажми на замок или соседний город, чтобы напасть. Карту можно двигать и приближать"; + const string DefaultHint = "Нажми на замок, соседний город или лагерь разбойников, чтобы напасть. Карту можно двигать и приближать"; const float CastleSize = 2.2f; const float CitySize = 1.1f; + const float CampSize = 0.8f; const float StartZoom = 8f; static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f); static readonly Color CastleColor = new Color(0.55f, 0.15f, 0.15f); static readonly Color PlayerCityColor = new Color(0.25f, 0.5f, 0.95f); static readonly Color NeighborCityColor = new Color(0.75f, 0.55f, 0.3f); + static readonly Color CampColor = new Color(0.45f, 0.2f, 0.45f); + static readonly Color CampClearedColor = new Color(0.35f, 0.35f, 0.35f, 0.6f); private Transform mapRoot; private readonly Dictionary marchViews = new Dictionary(); @@ -60,6 +63,10 @@ public class WorldMapController : MonoBehaviour private readonly List staleMarchIds = new List(); private MapNode selectedTarget; + // Лагеря меняют вид: разбит -- серый, с таймером до появления + private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>(); + private float nextCampRefresh; + WorldSimulation World => MarchManager.Instance.World; void Start() @@ -107,8 +114,12 @@ public class WorldMapController : MonoBehaviour { HandleMapTap(); SyncMarchViews(); + RefreshCamps(); - soldiersText.text = $"Войска дома: {GetSoldiersAtHome()} В походе: {World.Marches.Count}"; + // Воины, а не отряды: "В походе: 1" читалось как один воин + int inMarches = 0, garrison = 0; + if (GameServices.TryGet(out IArmy army)) { inMarches = army.TroopsInMarches; garrison = army.GarrisonTotal; } + soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {World.Marches.Count})"; } void HookUpComingSoon(Button button, string sectionName) @@ -149,6 +160,13 @@ public class WorldMapController : MonoBehaviour CreateSprite(node.id, PrimitiveSprites.Square, NeighborCityColor, pos, Vector3.one * CitySize, 0); CreateLabel($"{node.displayName}\nГарнизон: {node.garrison}", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f); break; + + case MapNodeType.Camp: + // Круг, а не квадрат: лагерь не путается с городом + var sprite = CreateSprite(node.id, PrimitiveSprites.Circle, CampColor, pos, Vector3.one * CampSize, 0); + var label = CreateLabel("", pos - new Vector3(0, CampSize / 2f + 0.5f, 0), 2.3f); + campViews.Add((node, sprite, label)); + break; } } } @@ -187,7 +205,7 @@ public class WorldMapController : MonoBehaviour return sr; } - void CreateLabel(string text, Vector3 position, float fontSize) + TextMeshPro CreateLabel(string text, Vector3 position, float fontSize) { var go = new GameObject("Label"); go.transform.SetParent(mapRoot, false); @@ -198,6 +216,29 @@ public class WorldMapController : MonoBehaviour tmp.alignment = TextAlignmentOptions.Center; tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f); tmp.sortingOrder = 10; + return tmp; + } + + void RefreshCamps() + { + if (Time.unscaledTime < nextCampRefresh) return; + nextCampRefresh = Time.unscaledTime + 1f; + + long now = MarchManager.NowMs; + foreach (var (node, sprite, label) in campViews) + { + long left = World.CampRespawnInMs(node.id, now); + sprite.color = left > 0 ? CampClearedColor : CampColor; + label.text = left > 0 + ? $"{node.displayName}\nразбит, вернётся через {FormatMinutes(left)}" + : $"{node.displayName}, ур. {node.level}\nГарнизон: {node.garrison}"; + } + } + + static string FormatMinutes(long ms) + { + long sec = (ms + 999) / 1000; + return sec >= 60 ? $"{(sec + 59) / 60} мин" : $"{sec} сек"; } public static Vector3 ToWorld(MapNode node) @@ -217,6 +258,8 @@ public class WorldMapController : MonoBehaviour if (node == null) return; if (node.type == MapNodeType.PlayerCity) hintText.text = "Это твой город. Нажми НАЗАД, чтобы вернуться в него"; + else if (node.type == MapNodeType.Camp && World.IsCampCleared(node.id, MarchManager.NowMs)) + hintText.text = $"{node.displayName} уже разбит. Появится снова через {FormatMinutes(World.CampRespawnInMs(node.id, MarchManager.NowMs))}"; else if (node.canBeAttacked) OpenAttackPanel(node); } @@ -225,7 +268,8 @@ public class WorldMapController : MonoBehaviour foreach (var node in World.Nodes) { // Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем - float halfSize = (node.type == MapNodeType.BossCastle ? CastleSize : CitySize) / 2f + 0.2f; + float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize : CitySize; + float halfSize = size / 2f + 0.2f; Vector3 pos = ToWorld(node); if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node; } @@ -241,11 +285,15 @@ public class WorldMapController : MonoBehaviour MapNode home = World.GetNode(WorldMapLayout.PlayerCityId); int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f); - attackTitleText.text = target.type == MapNodeType.NeighborCity - ? $"Город игрока {target.displayName}" + attackTitleText.text = target.type == MapNodeType.NeighborCity ? $"Город игрока {target.displayName}" + : target.type == MapNodeType.Camp ? $"{target.displayName}, ур. {target.level}" : target.displayName; - attackInfoText.text = $"Гарнизон: {target.garrison} солдат\n" + - $"Путь в одну сторону: {travelSeconds} сек\n" + + attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает + attackInfoText.fontSizeMin = 18; + attackInfoText.fontSizeMax = 32; + attackInfoText.text = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})\n" + + $"Путь в одну сторону: {travelSeconds} сек" + + (WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") + $"Награда за победу: {MarchManager.FormatLoot(target.rewards)}"; soldiersSlider.minValue = available > 0 ? 1 : 0; @@ -262,9 +310,45 @@ public class WorldMapController : MonoBehaviour void UpdateSoldiersToSendText() { int available = GetSoldiersAtHome(); + // Две строки: сколько отправить и ожидаемый исход -- в одну строку оценка не влезала и обрезалась + soldiersToSendText.enableAutoSizing = true; + soldiersToSendText.fontSizeMin = 18; + soldiersToSendText.fontSizeMax = 32; + soldiersToSendText.overflowMode = TMPro.TextOverflowModes.Truncate; + var size = soldiersToSendText.rectTransform.sizeDelta; + if (size.y < 76) soldiersToSendText.rectTransform.sizeDelta = new Vector2(size.x, 76); soldiersToSendText.text = available > 0 - ? $"Отправить: {(int)soldiersSlider.value} из {available}" - : "Дома нет солдат"; + ? $"Отправить: {(int)soldiersSlider.value} из {available}\n{PreviewText((int)soldiersSlider.value)}" + : GameServices.TryGet(out IArmy army) && army.GarrisonTotal > 0 + ? "Свободных воинов нет: все в гарнизоне (окно «Войска» в городе)" + : "Дома нет солдат"; + } + + // Ожидаемый исход боя: тот же расчёт, что сделает модуль Боя, с составом, который заберёт Армия + string PreviewText(int soldiers) + { + if (selectedTarget == null || soldiers <= 0) return ""; + IArmy army = GameServices.Get(); + var troops = new int[TroopType.Count]; + for (int i = 0; i < TroopType.Count; i++) troops[i] = army.GetTroops(i) - army.GetGarrison(i); // гарнизон в поход не идёт + int[] units = ArmyMath.TakeProportional(troops, soldiers); + if (units == null) return ""; + + BattleOutcome o = GameServices.Get().Resolve(new BattleRequest + { + AttackerUnits = units, + DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget), + Defenders = selectedTarget.garrison, + DefenderFortified = WorldMapLayout.IsFortified(selectedTarget) + }); + string result = o.Victory ? "скорее победа" : "скорее поражение"; + return $"сила {o.AttackerPower / 100} против {o.DefenderPower / 100} -- {result}, потери ~{o.AttackerLosses}"; + } + + static string UnitsText(int[] units) + { + return $"пех. {units[TroopType.Infantry]}, луч. {units[TroopType.Archers]}, " + + $"кав. {units[TroopType.Cavalry]}, осад. {units[TroopType.Siege]}"; } void CloseAttackPanel() @@ -292,7 +376,7 @@ public class WorldMapController : MonoBehaviour static int GetSoldiersAtHome() { return ResourceManager.Instance != null - ? GameServices.Get().SoldiersAtHome // [объединение] наши воины всех типов + ? GameServices.Get().AvailableForMarch // свободные воины всех типов: гарнизон охраняет город : 0; } @@ -335,6 +419,11 @@ public class WorldMapController : MonoBehaviour void HandleBattleResolved(MarchData march) { MapNode target = World.GetNode(march.targetNodeId); + if (march.targetEmpty) + { + EnqueuePopup("ЛАГЕРЬ ПУСТ", $"{target.displayName} уже разбит\nБоя не было, домой идут все {march.survivors} воинов"); + return; + } string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ"; string body = $"{target.displayName}\n" + $"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" + diff --git a/Docs/Modules/Army.md b/Docs/Modules/Army.md index d6ec890..6811ed4 100644 --- a/Docs/Modules/Army.md +++ b/Docs/Modules/Army.md @@ -1,6 +1,6 @@ # Армия -Войска и их характеристики: сколько воинов каждого типа в городе, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.** +Войска и их характеристики: сколько воинов каждого типа в городе, сколько из них в гарнизоне, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.** - **Сборка:** `Bogatyri.Army` (без Unity). - **Папка:** `Assets/Scripts/Modules/Army`. @@ -12,12 +12,17 @@ | --- | --- | | `TypeCount` | Типов войск — 4 (пехота, лучники, кавалерия, осадные; порядок как в `GameData.Troops`). | | `GetTroops(type)` | Воинов этого типа в городе. | -| `SoldiersAtHome` | Всего воинов в городе. | +| `SoldiersAtHome` | Всего воинов в городе (вместе с гарнизоном). | | `TroopsInMarches` | Воинов в походах. | +| `GarrisonTotal`, `GetGarrison(type)` | Сколько воинов в гарнизоне — всего и по типам. | +| `AvailableForMarch` | Свободные воины: в городе минус гарнизон. Только их можно отправить в поход. | +| `SetGarrison(count)` | Назначить гарнизон: набирается по составу армии в городе (30 пехоты и 10 лучников, гарнизон 8 → 6 пехоты и 2 лучника). Не больше, чем воинов в городе; возвращает, сколько назначено. | | `AddTroops(type, count)` | Добавить воинов (обучение в Казармах). | | `LoseFraction(fraction)` | Потери в бою: от каждого типа погибает доля (с округлением вниз); возвращает, сколько погибло. | -| `TakeForMarch(marchId, count)` | Забрать воинов в поход пропорционально составу армии и запомнить состав. | -| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам. | +| `TakeForMarch(marchId, count)` | Забрать свободных воинов (без гарнизона) в поход пропорционально составу и запомнить состав. `false` — ничего не взято: не хватает воинов или поход с таким номером уже есть. | +| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам (пропорционально). `false` — поход неизвестен, никто не добавлен. | +| `GetMarchUnits(marchId)` | Состав похода по типам — для боя с типами войск. | +| `ReturnUnits(marchId, survivorUnits)` | Вернуть выживших точно по типам, как посчитал бой. Больше, чем ушло, не вернётся. `false` — поход неизвестен. | Получить: `GameServices.Get()`. Регистрирует `App/GameModules`. `GameProgress` (обучение, кампания, арена, сила армии) и панели работают с войсками только через этот контракт. @@ -25,9 +30,26 @@ Воин либо в городе, либо в походе. Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. Тест `ArmyService_MarchNeverCreatesOrLosesSoldiers` проверяет, что «дома + в походах» всегда равно общему числу. +**Возвращение — только по известному походу и только один раз** (ревью 27.10.2026, п. 2.1): + +- неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого — раньше такие выжившие зачислялись пехотой; +- после возвращения запись похода удаляется, второе возвращение того же похода возвращает `false`; +- вернуться может не больше, чем ушло; +- два похода с одним номером невозможны: `TakeForMarch` отклоняет повтор, загрузка и перенос старых данных пропускают дубликаты; +- перенос старых сохранений — отдельно, в `ImportLegacy`. + +**Гарнизон** — часть воинов города. Они по-прежнему в городе (`SoldiersAtHome`), но в поход не уходят: + +- 30 воинов, 10 в гарнизоне → 20 свободны, всего по-прежнему 30 (тест `ArmyService_Garrison_OneSoldierInOnePlace`); +- гарнизон по каждому типу никогда не больше воинов этого типа: после потерь (`LoseFraction`) он обрезается; +- новые воины из Казарм становятся свободными, гарнизон не меняется; +- назначается в окне «Войска» в городе, карточка «Гарнизон города»: кнопки 0, -10, -1, +1, +10, «Все». + +Пока гарнизон только держит воинов дома: на город игрока никто не нападает. Защита города, еда гарнизона и дебаффы при голоде — по плану ниже. + ## События -- **Публикует:** `TroopsChangedEvent` (воинов в городе и в походах) — при обучении, потерях, походе, возвращении, загрузке, сбросе. По нему `App/GameModules` перерисовывает панели HUD. +- **Публикует:** `TroopsChangedEvent` (воинов в городе, из них в гарнизоне, и в походах) — при обучении, потерях, походе, возвращении, загрузке, сбросе. По нему `App/GameModules` перерисовывает панели HUD. - **Слушает:** ничего. ## Сохранение @@ -36,10 +58,13 @@ ``` troops=<пехота>,<лучники>,<кавалерия>,<осадные> +garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (часть troops) march=:<пехота>,<лучники>,<кавалерия>,<осадные> ``` -Сброс (`ResetState`): войск и походов нет. +Старое сохранение без строки `garrison` читается: гарнизона нет. + +Сброс (`ResetState`): войск, гарнизона и походов нет. **Перенос из старых сохранений:** - Раньше войска лежали в `progress.json`: `troops` хранил `GameProgress`, `sashaMarchUnits` — мост `ArmyBridge`. @@ -62,13 +87,17 @@ march=:<пехота>,<лучники>,<кавалерия>,<о - общая армия = гарнизон + доступные отряды + войска в походах, на точках и в гарнизоне Круга, **один солдат не может быть в двух местах**; - гарнизон ест еду и участвует в защите; - при голоде дебаффы действуют и на гарнизон, и на походы. -- **Следующий шаг модуля:** гарнизон — третье место для солдата рядом с «дома» и «в походе». +- **Сделано 27.10.2026:** гарнизон города (`SetGarrison`, `AvailableForMarch`). +- **Дальше по гарнизону:** участие в защите города (когда на него смогут напасть — PvP), еда гарнизона (сейчас все воины едят одинаково, считает модуль Населения), дебаффы при голоде. Выбор состава по типам — если понадобится. - Описания типов войск (`GameData.Troops`: цена, сила, время обучения) пока в слое приложения; цены перейдут в `EconomyConfig`. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | По ревью 27.10.2026 (п. 2.1–2.2): `ReturnFromMarch` и `ReturnUnits` возвращают `bool` и зачисляют только по известному походу и только один раз; `TakeForMarch` отклоняет повторный номер похода; дубликаты походов в сохранении пропускаются. Тесты: неизвестный поход, повторное возвращение, повторный номер. | +| 27.10.2026 | Claude | Гарнизон города: `GarrisonTotal`, `GetGarrison`, `AvailableForMarch`, `SetGarrison`; в поход уходят только свободные; строка `garrison=` в сохранении; поле `Garrison` в `TroopsChangedEvent`; карточка «Гарнизон города» в окне «Войска». Тест «один солдат в одном месте» с гарнизоном. | +| 27.10.2026 | Claude | `GetMarchUnits` и `ReturnUnits`: бой с типами войск получает состав похода, выжившие возвращаются точно по типам. | | 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/Battle.md b/Docs/Modules/Battle.md index eb292e1..a56e96a 100644 --- a/Docs/Modules/Battle.md +++ b/Docs/Modules/Battle.md @@ -1,8 +1,8 @@ # Боевая система -Урон, потери, исход боя. Модуль только считает: кто победил и сколько погибло. Кто идёт в бой и что делать с итогом, решают другие модули. +Урон, потери, исход боя с учётом типов войск. Модуль только считает: кто победил и сколько погибло каждого типа. Кто идёт в бой и что делать с итогом, решают другие модули. -- **Сборка:** `Bogatyri.Battle` (без Unity — тот же код будет считать бои на сервере). +- **Сборка:** `Bogatyri.Battle` (без Unity и без float — тот же код будет считать бои на сервере). - **Папка:** `Assets/Scripts/Modules/Battle`. - **Зависит от:** ядра. @@ -10,19 +10,45 @@ `BattleOutcome Resolve(BattleRequest request)` -| `BattleRequest` | `BattleOutcome` | +| `BattleRequest` | Что это | | --- | --- | -| `Attackers` — атакующих | `Victory` — победил атакующий | -| `Defenders` — защитников | `AttackerLosses`, `DefenderLosses`, `Survivors` | +| `AttackerUnits`, `DefenderUnits` | Состав сторон по типам (`TroopType`: пехота, лучники, кавалерия, осадные). | +| `Attackers`, `Defenders` | Число воинов, если состав не задан (`null`) — тогда все считаются пехотой. | +| `DefenderFortified` | Защитники за стенами (город, замок). У лагерей разбойников стен нет — бой в поле. | +| `AttackerBonusPercent`, `DefenderBonusPercent` | Бонусы сторон в процентах: герой, технологии, голод (минус). | -Регистрирует `App/GameModules`. Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере. +| `BattleOutcome` | Что это | +| --- | --- | +| `Victory` | Победил атакующий. | +| `AttackerPower`, `DefenderPower` | Сила сторон ×100 (целые числа): с контрами, стенами и бонусами. | +| `AttackerLosses`, `DefenderLosses`, `Survivors` | Потери сторон и выжившие атакующие — всего. | +| `AttackerSurvivorUnits`, `DefenderLossUnits` | То же по типам войск. | -## Текущие правила +Регистрирует `App/GameModules`: силу воина каждого типа передаёт из `GameData.Troops` (4 / 5 / 9 / 16), чтобы числа были в одном месте. Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере. -- Побеждает тот, у кого больше солдат; ничья — победа защитника. -- Проигравший теряет половину своего войска, победитель — столько же. +## Правила -Пример: 30 против 20 — победа, погибает 10 атакующих и 10 защитников, домой идут 20. +Это **предложение**. Формула боя — открытый вопрос этапа 6 плана, команда должна её утвердить. Все числа — константы в начале `BattleSystem.cs`. + +| Правило | Значение | +| --- | --- | +| Сила стороны | Сумма по типам: воины × сила типа × (100% + контры + осадный бонус) × (100% + бонусы стороны). | +| Контры, +25% | Пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты. Бонус взвешивается по составу врага: против армии, где кавалерии 20%, пехота получает +5%. | +| Осадные | Против стен +50%, в поле −25%. | +| Стены | Защитники за стенами сильнее на 20%. | +| Исход | Побеждает тот, у кого сила больше; ничья — защитнику. | +| Потери проигравшего | Половина каждого типа. | +| Потери победителя | Половина × (сила проигравшего / сила победителя): чем ровнее бой, тем больше потерь, но не больше половины. | + +Примеры (замок Змея Горыныча, гарнизон 20: пехота 8, лучники 6, кавалерия 4, осадные 2, за стенами): + +| Атакующие | Сила | Исход | Вернутся | +| --- | --- | --- | --- | +| 30 пехоты | 126 против 155 | поражение | 15 | +| 40 пехоты | 168 против 155 | победа | 22 | +| 30 смешанных (12 / 8 / 6 / 4) | 248 против 154 | победа | — | + +Бой без состава (старые походы, `Attackers` и `Defenders` числами) считается так, будто все — пехота. Без стен 30 против 20 — по-прежнему победа и 20 вернувшихся. ## События @@ -34,11 +60,26 @@ ## Решения и план -- **Этап 6:** формула с типами войск — кто кого контрит и как делятся потери. Сначала записать правила в документ экономики. -- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. В `BattleRequest` для этого появятся модификаторы силы сторон; проценты — из `EconomyConfig`. +- **Командный план и ревью от 27.10.2026** (`Bogatyri_Plan_razrabotki.docx`, `Bogatyri_Battle_Army_Review_27-10-2026.docx`): случайность ±5% каждой стороне (воспроизводимая), прогноз риска вместо точного итога, потери от соотношения сил, раненые и погибшие, голод −50%, усталость −20%, числа — в настройки. Делается на этапе 2 плана. +- **Открыто: малые отряды и округление.** Потери округляются вниз по каждому типу, поэтому одиночный проигравший не гибнет никогда. Проверено 28.10.2026: + +| Бой | Исход | Потери атакующего | Потери защитника | +| --- | --- | --- | --- | +| 1 против 1 | ничья, защитнику | 0 | 0 | +| 1 против 10 | поражение | 0 | 0 | +| 10 против 1 | победа | 0 | 0 | + + Варианты: округлять потери проигравшего вверх; или воспроизводимая случайность для остатка (одиночка гибнет с шансом 50%). Решает команда, тест `Battle_EdgeCases_OutcomeAndArithmetic` правило не фиксирует. + +- **Утвердить формулу** (контры, стены, осадные, потери) или поправить числа в `BattleSystem.cs`. +- **Бонус героя** пока не передаётся в поход: `GameProgress.ArmyBonus` и сила героя в слое приложения. Нужен способ передать их в `AttackerBonusPercent` — например, через новый контракт или поле похода. +- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. Для этого есть `AttackerBonusPercent` / `DefenderBonusPercent` (минус), проценты — из `EconomyConfig`. Голод уже считает модуль Населения (`IsStarving`). +- **Гарнизоны соседей** пока не уменьшаются после боя: соседи — заглушки до сервера. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | Тест крайних случаев (ревью, п. 2.4): 1×1, 1×10, 10×1, 0×0, пустая сторона, смешанный состав, бонусы −100%, −500%, +100000%, бой числом. Проверяются исход и арифметика (нет отрицательных, выжившие + погибшие = ушедшие). Правило округления потерь — открытый вопрос. | +| 27.10.2026 | Claude | Бой с типами войск: сила по типам из `GameData.Troops`, контры (пехота > кавалерия > лучники > пехота, +25%), стены +20%, осадные +50% против стен и −25% в поле, потери по типам. `BattleRequest` и `BattleOutcome` расширены, старый бой «числом» считается пехотой. Тесты: контры, стены, осадные, потери в пределах состава. | | 19.10.2026 | Claude | Модуль выделен: `BattleResolver` из `WorldSimulation.cs` стал `BattleSystem` с контрактом `IBattleSystem`; `BattleOutcome` перенесён в ядро. Правила не изменились. | diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index 569cecb..65bab7e 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -15,6 +15,7 @@ | `Core/ResourceType.cs` | Перечисление ресурсов `ResourceType` и структура `ResourceAmount` (тип + количество). Числа enum не менять: они в сохранениях. | | `Core/LocalPlayer.cs` | Id и имя локального игрока (пока без аккаунтов). | | `Core/GameServices.cs` | Реестр модулей: `Register`, `Get`, `TryGet`, `Has`, `Unregister`. | +| `Core/WorkJob.cs` | Профессии рабочих `WorkJob` и `WorkJobs` (какой ресурс добывает профессия, русское название). Числа enum не менять: они в сохранениях. | | `Core/GameClock.cs` | Часы игры: `UnixNow` = реальное время + сдвиг. `Advance(секунд)` — перемотка для отладки, публикует `GameTimeAdvancedEvent`. Сдвиг не сохраняется. Модули берут время только отсюда. | | `Core/EventBus.cs` | Шина событий: `Subscribe`, `Unsubscribe`, `Publish`. Ошибка одного подписчика не мешает остальным (уходит в `OnHandlerError`). | | `Core/Contracts/ModuleContracts.cs` | Контракты всех модулей и `SaveRegistry` — см. таблицу ниже. | @@ -26,13 +27,14 @@ | Контракт | Реализует | Что умеет | | --- | --- | --- | -| `IEconomy` | Экономика (`ResourceManager`) | Узнать, добавить, потратить ресурс; проверить и списать цену целиком. | +| `IEconomy` | Экономика (`ResourceManager`) | Узнать, добавить, потратить ресурс; проверить и списать цену целиком; лимит склада. | +| `IProduction` | Экономика (`ProductionService`) | Места для рабочих по профессиям, эффективность, добыча в час; `Produce` — добыть за время. | | `ISettlement` | Поселение (`SettlementModule`) | Уровень Ратуши, число построек, открыт ли город. | | `ILeveledBuilding` | Здания с уровнем (`ResourceCollector`) | Уровень здания — его сохраняет Поселение. | -| `IPopulation` | Население (`PopulationService`) | Жители, рабочие, воины, предел жилья, расход еды и прирост в час, голод; `Tick` — досчитать до момента. | +| `IPopulation` | Население (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод; `Tick` — досчитать до момента. | | `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. | | `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. | -| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`. | +| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`: состав сторон по типам, стены, бонусы. | | `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. | | `ICircle` | Круг (`CircleService`) | Круг игрока, список Кругов; основать, вступить, выйти. | | `ISaveSystem` | Сохранения (`SaveManager`) | Есть ли сохранение; сохранить сейчас; стереть; сбросить всё (`ResetAll`). | @@ -56,6 +58,11 @@ | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | `IArmy.ReturnFromMarch` и `IArmy.ReturnUnits` возвращают `bool`: зачисление только по известному походу и один раз. `TakeForMarch` отклоняет повторный номер похода. | +| 27.10.2026 | Claude | `IArmy`: гарнизон (`GarrisonTotal`, `GetGarrison`, `AvailableForMarch`, `SetGarrison`); `TroopsChangedEvent.Garrison`. | +| 27.10.2026 | Claude | `MarchReturnedEvent.TargetEmpty`: войско пришло к уже разбитому лагерю. | +| 27.10.2026 | Claude | `TroopType` (типы войск), `BattleRequest` / `BattleOutcome` с составом по типам, силой сторон и бонусами; `IArmy.GetMarchUnits`, `IArmy.ReturnUnits`. | +| 27.10.2026 | Claude | `WorkJob`; контракт `IProduction`; `IEconomy.GetCapacity`; `IPopulation`: `Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`; событие `WorkersChangedEvent`. | | 26.10.2026 | Claude | `GameClock`; контракт `IPopulation`; события `GameTimeAdvancedEvent`, `PopulationChangedEvent`, `StarvationChangedEvent`, `NewGameStartedEvent`. | | 23.10.2026 | Claude | `IArmy` расширен (типы, `AddTroops`, `LoseFraction`, `TroopsInMarches`), событие `TroopsChangedEvent`. `GameLoadedEvent` публикуется и когда файла сохранения нет. | | 19.10.2026 | Claude | Контракт `ICircle` и типы `CircleInfo`, `CircleMember`, `CircleRole`; события `CircleJoinedEvent`, `CircleLeftEvent`; `IChat.CanWrite`. | diff --git a/Docs/Modules/Economy.md b/Docs/Modules/Economy.md index 28722e5..9533283 100644 --- a/Docs/Modules/Economy.md +++ b/Docs/Modules/Economy.md @@ -2,7 +2,9 @@ Ресурсы и производство: сколько у игрока дерева, еды, золота и остального, кто их добывает и тратит. -- **Сборка:** `Bogatyri.Economy`. +- **Сборки:** + - `Bogatyri.Economy` — склад ресурсов и здания-рабочие места (Unity); + - `Bogatyri.Economy.Rules` — правила добычи, чистый C# без Unity (папка `Rules`). - **Папка:** `Assets/Scripts/Modules/Economy`. - **Зависит от:** ядра. @@ -15,9 +17,41 @@ | `Add(type, amount)` | Добавить (или отнять отрицательным числом; в минус не уходит). | | `TrySpend(type, amount)` | Потратить, если хватает. | | `CanAfford(cost)` / `TrySpend(cost)` | Проверить и списать цену из нескольких ресурсов — всё или ничего. | +| `GetCapacity(type)` | Лимит склада; `float.PositiveInfinity` — без лимита (люди, сила). | Получить: `GameServices.Get()`. +## Контракт `IProduction` (добыча через рабочих, этап 3) + +| Метод | Что делает | +| --- | --- | +| `Slots(job)` | Мест для рабочих этой профессии во всех зданиях города. | +| `Efficiency(job)` | Множитель нормы от уровня зданий (1 — здания 1 уровня). | +| `RatePerHour(type)` | Добыча ресурса в час сейчас. | +| `Produce(hours)` | Добыть за `hours` часов. Вызывает модуль Населения в своём расчёте: сначала добыли, потом съели. | + +Получить: `GameServices.Get()`. + +## Ресурсы и склады (этап 1) + +- **Ресурсы:** дерево, еда, золото, камень, металл, синий кристалл. `Power` («Сила») — не ресурс, а вычисляемая мощь для рейтинга (`GameProgress.TotalPower`), лимита у неё нет. «Люди» — отражение модуля Населения. +- **Лимиты:** без построек — 1000 дерева, камня, металла и золота, 1500 еды. Каждый уровень **Склада** — +1000 к первым четырём, каждый уровень **Амбара** — +1500 к еде. Синий кристалл — отдельный небольшой лимит 50 (решение команды от 19.10). Все числа — `EconomyConfig`, раздел «Склады и лимиты», не утверждены. +- **Сверх лимита ресурс не прибавляется** — ни добыча, ни награды, лишнее пропадает. Если запас уже выше лимита (старое сохранение), он не урезается, просто больше не растёт. +- **Склад и Амбар** появляются в списке стройки кодом (`App/StorageBootstrap`, как Казармы), пока без своего арта — отличаются цветом. Компонент `App/StorageBuilding`. +- **Верхняя панель** (`ResourceUIText`): «запас / лимит» и строкой ниже прирост в час (у еды — добыча минус расход). Без сокращений 1.2K. У «Людей» — население / предел жилья и прирост людей. + +## Добыча через рабочих (этап 3) + +- **Ресурс производят люди, здание даёт места и эффективность.** Добыча в час = min(рабочие, места) × норма × эффективность. +- **Нормы T1 в час:** лесоруб — 2 дерева, фермер — 2 еды, каменотёс — 1.5 камня, шахтёр — 1 металла (Шахта) или 0.4 золота (Золотой рудник). +- **Здание 1 уровня — 30 мест.** Каждый следующий уровень: +10 мест и +10% к норме. Не утверждено. +- **Кликер убран.** `ResourceCollector` теперь здание-рабочее место (имя класса оставлено, чтобы не перенастраивать префабы). Тап по зданию — окно здания и рабочих (`App/BuildingWindow`): `−` / `+` на каждую профессию, «Свободно: N из M», кнопка «Улучшить». `ResourceProducer` (добыча по таймеру) выключен. +- **Дом** (здание «Население», `resourceType = Population`) — не рабочее место: каждый уровень +50 к пределу жилья. +- **Кристалл** — рабочих нет, синий кристалл добывается в разломах на карте мира (этап 6). +- **Какие здания стоят,** модулю сообщает `App/CitySync`: раз в полсекунды смотрит город и передаёт места, Дома и уровни Складов. Модуль запоминает места в сохранении, поэтому добыча идёт и на карте мира, и офлайн. +- **Новые места заполняются сами:** построили или улучшили здание — свободные рабочие встают туда. Старое сохранение при первом запуске — рабочие расставляются поровну по всем местам. +- **Проверка из документа** (тест `BalancedCity_…`): 45 фермеров, 25 лесорубов, 15 каменотёсов, 15 шахтёров → 90 еды, 50 дерева, 22.5 камня, 15 металла в час. + ## События - **Публикует:** `ResourceChangedEvent` — при любом изменении ресурса (тип и новое значение). @@ -27,7 +61,9 @@ ## Сохранение -Секция `"economy"`: `{ "resources": [ { "type", "amount" } ] }` — имена полей как в старом `save.json`. +Секция `"economy"`: `{ "resources": [ { "type", "amount" } ], "capacities": [ … ] }` — имена полей как в старом `save.json`, лимиты склада — новым списком. + +Секция `"production"` (`ProductionService`, простые строки): `slots=` — места по профессиям, `eff=` — эффективность, `pending=` — доли добычи меньше 1. Сброс (`ResetState`): стартовые значения из Inspector `ResourceManager` (`startWood` и т.д.). @@ -35,11 +71,20 @@ | Файл | Что это | | --- | --- | -| `ResourceManager.cs` | Хранилище ресурсов, реализует `IEconomy` и `ISaveParticipant`. Живёт между сценами. | -| `ResourceProducer.cs` | Здание, добывающее ресурс по таймеру. | -| `ResourceCollector.cs` | Временная добыча по клику. Реализует `ILeveledBuilding`. | +| `ResourceManager.cs` | Хранилище ресурсов и лимитов склада, реализует `IEconomy` и `ISaveParticipant`. Живёт между сценами. | +| `ResourceCollector.cs` | Здание-рабочее место (или Дом, Кристалл). Реализует `ILeveledBuilding`. Тап — событие `Tapped`. | +| `ResourceProducer.cs` | Устарело: выключен, оставлен для старых префабов. | +| `Rules/ProductionService.cs` | Добыча через рабочих: `ProductionRules`, `IProduction`, секция `"production"`. | -Интерфейс экономики остаётся в слое приложения: `Assets/Scripts/Economy/ResourceUIText.cs`, `BuildingUI.cs`. +Слой приложения: + +| Файл | Что это | +| --- | --- | +| `Economy/ResourceUIText.cs` | Ресурс на верхней панели: «запас / лимит» и прирост в час. | +| `Economy/BuildingUI.cs` | Полоска над зданием: рабочие / места. | +| `App/CitySync.cs` | Здания города → места, Дома, лимиты. | +| `App/BuildingWindow.cs` | Окно здания и рабочих. | +| `App/StorageBuilding.cs`, `App/StorageBootstrap.cs` | Склад и Амбар. | ## Таблица баланса `EconomyConfig` @@ -53,15 +98,16 @@ ## Решения и план -- **Этап 1 (план разработки):** лимиты Склада и Амбара; `Gems` → `BlueCrystal`; `Power` убрать из ресурсов. +- **Этап 1 (план разработки):** сделано 27.10.2026 — лимиты Склада и Амбара, «запас / лимит» и прирост на панели, без сокращений. `Gems` по-прежнему называется `Gems` (числа enum записаны в сохранениях), в интерфейсе — «Кристаллы». - **Решение команды от 19.10.2026:** у синего кристалла отдельный небольшой настраиваемый лимит хранения, не бесконечный. Переполнение — одно правило для наград и добычи. Число не утверждено. -- **Этап 3:** убрать кликер (`ResourceCollector`); производство = рабочие × норма × качество месторождения × бонус уровня. +- **Этап 3:** сделано 27.10.2026 — кликер убран, производство = рабочие × норма × эффективность уровня. Множитель качества месторождения добавится в этапе 4. - Все числа — из `EconomyConfig`, не в коде. Таблица создана 26.10.2026; производство, месторождения и цена солдата в ней уже есть, но используются начиная с этапов 3–5. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 27.10.2026 | Claude | Этапы 1 и 3: лимиты склада (`GetCapacity`, Склад, Амбар), «запас / лимит» и прирост на панели; добыча через рабочих (`Bogatyri.Economy.Rules`, `IProduction`), кликер убран, окно здания и рабочих, улучшение зданий. Исправлены лишние `ResourceUIText` «Люди» на ячейках панели. | | 26.10.2026 | Claude | Таблица баланса `EconomyConfig`. Стартовые ресурсы новой игры. Ресурс «Люди» отражает модуль Населения. | | 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений ресурсы возвращаются к стартовым. | | 19.10.2026 | Claude | Модуль выделен: `ResourceManager`, `ResourceProducer`, `ResourceCollector` перенесены в `Modules/Economy`. `ResourceManager` реализует `IEconomy`, публикует `ResourceChangedEvent`, сам сохраняет секцию `"economy"`. | diff --git a/Docs/Modules/Population.md b/Docs/Modules/Population.md index b62deab..651f997 100644 --- a/Docs/Modules/Population.md +++ b/Docs/Modules/Population.md @@ -9,11 +9,13 @@ ## Как устроено - **Люди города = жители + воины.** Жителей хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах. -- **Жители:** рабочие (`Workers`) и остальные (`Idle`). Распределение рабочих по зданиям — этап 3, пока рабочих 120. +- **Жители:** способные работать (`Workforce` — 120 из 270 в стартовом городе, доля сохраняется при росте) и остальные. Способные работать стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота) или свободны (`FreeWorkers`). +- **Рабочих ставят** окно здания (`SetWorkers`) и `App/CitySync` (новые места заполняются свободными сами). Больше, чем мест в зданиях (`IProduction.Slots`) и свободных, не поставить. - **Еда в час:** 0.1 на жителя, 0.2 на воина. Стартовый город (270 жителей + 30 воинов) ест 33 еды в час. - **Прирост:** +2.5 человека в час, только если еды хватило и население ниже предела жилья. - **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Что ещё происходит при голоде, команда пока не решила. -- **Предел жилья:** `HousingBase + HousingPerTownHallLevel × (уровень Ратуши − 1)`. Числа не утверждены. +- **Предел жилья:** `HousingBase + HousingPerTownHallLevel × (уровень Ратуши − 1) + HousingPerHouseLevel × уровни Домов` = 300 + 50 + 50. По решению команды от 19.10 стартовый город (300) упирается в предел: прирост пойдёт после постройки Дома. Прибавки не утверждены. +- **Добыча** идёт внутри расчёта населения: в каждом куске сначала `IProduction.Produce`, потом еда, потом прирост. Иначе офлайн город «голодал» бы, хотя фермы работали. - **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, расчёт просто продолжается с «сейчас». - **Еда списывается целыми единицами,** доли копятся в модуле: склад хранит `float`, и частые мелкие списания давали бы ошибку округления. @@ -22,7 +24,9 @@ | Член | Что это | | --- | --- | | `Total` | Все люди: жители + воины (в городе и в походах). | -| `Civilians`, `Workers`, `Idle` | Жители, из них рабочие и остальные. | +| `Civilians`, `Workers`, `Idle` | Жители, из них на работе и остальные. | +| `Workforce`, `FreeWorkers` | Сколько могут работать; сколько из них без работы. | +| `GetWorkers(job)`, `SetWorkers(job, n)` | Рабочие профессии; поставить n (в пределах мест и свободных). | | `Soldiers` | Воины — из модуля Армии. | | `HousingCap` | Предел населения. | | `FoodPerHour`, `GrowthPerHour` | Расход еды и прирост в час сейчас. | @@ -35,6 +39,7 @@ - **Публикует:** - `PopulationChangedEvent` — изменилось число людей; + - `WorkersChangedEvent` — переставили рабочих; - `StarvationChangedEvent` — голод начался или закончился. - **Слушает** (через `App/GameModules`): - `GameLoadedEvent` — офлайн-прогресс; @@ -43,7 +48,7 @@ ## Сохранение -Секция `"population"`, простые строки: `civilians=`, `workers=`, `growth=`, `food=`, `lastTick=`, `starving=`, `kit=`. +Секция `"population"`, простые строки: `civilians=`, `jobs=` (рабочие по профессиям), `jobsInit=`, `houses=`, `growth=`, `food=`, `lastTick=`, `starving=`, `kit=`. Сброс (`ResetState`): стартовый город, 270 жителей. Стартовое войско (30 пехоты) и стартовые ресурсы выдаёт `App/GameModules` после сброса и при первой игре (`NewGameStartedEvent`) — один раз (`kit=1`). Старым сохранениям без секции населения стартовый набор не выдаётся. @@ -80,12 +85,13 @@ ## Решения и план - **Голод:** дебаффы (в том числе у защитников) и правило снятия — решение команды от 19.10.2026, числа не утверждены. Модуль уже сообщает `StarvationChangedEvent`. -- **Предел жилья:** по решению команды растёт с постройкой Дома. Пока Дома нет — предел от уровня Ратуши. -- **Этап 3:** рабочие распределяются по зданиям, свободные — `Idle`. +- **Предел жилья:** по решению команды растёт с постройкой Дома — сделано (здание «Население»). +- **Этап 3:** сделано 27.10.2026 — рабочие по профессиям, Дома, добыча в общем расчёте. - **Этап 5:** найм воина забирает человека из жителей (гражданский резерв — решение от 19.10.2026). ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 27.10.2026 | Claude | Рабочие по профессиям (`Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`, автозаполнение), Дома в пределе жилья, базовый предел 300 (решение 19.10), добыча в общем расчёте. | | 26.10.2026 | Claude | Создан модуль: жители, еда, прирост, голод, предел жилья, офлайн-прогресс, секция `population`, тесты. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index fd43e5d..a8b74f3 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -17,7 +17,7 @@ | Модуль | Сборка | Папка | Документ | | --- | --- | --- | --- | | Игровое ядро | `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.Economy`, `Bogatyri.Economy.Rules` (без Unity) | `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.Population` (без Unity) | `Assets/Scripts/Modules/Population` | [Population.md](Population.md) | @@ -47,13 +47,13 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск: **Window → General → Test Runner → EditMode → Run All**. -Уже есть: Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей. +Уже есть: безопасная отправка и возвращение (неизвестный поход, повторное возвращение, повторный номер, откат похода), крайние случаи боя (1×1, 1×10, 0×0, предельные бонусы), гарнизон города (свободные и гарнизон, в поход только свободные, потери, сохранение), лагеря разбойников (расстановка, бой в поле, разбит и появляется снова, второе войско находит лагерь пустым, сохранение), бой с типами войск (контры, стены, осадные, потери в пределах состава), состав гарнизонов, Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей. Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026): - [x] лимит жилья: 300 жителей при лимите 300 — прирост стоит, предел вырос — прирост идёт (`PopulationTests`; пока предел растёт от Ратуши, Дома ещё нет); - [ ] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120; -- [ ] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (основа — тест «один солдат в одном месте» для походов уже есть); +- [x] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (`ArmyService_Garrison_OneSoldierInOnePlace`); - [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу; - [ ] предел синих кристаллов: кристаллы не копятся бесконечно, сверх лимита — по общему правилу. @@ -61,6 +61,12 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | Исправления по ревью 27.10.2026 (п. 2.1, 2.2, 2.4): воины не появляются из неизвестных и повторных походов, отправка с откатом, тесты крайних случаев боя. Обновлены Army, World, Battle, Core. | +| 27.10.2026 | Claude | Слой приложения: содержимое окон города (`BasePanel`) — в безопасной зоне, левый край больше не уходит под вырез камеры. `SafeArea.fitInsideParent` — для объектов не на весь экран. | +| 27.10.2026 | Claude | Гарнизон города в модуле Армии. Обновлены Army, Core, World. | +| 27.10.2026 | Claude | Лагеря разбойников (PvE) на карте мира. Обновлены World, Battle, Save, Core. | +| 27.10.2026 | Claude | Боевая система с типами войск (контры, стены, осадные, потери по типам), состав гарнизонов на карте, ожидаемый исход в окне атаки. Обновлены Battle, World, Army, Core. | +| 27.10.2026 | Claude | Этапы 1 и 3: склады и лимиты, добыча через рабочих, окно здания и рабочих, улучшение зданий, Склад и Амбар. Тесты `ProductionTests`. Обновлены Economy, Population, Core, Save. | | 26.10.2026 | Claude | Модуль Населения ([Population.md](Population.md)), таблица баланса `EconomyConfig` (`Assets/Resources/EconomyConfig.asset`), часы игры `GameClock`, отладочная панель «DEV». Обновлены Core, Economy, Save. | | 23.10.2026 | Claude | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе (`UI/MarchReturnPopup.cs`), обновлён World. | | 23.10.2026 | Claude | Войска переехали в модуль Армии (`ArmyService`, секция `army`), `ArmyBridge` удалён. Обновлены Army, Core, Save. | diff --git a/Docs/Modules/Save.md b/Docs/Modules/Save.md index 5149d29..6c69d82 100644 --- a/Docs/Modules/Save.md +++ b/Docs/Modules/Save.md @@ -34,10 +34,11 @@ SaveRegistry.Register(myModule); // при запуске модуля | --- | --- | | `economy` | Экономика | | `settlement` | Поселение | -| `world` | Глобальный мир | +| `world` | Глобальный мир (походы и разбитые лагеря) | | `circle` | Круг (простые строки, не JSON — модуль без Unity) | | `army` | Армия (простые строки: `troops=…`, `march=…`) | -| `population` | Население (простые строки: `civilians=…`, `lastTick=…` и др.) | +| `population` | Население (простые строки: `civilians=…`, `jobs=…`, `lastTick=…` и др.) | +| `production` | Добыча (простые строки: `slots=…`, `eff=…`, `pending=…`) | ## Формат файла @@ -67,9 +68,10 @@ SaveRegistry.Register(myModule); // при запуске модуля Что сбрасывает каждый модуль: -- **Экономика** — стартовые ресурсы из Inspector `ResourceManager`; +- **Экономика** — стартовые ресурсы из Inspector `ResourceManager` (лимиты склада пересчитает `App/CitySync` по зданиям); +- **Добыча** — мест нет (построек нет); - **Поселение** — Ратуша 1 уровня, постройки убраны, участки свободны; -- **Мир** — все походы отменены; +- **Мир** — все походы отменены, все лагеря разбойников на месте; - **Круг** — игрок ни в каком Круге, его Круги распущены; - **Армия** — войск и походов нет; - **Население** — стартовый город (270 жителей). После сброса `App/GameModules` выдаёт стартовое войско и ресурсы из `EconomyConfig`. @@ -104,6 +106,8 @@ SaveRegistry.Register(myModule); // при запуске модуля | Дата | Кто | Что | | --- | --- | --- | +| 27.10.2026 | Claude | Секция `world`: добавлен список разбитых лагерей `camps`. | +| 27.10.2026 | Claude | Новая секция `production`; в секции `economy` — список `capacities` (лимиты склада). | | 26.10.2026 | Claude | Новая секция `population` (модуль Населения). `GameBootstrap` публикует `NewGameStartedEvent`, если файла нет. | | 23.10.2026 | Claude | Новая секция `army` (модуль Армии). `GameLoadedEvent` публикуется и без файла сохранения. | | 19.10.2026 | Claude | Новая секция `circle` (модуль Круга). | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index a31971e..db6afda 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -1,6 +1,6 @@ # Глобальный мир -Карта и перемещения: замок с боссом, 20 соседних городов, походы по реальному времени (идут, даже когда игра закрыта). +Карта и перемещения: замок с боссом, 20 соседних городов, 12 лагерей разбойников, походы по реальному времени (идут, даже когда игра закрыта). - **Сборки:** - `Bogatyri.World.Simulation` — карта и походы, чистый C# без Unity (переедет на сервер); @@ -23,7 +23,7 @@ - `MarchStartedEvent` — войско выступило; - `MarchBattleResolvedEvent` — бой у цели прошёл; -- `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. Поля: `MarchId`, `Victory`, `Survivors`, `Sent`, `TargetName`, `LootText`. +- `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. Поля: `MarchId`, `Victory`, `Survivors`, `Sent`, `TargetEmpty` (лагерь уже был разбит, боя не было), `TargetName`, `LootText`. **Слушает:** ничего. @@ -36,9 +36,9 @@ ## Сохранение -Секция `"world"`: `{ "marches": [ MarchData ] }`. +Секция `"world"`: `{ "marches": [ MarchData ], "camps": [ { "id", "respawnMs" } ] }` — походы и разбитые лагеря с моментом их появления. Старые сохранения без `camps` читаются: все лагеря на месте. -Сброс (`ResetState`): все походы отменяются. Войска в походе не возвращаются: их сбрасывает `GameProgress` вместе с армией. +Сброс (`ResetState`): все походы отменяются, все лагеря на месте. Войска в походе не возвращаются: их сбрасывает `GameProgress` вместе с армией. Раньше походы лежали в `progress.json` (`sashaMarches`). При первом запуске `App/GameModules` переносит их в модуль Мира и удаляет из `progress.json` только после записи `save.json`. @@ -47,9 +47,34 @@ | Файл | Что это | | --- | --- | | `MarchManager.cs` | Ведёт походы, реализует `IWorld` и `ISaveParticipant`. Создаётся сам при запуске игры. | -| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей из seed. | +| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей и 12 лагерей из seed. | | `Simulation/WorldSimulation.cs` | Походы: старт, бой по прибытии, возвращение. Бой получает через `IBattleSystem`. | +**Бой с типами войск:** +- у похода есть состав по типам (`MarchData.units`, берётся у Армии при выступлении) и выжившие по типам (`survivorUnits`); +- у цели — состав гарнизона `WorldMapLayout.GarrisonUnits(node)`. Он вычисляется из id цели, а не из генератора карты, поэтому карта осталась прежней. У Змея Горыныча 40% пехоты, 30% лучников, 20% кавалерии, 10% осадных; у соседей пропорции свои у каждого; +- замок и города сражаются за стенами; +- выжившие возвращаются в Армию точно по типам (`IArmy.ReturnUnits`). Старые походы без состава — как раньше, по пропорции; +- окно атаки показывает состав гарнизона и ожидаемый исход: силу сторон, «скорее победа / поражение» и примерные потери. Расчёт тот же, что сделает модуль Боя. + +**Лагеря разбойников (PvE):** +- 12 лагерей, по 4 каждого уровня. Ставятся отдельным генератором после городов (`seed ^ CampSeedSalt`), поэтому замок, твой город и соседи остались на прежних местах. Лагерь может стоять ближе к городу (2,6 клетки), чем город к городу (3,6). + +| Уровень | Название | Гарнизон | Добыча | Нужно пехоты (примерно) | +| --- | --- | --- | --- | --- | +| 1 | Стан разбойников | 6–9 | 15 еды, 15 дерева | 8–12 | +| 2 | Логово лиходеев | 14–17 | 25 еды, 25 дерева, 8 золота | 18–21 | +| 3 | Становище Соловья-разбойника | 26–29 | 40 еды, 40 дерева, 15 золота, 8 металла | 34–37 | + +- бой в поле: стен нет (`WorldMapLayout.IsFortified`), у разбойников нет осадных — их доля уходит пехоте; +- после победы лагерь **разбит** на 30 минут (`CampRules.RespawnMs`, отсчёт от момента боя): на карте серый, с таймером, напасть нельзя; +- если к лагерю шли два войска, бьётся первое пришедшее (бои считаются по времени прихода). Второе находит лагерь пустым: боя нет, домой идут все, добычи нет (`MarchData.targetEmpty`, окно «ЛАГЕРЬ ПУСТ»); +- на карте лагерь — фиолетовый круг, подпись «название, ур. N, гарнизон». + +**Отправка целиком или никак** (ревью 27.10.2026, п. 2.2): `MarchManager.TryStartMarch` создаёт поход, затем просит Армию забрать воинов. Если Армия отказала, поход откатывается (`WorldSimulation.CancelMarch`) и игрок видит ошибку. Если при возвращении Армия не знает поход, ни воины, ни добыча не зачисляются (предупреждение в консоли). + +**Время** походов — по `GameClock`: кнопки «+1 час» / «+10 часов» в отладочной панели проматывают и походы. + В слое приложения остаются `WorldMap/WorldMapController.cs`, `MarchView.cs`, `MapCameraController.cs`, `SceneNavigator.cs`. **В игре одна карта мира** — сцена WorldMap. Открывается кнопкой «БОЙ» и кнопкой «МИР» в городе (`UIPanelManager.Open("World")` ведёт в эту сцену). @@ -59,11 +84,16 @@ - **Этап 0, решено 19.10.2026:** осталась одна карта мира — эта. Маленькая карта (панель «МИР», `Meta/World`) удалена. Перед удалением проверены ссылки в коде, сценах и префабах. Перенос сохранений: солдаты из её походов (`marches` в `progress.json`) возвращаются в город при загрузке — отправленные, а если отряд уже шёл назад, то выжившие. Добыча таких походов не переносится. - **Этап 4 (утверждён без изменений):** у позиций на карте — месторождения и их качество. - **Этап 6:** временные ресурсные точки и кристальные разломы; войска на точке недоступны дома. +- **Лагеря:** числа (гарнизон, добыча, 30 минут до появления) — предложение, требуют балансировки. Позже их можно перенести в `EconomyConfig`. В мультиплеере разбитый лагерь — общий для всех игроков, его состояние будет вести сервер. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | По ревью 27.10.2026: отправка с проверкой и откатом (`CancelMarch`), возвращение по неизвестному походу ничего не зачисляет. Тест отмены похода. | +| 27.10.2026 | Claude | В поход уходят только свободные воины: гарнизон остаётся в городе. Верхняя панель карты: «Свободно / Гарнизон / В походе». | +| 27.10.2026 | Claude | Лагеря разбойников (PvE): 12 лагерей трёх уровней, бой в поле, после победы лагерь разбит на 30 минут, второе войско находит его пустым. Секция `world` хранит разбитые лагеря, в `MarchReturnedEvent` добавлен `TargetEmpty`. Тесты: расстановка лагерей, разбит и появляется снова, сохранение. | +| 27.10.2026 | Claude | Бой с типами войск: состав похода и гарнизона, стены у замка и городов, выжившие по типам, ожидаемый исход в окне атаки. Время походов — по `GameClock` (перемотка в DEV проматывает походы). | | 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()`: при сбросе сохранений походы отменяются. |