From 1a54890a09fc75b2f9ca345a744b3d780cfad958 Mon Sep 17 00:00:00 2001 From: Valdemar Date: Sat, 31 Oct 2026 20:52:28 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=BA=D0=BD=D0=B0=20=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9,=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B8=20?= =?UTF-8?q?=D0=9A=D0=B0=D0=B7=D0=B0=D1=80=D0=BC=20=D0=B8=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=B2=D0=BE=D0=B9=D1=81?= =?UTF-8?q?=D0=BA=20=D0=BF=D0=BE=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D1=8E=20?= =?UTF-8?q?=D0=9A=D0=B0=D0=B7=D0=B0=D1=80=D0=BC;=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5:=20=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D1=8B,=20=D0=BD=D0=B0=D0=B9=D0=BC,=20=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Scripts/App/BuildingWindow.cs | 458 +++++++++++++++--- Assets/Scripts/App/CitySync.cs | 16 +- Assets/Scripts/App/EconomyConfig.cs | 3 + Assets/Scripts/App/GameModules.cs | 2 + Assets/Scripts/Economy/BuildingUI.cs | 25 +- Assets/Scripts/Meta/Build/Barracks.cs | 32 +- Assets/Scripts/Meta/Core/GameProgress.cs | 41 +- Assets/Scripts/Meta/Core/ProgressData.cs | 1 + Assets/Scripts/Meta/Panels/TroopsPanel.cs | 2 +- .../Modules/Core/Contracts/ModuleContracts.cs | 6 +- .../Modules/Population/PopulationService.cs | 57 ++- Assets/Scripts/Modules/Settlement/TownHall.cs | 19 +- .../Scripts/Modules/Tests/PopulationTests.cs | 118 +++++ .../Scripts/Modules/Tests/ProductionTests.cs | 30 +- Docs/Modules/Core.md | 1 + Docs/Modules/Economy.md | 4 +- Docs/Modules/Population.md | 6 +- Docs/Modules/README.md | 1 + Docs/Modules/Settlement.md | 2 + 19 files changed, 703 insertions(+), 121 deletions(-) diff --git a/Assets/Scripts/App/BuildingWindow.cs b/Assets/Scripts/App/BuildingWindow.cs index 5309966..6a36e66 100644 --- a/Assets/Scripts/App/BuildingWindow.cs +++ b/Assets/Scripts/App/BuildingWindow.cs @@ -1,22 +1,35 @@ +using System; +using System.Collections.Generic; using TMPro; using UnityEngine; +using UnityEngine.UI; -// Окно здания и рабочих (этап 3). Открывается тапом по зданию или кнопкой "Рабочие" в панели DEV. -// - сверху: что это за здание, его уровень и кнопка "Улучшить" (больше мест и эффективность / больше лимит / больше жилья); -// - ниже: все профессии с "−" и "+", сколько рабочих стоит, сколько мест и сколько добывается в час; -// - внизу: "Свободно: N из M". -// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль добычи. +// Окно здания. Открывается тапом по зданию (подписей над зданиями больше нет -- всё здесь). +// У каждого здания своё окно: +// - добывающее (Лесопилка, Ферма, Каменоломня, Шахта, Рудник): рабочие этой профессии и кнопки "−" / "+", +// добыча в час, сколько ресурса на складе и когда склад заполнится, что даст улучшение и его цена; +// - Склад и Амбар: запас / лимит по их ресурсам, что даст улучшение и цена; +// - Дом: сколько жилья даёт, население / предел, улучшение и цена; +// - Ратуша: жильё, резерв для найма, что откроется на следующем уровне, цена улучшения; +// - Кристалл: где добывается, запас / лимит; +// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска), +// кнопка "Нанять войска" (панель "Войска"). +// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям. +// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики. +// Пока окно открыто, цифры обновляются сами (раз в полсекунды). public static class BuildingWindow { static GameObject root; - static GameObject focus; // здание, по которому тапнули (null -- просто список рабочих) + static GameObject focus; // здание, по которому тапнули (null -- список рабочих) + static readonly List live = new List(); // что обновлять, пока окно открыто 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 OnTownHallTapped(TownHall townHall) { Open(townHall != null ? townHall.gameObject : null); } public static void Open(GameObject building) { - // В режиме стройки тап -- это выбор участка, а не здания + // В режиме стройки тап -- это выбор места, а не здания if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return; focus = building; Rebuild(); @@ -24,135 +37,440 @@ public static class BuildingWindow public static void Close() { - if (root != null) Object.Destroy(root); + if (root != null) UnityEngine.Object.Destroy(root); root = null; focus = null; + live.Clear(); } static void Rebuild() { - if (root != null) Object.Destroy(root); + if (root != null) UnityEngine.Object.Destroy(root); + live.Clear(); var card = UIKit.Modal(TitleFor(focus), out root); + root.AddComponent(); - if (focus != null) BuildingSection(card, focus); - WorkersSection(card); + var rc = focus != null ? focus.GetComponent() : null; + var storage = focus != null ? focus.GetComponent() : null; + var townHall = focus != null ? focus.GetComponent() : null; + var barracks = focus != null ? focus.GetComponent() : null; + + if (focus == null) WorkersList(card); + else if (townHall != null) TownHallSection(card, townHall); + else if (barracks != null) BarracksSection(card, barracks); + else if (rc != null && rc.IsWorkplace) WorkplaceSection(card, rc); + else if (rc != null && rc.IsHouse) HouseSection(card, rc); + else if (rc != null && rc.resourceType == ResourceType.Gems) CrystalSection(card); + else if (storage != null) StorageSection(card, storage); UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28); } + internal static void Tick() + { + if (root == null) return; + if (focus == null && FocusWasDestroyed) { Close(); return; } + foreach (var update in live) update(); + } + + static bool focusSet; + static bool FocusWasDestroyed => focusSet && focus == null; + static string TitleFor(GameObject building) { + focusSet = building != null; if (building == null) return "РАБОЧИЕ"; + var townHall = building.GetComponent(); + if (townHall != null) return $"РАТУША — {townHall.currentLevel} УР."; 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) + static void WorkplaceSection(RectTransform card, ResourceCollector rc) + { + if (!WorkJobs.TryFromResource(rc.resourceType, out WorkJob job)) return; + var production = GameModules.Production; + if (production == null || !GameServices.TryGet(out IPopulation population) || !GameServices.TryGet(out IEconomy economy)) + { + UIKit.Text(card, "Модули Населения и Экономики не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center); + return; + } + ResourceType res = WorkJobs.Resource(job); + string resName = GameData.ResName(res).ToLower(); + double norm = production.Rules.PerWorkerPerHour[(int)job]; + + // Что даёт это здание + int slots = production.BuildingSlots(rc.level); + double eff = production.BuildingEfficiency(rc.level); + UIKit.Text(card, $"Здание даёт {slots} мест, норма ×{eff:0.##} — до {slots * norm * eff:0.#} {resName} в час при полном штате.", + 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + + // Рабочие профессии и кнопки + Header(card, $"Рабочие: {WorkJobs.Name(job).ToLower()}"); + Live(card, () => $"{population.GetWorkers(job)} / {production.Slots(job)} мест в городе • свободных жителей: {population.FreeWorkers}", + 26, UIKit.Theme.Text); + var row = UIKit.Row(card, 72, 8); + StepButton(row, "−5", job, -5); + StepButton(row, "−1", job, -1); + StepButton(row, "+1", job, +1); + StepButton(row, "+5", job, +5); + + // Добыча и склад + Header(card, "Добыча и склад"); + Live(card, () => + { + float rate = production.RatePerHour(res); + string famine = population.IsStarving ? " голод: добыча ×0.5" : ""; + return $"Сейчас: +{rate:0.#} {resName} в час{famine}"; + }, 26, UIKit.Theme.Text); + StorageBar(card, economy, res); + Live(card, () => FillTimeText(economy, res, production.RatePerHour(res)), 22, UIKit.Theme.TextDim); + + // Улучшение + int nextSlots = production.BuildingSlots(rc.level + 1); + double nextEff = production.BuildingEfficiency(rc.level + 1); + UpgradeBlock(card, rc.level, + $"мест {slots} → {nextSlots}, норма ×{eff:0.##} → ×{nextEff:0.##}", + () => rc.Level = rc.level + 1); + + UIKit.Button(card, "Все рабочие", UIKit.Theme.Blue, () => Open(null), -1, 70, 24); + } + + // ---------------- Дом ---------------- + + static void HouseSection(RectTransform card, ResourceCollector house) + { + int perLevel = EconomyConfig.Load().population.housingPerHouseLevel; + UIKit.Text(card, $"Дом даёт +{perLevel * house.level} к пределу жилья (+{perLevel} за уровень).", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + if (GameServices.TryGet(out IPopulation population)) + { + Header(card, "Население"); + LiveBar(card, () => population.HousingCap > 0 ? (float)population.Total / population.HousingCap : 0f, + () => $"{population.Total} / {population.HousingCap} человек", UIKit.Theme.Blue); + Live(card, () => population.Total >= population.HousingCap + ? "Предел жилья: прирост стоит, нужны Дома или Ратуша выше" + : $"Прирост: +{population.GrowthPerHour:0.#} человека в час", 22, UIKit.Theme.TextDim); + } + UpgradeBlock(card, house.level, $"жильё +{perLevel * house.level} → +{perLevel * (house.level + 1)}", () => house.Level = house.level + 1); + } + + // ---------------- Кристалл ---------------- + + static void CrystalSection(RectTransform card) + { + UIKit.Text(card, "Синий кристалл добывается в кристальных разломах на карте мира. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + if (GameServices.TryGet(out IEconomy economy)) StorageBar(card, economy, ResourceType.Gems); + } + + // ---------------- Склад и Амбар ---------------- + + static void StorageSection(RectTransform card, StorageBuilding storage) + { + var s = EconomyConfig.Load().storage; + bool warehouse = storage.kind == StorageBuilding.Kind.Warehouse; + float perLevel = warehouse ? s.perWarehouseLevel : s.perGranaryLevel; + UIKit.Text(card, warehouse + ? $"Склад даёт +{perLevel * storage.level:0} к лимиту дерева, камня, металла и золота." + : $"Амбар даёт +{perLevel * storage.level:0} к лимиту еды.", + 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + + if (GameServices.TryGet(out IEconomy economy)) + { + Header(card, "Запас / лимит"); + if (warehouse) + { + StorageBar(card, economy, ResourceType.Wood); + StorageBar(card, economy, ResourceType.Stone); + StorageBar(card, economy, ResourceType.Iron); + StorageBar(card, economy, ResourceType.Gold); + } + else StorageBar(card, economy, ResourceType.Food); + UIKit.Text(card, "Сверх лимита ресурс не прибавляется — пропадает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + } + + UpgradeBlock(card, storage.level, $"лимит +{perLevel * storage.level:0} → +{perLevel * (storage.level + 1):0}", + () => storage.Level = storage.level + 1); + } + + // ---------------- Ратуша ---------------- + + static void TownHallSection(RectTransform card, TownHall townHall) { var config = EconomyConfig.Load(); - var rc = building.GetComponent(); - var storage = building.GetComponent(); - var leveled = building.GetComponent(); - string info; + int level = townHall.currentLevel; + int next = level + 1; + var p = config.population; - 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 = ""; + UIKit.Text(card, $"Ратуша даёт {p.housingBase + p.housingPerTownHallLevel * (level - 1)} мест жилья. От её уровня зависят резерв для найма и доступные здания и войска.", + 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); - if (info.Length > 0) UIKit.Text(card, info, 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + if (GameServices.TryGet(out IPopulation population)) + { + Header(card, "Город"); + LiveBar(card, () => population.HousingCap > 0 ? (float)population.Total / population.HousingCap : 0f, + () => $"Население {population.Total} / {population.HousingCap}", UIKit.Theme.Blue); + Live(card, () => $"Жителей {population.Civilians}: рабочих {population.Workers}, свободных {population.Idle} • воинов {population.Soldiers}", + 22, UIKit.Theme.TextDim); + Live(card, () => $"Резерв для найма: {population.CivilianReserve} • можно нанять: {population.Recruitable}", 22, UIKit.Theme.TextDim); + } - if (leveled == null || (rc != null && rc.resourceType == ResourceType.Gems)) return; - if (leveled.Level >= config.upgrade.maxLevel) + if (level >= townHall.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, () => + // Что даст следующий уровень + Header(card, $"Уровень {next}"); + var gains = new List { $"жильё +{p.housingPerTownHallLevel}" }; + int nowReserve = ReserveAt(level), nextReserve = ReserveAt(next); + if (nextReserve != nowReserve) gains.Add($"резерв для найма {nowReserve} → {nextReserve}"); + UIKit.Text(card, string.Join(", ", gains.ToArray()), 24, UIKit.Theme.Text, TextAlignmentOptions.Center); + string unlocks = UnlocksAt(next); + if (unlocks.Length > 0) UIKit.Text(card, "Откроется: " + unlocks, 24, UIKit.Theme.Gold, TextAlignmentOptions.Center); + + var cost = BuildingData.ToAmounts(townHall.GetUpgradeCost()); + UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center); + bool canPay = townHall.CanUpgrade(); + var b = UIKit.Button(card, $"Улучшить до {next} ур.", 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 (!townHall.CanUpgrade()) { UIKit.Toast("Не хватает ресурсов"); return; } + townHall.Upgrade(); if (CitySync.Instance != null) CitySync.Instance.SyncNow(); - UIKit.Toast($"Улучшено до {leveled.Level} уровня"); + UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня"); Rebuild(); }, -1, 80, 26); b.interactable = canPay; } + // ---------------- Казармы ---------------- + + static void BarracksSection(RectTransform card, Barracks barracks) + { + var gp = GameProgress.Instance; + UIKit.Text(card, $"Казармы обучают войска. Казарм в городе: {Barracks.Count}, мест в очереди: {Barracks.QueueSize}. " + + $"Новые войска открывает уровень самых прокачанных Казарм ({Barracks.MaxLevel} ур.) вместе с уровнем Ратуши.", + 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + + if (gp != null) + { + Header(card, "Обучение"); + Live(card, () => + { + var d = gp.Data; + if (d.training.Count == 0) return "Никто не обучается"; + var o = d.training[0]; + string more = d.training.Count > 1 ? $" (ещё заказов: {d.training.Count - 1})" : ""; + return $"{GameData.Troops[o.troopIndex].name} ×{o.count} — осталось {GameData.FormatTime(gp.TrainingSecondsLeft(o))}{more}"; + }, 24, UIKit.Theme.Text); + if (GameServices.TryGet(out IPopulation population)) + Live(card, () => $"Можно нанять из жителей: {population.Recruitable} • в Казармах: {population.InTraining}", 22, UIKit.Theme.TextDim); + + Header(card, "Войска"); + for (int i = 0; i < GameData.Troops.Count; i++) + { + string lockText = gp.TroopLockText(i); + UIKit.Text(card, lockText.Length == 0 + ? $"{GameData.Troops[i].name} — открыты" + : $"{GameData.Troops[i].name} — нужно: {lockText}", + 24, lockText.Length == 0 ? UIKit.Theme.Text : UIKit.Theme.Gold, TextAlignmentOptions.Center); + } + } + + // Что откроет следующий уровень этих Казарм + var opens = new List(); + if (gp != null) + for (int i = 0; i < GameData.Troops.Count; i++) + if (gp.RequiredBarracksLevel(i) == barracks.level + 1 && Barracks.MaxLevel <= barracks.level) opens.Add(GameData.Troops[i].name); + string effect = opens.Count > 0 + ? $"откроются: {string.Join(", ", opens.ToArray())} (если хватает уровня Ратуши)" + : "ближе к новым войскам"; + UpgradeBlock(card, barracks.level, effect, () => barracks.Level = barracks.level + 1); + + UIKit.Button(card, "Нанять войска", UIKit.Theme.Blue, () => + { + Close(); + if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops"); + }, -1, 80, 26); + } + + // Резерв для найма на уровне Ратуши (те же числа, что у модуля Населения) + static int ReserveAt(int townHallLevel) + { + var table = GameModules.Population != null ? GameModules.Population.Rules.CivilianReserveByTownHallLevel : null; + if (table == null || table.Length == 0) return 0; + return table[Mathf.Clamp(townHallLevel, 1, table.Length) - 1]; + } + + // Здания и войска, которые открываются ровно на этом уровне Ратуши + static string UnlocksAt(int townHallLevel) + { + var names = new List(); + if (BuildManager.Instance != null && BuildManager.Instance.allBuildings != null) + foreach (var b in BuildManager.Instance.allBuildings) + if (b != null && b.requiredTownHallLevel == townHallLevel) names.Add(b.buildingName); + foreach (var t in GameData.Troops) + if (t.requiredTownHall == townHallLevel) names.Add(t.name); + return string.Join(", ", names.ToArray()); + } + + // ---------------- Улучшение (общее для зданий с уровнем, кроме Ратуши) ---------------- + + static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel) + { + var config = EconomyConfig.Load(); + if (level >= config.upgrade.maxLevel) + { + UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center); + return; + } + Header(card, $"Улучшение до {level + 1} ур."); + UIKit.Text(card, effect, 24, UIKit.Theme.Text, TextAlignmentOptions.Center); + var cost = config.UpgradeCost(level); + UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center); + + bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost); + var b = UIKit.Button(card, $"Улучшить до {level + 1} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () => + { + if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; } + raiseLevel(); + if (CitySync.Instance != null) CitySync.Instance.SyncNow(); + UIKit.Toast($"Улучшено до {level + 1} уровня"); + Rebuild(); + }, -1, 80, 26); + b.interactable = canPay; + } + + // Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть static string CostText(ResourceAmount[] cost) { + GameServices.TryGet(out IEconomy economy); 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()}"; + for (int i = 0; i < cost.Length; i++) + { + float have = economy != null ? economy.Get(cost[i].Type) : 0f; + bool ok = have >= cost[i].Amount; + string name = GameData.ResName(cost[i].Type).ToLower(); + parts[i] = ok + ? $"{cost[i].Amount:0} {name}" + : $"{cost[i].Amount:0} {name} (есть {have:0})"; + } return string.Join(", ", parts); } - // ---------------- Рабочие по профессиям ---------------- + // ---------------- Склад: полоска и время до заполнения ---------------- - static void WorkersSection(RectTransform card) + static void StorageBar(RectTransform card, IEconomy economy, ResourceType res) + { + string name = GameData.ResName(res); + LiveBar(card, () => + { + float cap = economy.GetCapacity(res); + return float.IsInfinity(cap) || cap <= 0 ? 0f : economy.Get(res) / cap; + }, + () => + { + float cap = economy.GetCapacity(res); + return float.IsInfinity(cap) ? $"{name}: {economy.Get(res):0}" : $"{name}: {economy.Get(res):0} / {cap:0}"; + }, + new Color(0.85f, 0.65f, 0.25f)); + } + + static string FillTimeText(IEconomy economy, ResourceType res, float ratePerHour) + { + float cap = economy.GetCapacity(res), have = economy.Get(res); + if (float.IsInfinity(cap)) return ""; + if (have >= cap) return "Склад полон — добыча пропадает. Улучшите Склад или Амбар."; + if (ratePerHour <= 0) return "Добычи нет — поставьте рабочих"; + float hours = (cap - have) / ratePerHour; + return hours < 1f ? $"Склад заполнится через {Mathf.CeilToInt(hours * 60)} мин" : $"Склад заполнится через {hours:0.#} ч"; + } + + // ---------------- Список рабочих по всем профессиям ---------------- + + static void WorkersList(RectTransform card) { if (!GameServices.TryGet(out IPopulation pop) || !GameServices.TryGet(out IProduction prod)) { - UIKit.Text(card, "Модули Населения и добычи не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center); + 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); + var label = Live(row, () => + { + int slots = prod.Slots(job); + if (slots <= 0) return $"{WorkJobs.Name(job)}: нет зданий"; + return $"{WorkJobs.Name(job)}: {pop.GetWorkers(job)} / {slots} +{prod.RatePerHour(WorkJobs.Resource(job)):0.#}/ч"; + }, 24, UIKit.Theme.Text); label.enableAutoSizing = true; label.fontSizeMin = 14; label.fontSizeMax = 24; + label.alignment = TextAlignmentOptions.Left; 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); + StepButton(row, "−5", job, -5); + StepButton(row, "−1", job, -1); + StepButton(row, "+1", job, +1); + StepButton(row, "+5", job, +5); } - UIKit.Text(card, $"Свободно: {pop.FreeWorkers} из {pop.Workforce} рабочих • остальные жители: {Mathf.Max(0, pop.Civilians - pop.Workforce)}", - 24, UIKit.Theme.Gold, TextAlignmentOptions.Center); + Live(card, () => $"Свободных жителей: {pop.FreeWorkers} из {pop.Civilians}", 24, UIKit.Theme.Gold); } - static void StepButton(Transform row, string label, WorkJob job, int delta, bool enabled) + // Кнопка "−" / "+": активна, только если есть кого снять или куда и кого поставить + static void StepButton(Transform row, string label, WorkJob job, int delta) { - var b = UIKit.Button(row, label, delta > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => + Button b = null; + 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(); + Tick(); }, 86, 64, 24); - b.interactable = enabled; + live.Add(() => + { + if (b == null || !GameServices.TryGet(out IPopulation pop) || !GameServices.TryGet(out IProduction prod)) return; + int workers = pop.GetWorkers(job), slots = prod.Slots(job); + b.interactable = delta < 0 ? workers > 0 : slots > workers && pop.FreeWorkers > 0; + }); + } + + // ---------------- Мелочи ---------------- + + static void Header(RectTransform card, string text) + { + UIKit.Text(card, text, 28, UIKit.Theme.Gold, TextAlignmentOptions.Center).fontStyle = FontStyles.Bold; + } + + // Текст, который обновляется, пока окно открыто + static TextMeshProUGUI Live(Transform parent, Func text, float size, Color color) + { + var t = UIKit.Text(parent, text(), size, color, TextAlignmentOptions.Center); + live.Add(() => { if (t != null) t.text = text(); }); + return t; + } + + static void LiveBar(RectTransform card, Func fraction, Func label, Color color) + { + var bar = UIKit.Bar(card, fraction(), color, 34, label()); + live.Add(() => { if (bar != null) UIKit.SetBar(bar, fraction(), label()); }); + } +} + +// Обновляет открытое окно здания раз в полсекунды (добыча идёт, склад наполняется) +public class BuildingWindowTicker : MonoBehaviour +{ + float next; + + void Update() + { + if (Time.unscaledTime < next) return; + next = Time.unscaledTime + 0.5f; + BuildingWindow.Tick(); } } diff --git a/Assets/Scripts/App/CitySync.cs b/Assets/Scripts/App/CitySync.cs index ab7314c..c0250c4 100644 --- a/Assets/Scripts/App/CitySync.cs +++ b/Assets/Scripts/App/CitySync.cs @@ -117,21 +117,13 @@ public class CitySync : MonoBehaviour 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); + // Места для рабочих. Новые места (построили, улучшили) остаются пустыми, пока игрок сам + // не поставит рабочих (регламент 31.10). Мест стало меньше -- лишних рабочих снимаем. 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); - } + // Единственное автозаполнение -- стартовая расстановка, один раз: новая игра или первая загрузка старого сохранения + if (!population.JobsInitialized) population.AutoFillAll(); } } diff --git a/Assets/Scripts/App/EconomyConfig.cs b/Assets/Scripts/App/EconomyConfig.cs index 87cbd9e..9682259 100644 --- a/Assets/Scripts/App/EconomyConfig.cs +++ b/Assets/Scripts/App/EconomyConfig.cs @@ -118,6 +118,9 @@ public class EconomyConfig : ScriptableObject public float gold = 5f; [Tooltip("Время подготовки, секунд. Не утверждено")] public int trainSeconds = 60; + [Tooltip("Уровень Казарм, с которого открывается тип войск (порядок как в GameData.Troops: пехота, лучники, кавалерия, осадные). " + + "Нужен и уровень Ратуши. Считаются самые прокачанные Казармы. ТЕСТ, не утверждено")] + public int[] requiredBarracksLevel = { 1, 2, 3, 5 }; } [Serializable] diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index d96df7e..01d1e6e 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -50,6 +50,8 @@ public static class GameModules ResourceCollector.Tapped += BuildingWindow.OnWorkplaceTapped; StorageBuilding.Tapped -= BuildingWindow.OnStorageTapped; StorageBuilding.Tapped += BuildingWindow.OnStorageTapped; + TownHall.Tapped -= BuildingWindow.OnTownHallTapped; + TownHall.Tapped += BuildingWindow.OnTownHallTapped; EventBus.Subscribe(_ => population.OnGameLoaded(GameClock.UnixNow)); // офлайн-прогресс EventBus.Subscribe(e => population.Tick(e.UnixNow)); // перемотка (отладка) EventBus.Subscribe(_ => population.Changed()); // воины -- тоже люди diff --git a/Assets/Scripts/Economy/BuildingUI.cs b/Assets/Scripts/Economy/BuildingUI.cs index 6fdf34d..70aa9ca 100644 --- a/Assets/Scripts/Economy/BuildingUI.cs +++ b/Assets/Scripts/Economy/BuildingUI.cs @@ -2,9 +2,8 @@ using UnityEngine; using UnityEngine.UI; using TMPro; -// Полоска над зданием (этап 3): рабочие этой профессии / места в городе. -// Раньше показывала накопленный запас кликера -- кликер убран, добычу дают рабочие. -// У Дома и Кристалла рабочих нет -- там подпись уровня, полоска скрыта. +// Подпись над зданием -- больше не показывается: всё о здании теперь в его окне (App/BuildingWindow, тап по зданию). +// Компонент оставлен, чтобы не перенастраивать префабы: при запуске он прячет свою подпись и полоску и выключается. public class BuildingUI : MonoBehaviour { [Header("Настройки")] @@ -14,8 +13,16 @@ public class BuildingUI : MonoBehaviour float next; + public static bool ShowLabels = false; // вернуть подписи над зданиями -- поставить true + void Start() { + if (!ShowLabels) + { + HideLabel(); + enabled = false; + return; + } if (building == null) { Debug.LogError("Ошибка BuildingUI: не назначено здание (Building)!"); @@ -25,6 +32,18 @@ public class BuildingUI : MonoBehaviour UpdateUI(); } + // Прячем холст с подписью и полоской. Сам объект здания не трогаем: компонент висит на его корне + void HideLabel() + { + foreach (Component c in new Component[] { stokText, progressBar }) + { + if (c == null) continue; + var canvas = c.GetComponentInParent(true); + GameObject target = canvas != null && canvas.gameObject != gameObject ? canvas.gameObject : c.gameObject; + if (target != gameObject) target.SetActive(false); + } + } + void Update() { if (Time.unscaledTime < next) return; diff --git a/Assets/Scripts/Meta/Build/Barracks.cs b/Assets/Scripts/Meta/Build/Barracks.cs index eb34e61..1589dd5 100644 --- a/Assets/Scripts/Meta/Build/Barracks.cs +++ b/Assets/Scripts/Meta/Build/Barracks.cs @@ -5,13 +5,25 @@ using TMPro; // КАЗАРМЫ -- здание для найма войск. // - Без казарм нанимать войска нельзя. // - Каждые казармы дают +1 место в очереди обучения (1 казармы = 3 места, максимум 5). -// - Нажатие на казармы открывает панель "Войска" (найм). -// - Над зданием подпись: что сейчас обучается и сколько осталось. +// - Уровень Казарм открывает новые типы войск (вместе с уровнем Ратуши; числа -- EconomyConfig, раздел "Солдат", ТЕСТ). +// Считаются самые прокачанные Казармы. Уровень сохраняет модуль Поселения (ILeveledBuilding). +// - Тап по казармам открывает окно Казарм (App/BuildingWindow): очередь, войска, улучшение, кнопка "Нанять войска". +// - Подписи над зданием больше нет -- всё в окне (вернуть: ShowLabel = true). // Вешается на префаб Barracks (его создаёт Tools -> Build HUD Panels). -public class Barracks : MonoBehaviour +public class Barracks : MonoBehaviour, ILeveledBuilding { public static readonly List All = new List(); public static int Count { get { return All.Count; } } + public static bool ShowLabel = false; + + public int level = 1; + public int Level { get => level; set => level = Mathf.Max(1, value); } + + // Уровень самых прокачанных Казарм в городе (0 -- казарм нет) + public static int MaxLevel + { + get { int m = 0; foreach (var b in All) if (b != null && b.level > m) m = b.level; return m; } + } // Размер очереди обучения в зависимости от числа казарм public static int QueueSize { get { return Count == 0 ? 0 : Mathf.Min(5, 2 + Count); } } @@ -24,6 +36,7 @@ public class Barracks : MonoBehaviour void Start() { + if (!ShowLabel) return; // подпись над зданием (обычный 3D-текст, не UI) var go = new GameObject("BarracksLabel"); go.transform.SetParent(transform, false); @@ -44,6 +57,7 @@ public class Barracks : MonoBehaviour void Update() { + if (label == null) return; if (Time.unscaledTime < next) return; next = Time.unscaledTime + 0.5f; Refresh(); @@ -64,10 +78,18 @@ public class Barracks : MonoBehaviour (d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + ""; } + Vector3 downPos; + void OnMouseDown() + { + downPos = Input.mousePosition; + } + + // Тап (а не перетаскивание камеры) -- окно Казарм + void OnMouseUpAsButton() { if (UIBlocker.IsPointerOverUI()) return; - if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return; - if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops"); + if ((Input.mousePosition - downPos).magnitude > 25f) return; + BuildingWindow.Open(gameObject); } } diff --git a/Assets/Scripts/Meta/Core/GameProgress.cs b/Assets/Scripts/Meta/Core/GameProgress.cs index 0b9c458..e9105b9 100644 --- a/Assets/Scripts/Meta/Core/GameProgress.cs +++ b/Assets/Scripts/Meta/Core/GameProgress.cs @@ -635,13 +635,34 @@ public class GameProgress : MonoBehaviour get { return Army.SoldiersAtHome; } } - public bool TroopUnlocked(int index) { return TownHallLevel >= GameData.Troops[index].requiredTownHall; } + // Тип войск открыт, если выполнены оба условия: уровень Ратуши (GameData.Troops) и уровень Казарм + // (EconomyConfig, раздел "Солдат", ТЕСТ). Считаются самые прокачанные Казармы. + public bool TroopUnlocked(int index) + { + return TownHallLevel >= GameData.Troops[index].requiredTownHall && Barracks.MaxLevel >= RequiredBarracksLevel(index); + } + + public int RequiredBarracksLevel(int index) + { + var table = EconomyConfig.Load().soldier.requiredBarracksLevel; + return table != null && index >= 0 && index < table.Length ? Mathf.Max(1, table[index]) : 1; + } + + // Чего не хватает, чтобы открыть тип войск: "Ратуша 2 ур., Казармы 3 ур." (пусто -- открыт) + public string TroopLockText(int index) + { + var parts = new List(); + int th = GameData.Troops[index].requiredTownHall, bk = RequiredBarracksLevel(index); + if (TownHallLevel < th) parts.Add("Ратуша " + th + " ур."); + if (Barracks.MaxLevel < bk) parts.Add("Казармы " + bk + " ур."); + return string.Join(", ", parts.ToArray()); + } public void Train(int index, int count) { var def = GameData.Troops[index]; - if (!TroopUnlocked(index)) { UIKit.Toast("Нужна Ратуша " + def.requiredTownHall + " уровня"); return; } if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; } + if (!TroopUnlocked(index)) { UIKit.Toast("Нужно: " + TroopLockText(index)); return; } if (Data.training.Count >= Barracks.QueueSize) { UIKit.Toast("Очередь обучения заполнена. Больше казарм -- длиннее очередь"); return; } // Воин -- житель города (модуль Населения): берутся свободные жители, резерв не отдаётся var population = Population; @@ -653,12 +674,12 @@ public class GameProgress : MonoBehaviour return; } if (!Pay(def.cost, count)) return; - if (population != null) population.TryRecruit(count); + bool recruited = population != null && population.TryRecruit(count); long start = Data.training.Count > 0 ? Data.training[Data.training.Count - 1].endTicks : Now; if (start < Now) start = Now; long end = start + TimeSpan.FromSeconds(def.secondsPerUnit * count).Ticks; - Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end }); + Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end, recruited = recruited }); UIKit.Toast("Обучение начато: " + count + " × " + def.name); MarkChanged(); } @@ -697,8 +718,16 @@ public class GameProgress : MonoBehaviour { var o = Data.training[0]; Data.training.RemoveAt(0); - if (Population != null) Population.RecruitsTrained(o.count); // сначала из Казарм, потом в Армию -- без двойного счёта - Army.AddTroops(o.troopIndex, o.count); + if (o.recruited && Population != null) + { + // Люди из Казарм переходят в Армию одним действием: не теряются и не дублируются + if (!Population.CompleteTraining(o.troopIndex, o.count)) + { + Debug.LogWarning($"[ВОЙСКА] Армия не приняла {o.count} × {GameData.Troops[o.troopIndex].name} -- новобранцы остаются в Казармах"); + continue; // воинов не прибавилось -- и в статистику не идёт + } + } + else Army.AddTroops(o.troopIndex, o.count); // старый заказ: людей под него не брали Data.troopsTrained += o.count; Data.dailyTroops += o.count; Data.eventTroopRemainder += o.count; diff --git a/Assets/Scripts/Meta/Core/ProgressData.cs b/Assets/Scripts/Meta/Core/ProgressData.cs index cab244c..c830da0 100644 --- a/Assets/Scripts/Meta/Core/ProgressData.cs +++ b/Assets/Scripts/Meta/Core/ProgressData.cs @@ -24,6 +24,7 @@ public class TrainingOrder public int count; public long startTicks; public long endTicks; + public bool recruited; // под заказ взяты люди из жителей (модуль Населения). false -- старый заказ, людей не брали } [Serializable] diff --git a/Assets/Scripts/Meta/Panels/TroopsPanel.cs b/Assets/Scripts/Meta/Panels/TroopsPanel.cs index 300f638..eb45b40 100644 --- a/Assets/Scripts/Meta/Panels/TroopsPanel.cs +++ b/Assets/Scripts/Meta/Panels/TroopsPanel.cs @@ -104,7 +104,7 @@ public class TroopsPanel : BasePanel if (!unlocked) { - UIKit.Text(card, "Откроется на Ратуше " + def.requiredTownHall + " уровня", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center); + UIKit.Text(card, "Откроется: " + GP.TroopLockText(i), 26, UIKit.Theme.Gold, TextAlignmentOptions.Center); continue; } diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 32c94af..097f5ed 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -70,7 +70,11 @@ public interface IPopulation int Recruitable { get; } // сколько воинов можно нанять прямо сейчас: (свободные - резерв) / жителей на воина int InTraining { get; } // людей, взятых из жителей и ещё обучающихся в Казармах bool TryRecruit(int soldiers); // забрать людей в Казармы (в начале обучения); false -- столько жителей нет - void RecruitsTrained(int soldiers); // обучение закончено: этих людей теперь считает Армия + void RecruitsTrained(int soldiers); // обучение закончено: этих людей теперь считает Армия (без проверки Армии) + // Обучение закончено -- передать новобранцев в Армию одним действием: люди уходят из Населения, только если + // Армия их действительно приняла (IArmy.AddTroops). false -- ничего не изменилось: столько новобранцев нет, + // Армия не подключена или не приняла (новобранцы остаются в Казармах, никто не теряется и не дублируется). + bool CompleteTraining(int troopType, int soldiers); // Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке) void Tick(long unixNow); diff --git a/Assets/Scripts/Modules/Population/PopulationService.cs b/Assets/Scripts/Modules/Population/PopulationService.cs index 70a5f44..4b81cdc 100644 --- a/Assets/Scripts/Modules/Population/PopulationService.cs +++ b/Assets/Scripts/Modules/Population/PopulationService.cs @@ -10,6 +10,14 @@ using System.Text; // и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах. // Жители: работать может любой житель (общего ограничения нет, регламент 31.10) -- рабочих ограничивают // свободные жители и рабочие места в зданиях. Стартовый город: 120 рабочих из 270 жителей. +// +// Каждый человек ровно в одной группе (регламент 31.10): +// свободный житель = Civilians - Workers (Idle) +// рабочий = Workers (jobs[], только из жителей: SetWorkers берёт не больше свободных) +// новобранец = InTraining (TryRecruit забирает только свободных, рабочих не трогает) +// воин = Soldiers (хранит Армия; RecruitsTrained убирает новобранцев до зачисления в Армию) +// Total = Civilians + InTraining + Soldiers -- сумма всех групп. +// Рабочие места сами не заполняются: только стартовая расстановка (AutoFillAll, один раз), дальше -- игрок. // Найм воина: свободный житель уходит в Казармы (InTraining), после обучения его считает Армия. Резерв не нанять. // // Время -- только из GameClock. Расчёт идёт кусками по 10 минут: раз в несколько секунд в игре, @@ -106,6 +114,27 @@ public sealed class PopulationService : IPopulation, ISaveParticipant return true; } + public bool CompleteTraining(int troopType, int soldiers) + { + if (soldiers <= 0) return false; + int people = soldiers * Math.Max(1, rules.PeoplePerSoldier); + if (people > inTraining) return false; // столько новобранцев нет (повтор или ошибка) + if (!GameServices.TryGet(out IArmy army)) return false; + + // Сначала убираем из Казарм, потом отдаём в Армию: событие Армии увидит уже правильную сумму. + // Армия не приняла -- возвращаем новобранцев, как было. + inTraining -= people; + int before = army.SoldiersAtHome; + army.AddTroops(troopType, soldiers); + if (army.SoldiersAtHome - before != soldiers) + { + inTraining += people; + return false; + } + Changed(); + return true; + } + public void RecruitsTrained(int soldiers) { if (soldiers <= 0) return; @@ -167,7 +196,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant int j = (int)job; if (j < 0 || j >= WorkJobs.Count) return 0; - int limit = jobs[j] + FreeWorkers; // больше, чем есть свободных, не поставить + 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)); @@ -288,18 +317,10 @@ public sealed class PopulationService : IPopulation, ISaveParticipant // ---------------- Рабочие: расстановка ---------------- - // Поставить свободных рабочих на пустые места профессии (новое здание, улучшение). Возвращает, сколько поставлено. - 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). - // Не больше StartWorkers рабочих: стартовый город -- 120 рабочих, остальные жители свободны. + // Стартовая расстановка -- единственное автоматическое заполнение мест (регламент 31.10). + // Один раз: в новой игре или при первой загрузке старого сохранения (пока JobsInitialized == false). + // Свободных -- поровну по всем местам, всего не больше StartWorkers (120). Новые места после стройки + // и улучшения остаются пустыми, пока игрок сам не поставит рабочих. public void AutoFillAll() { bool changed = false; @@ -474,6 +495,16 @@ public sealed class PopulationService : IPopulation, ISaveParticipant case "kit": startingKitGiven = value == "1"; break; } } + + // Рабочих не может быть больше, чем жителей (испорченное или очень старое сохранение). + // Места в зданиях здесь не проверяем: секция Экономики может загрузиться позже -- это делает ClampJobs. + int over = Workers - civilians; + for (int j = WorkJobs.Count - 1; j >= 0 && over > 0; j--) + { + int take = Math.Min(over, jobs[j]); + jobs[j] -= take; + over -= take; + } } // Новая игра: стартовый город. Стартовое войско выдаст App/GameModules после сброса всех модулей. diff --git a/Assets/Scripts/Modules/Settlement/TownHall.cs b/Assets/Scripts/Modules/Settlement/TownHall.cs index 5fa101b..74105a1 100644 --- a/Assets/Scripts/Modules/Settlement/TownHall.cs +++ b/Assets/Scripts/Modules/Settlement/TownHall.cs @@ -1,13 +1,18 @@ +using System; using UnityEngine; // МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md // Вешается на объект Ратуши, который уже стоит на карте. // Хранит текущий уровень -- от него зависит, какие здания разблокированы в BuildManager. +// Тап по Ратуше открывает окно Ратуши (слой приложения подписан на Tapped), улучшение -- кнопкой в окне. public class TownHall : MonoBehaviour { public static TownHall Instance { get; private set; } + // Тап по Ратуше: интерфейс откроет её окно (модуль про интерфейс не знает) + public static event Action Tapped; + [Header("Уровень")] public int currentLevel = 1; public int maxLevel = 10; @@ -64,10 +69,18 @@ public class TownHall : MonoBehaviour EventBus.Publish(new TownHallLevelChangedEvent { Level = currentLevel }); Debug.Log($"[РАТУША] Улучшена до уровня {currentLevel}!"); } + Vector3 downPos; + void OnMouseDown() { - if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен улучшать Ратушу под ней - Debug.Log($"[Ратуша] Клик! текущий уровень: {currentLevel}. Улучшение..."); - Upgrade(); + downPos = Input.mousePosition; + } + + // Отпустил палец на Ратуше, почти не сдвинув (а не тащил камеру) -- окно Ратуши. Раньше тап сразу улучшал + void OnMouseUpAsButton() + { + if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен проходить на Ратушу под ней + if ((Input.mousePosition - downPos).magnitude > 25f) return; + Tapped?.Invoke(this); } } diff --git a/Assets/Scripts/Modules/Tests/PopulationTests.cs b/Assets/Scripts/Modules/Tests/PopulationTests.cs index 0187108..3c40f6b 100644 --- a/Assets/Scripts/Modules/Tests/PopulationTests.cs +++ b/Assets/Scripts/Modules/Tests/PopulationTests.cs @@ -150,6 +150,124 @@ public class PopulationTests Assert.AreEqual(300, p.Total, "новобранцы не посчитаны дважды"); } + // ---------------- Группы людей не пересекаются (регламент 31.10) ---------------- + + static void AssertGroupsAddUp(PopulationService p, string when) + { + int free = p.Idle, workers = p.Workers, recruits = p.InTraining, soldiers = p.Soldiers; + Assert.AreEqual(p.Total, free + workers + recruits + soldiers, "свободные + рабочие + новобранцы + воины = все люди: " + when); + Assert.AreEqual(p.Civilians, free + workers, "жители = свободные + рабочие: " + when); + Assert.GreaterOrEqual(free, 0, when); + } + + [Test] + public void Groups_NeverOverlap_SumEqualsTotal() + { + var p = StartCity(); + AssertGroupsAddUp(p, "старт"); + + p.SetWorkers(WorkJob.Farmer, 100); + AssertGroupsAddUp(p, "поставили 100 рабочих"); + + Assert.IsTrue(p.TryRecruit(150)); // берутся только свободные: 170 - резерв 10 = 160 доступно + Assert.AreEqual(100, p.Workers, "рабочих в новобранцы не взяли"); + AssertGroupsAddUp(p, "наняли 150"); + + p.RecruitsTrained(150); + army.AddTroops(0, 150); + AssertGroupsAddUp(p, "новобранцы стали воинами"); + Assert.AreEqual(300, p.Total, "людей не прибавилось и не убавилось"); + } + + [Test] + public void Workers_CannotExceedFreeCivilians() + { + var p = StartCity(); + p.SetWorkers(WorkJob.Farmer, 100); + p.TryRecruit(150); // жителей осталось 120: 100 рабочих + 20 свободных + + Assert.AreEqual(20, p.SetWorkers(WorkJob.Woodcutter, 50), "поставить можно только 20 свободных -- не воинов и не новобранцев"); + Assert.AreEqual(0, p.FreeWorkers); + Assert.AreEqual(0, p.SetWorkers(WorkJob.Stonecutter, 10), "свободных нет -- никого"); + AssertGroupsAddUp(p, "все жители на работе"); + } + + [Test] + public void Restore_WorkersNeverMoreThanCivilians() + { + var p = StartCity(); + p.SetWorkers(WorkJob.Farmer, 120); + string save = p.Capture().Replace("civilians=270", "civilians=50"); // испорченное сохранение + var copy = new PopulationService(new PopulationRules()); + copy.Restore(save); + Assert.AreEqual(50, copy.Workers, "рабочих не больше, чем жителей"); + Assert.AreEqual(0, copy.Idle); + } + + // ---------------- Интеграция: новобранцы переходят из Населения в Армию (настоящий ArmyService) ---------------- + + [Test] + public void Training_RecruitsMoveToArmy_NoOneLostOrDuplicated() + { + var p = StartCity(); + int infantryBefore = army.GetTroops(0); + Assert.IsTrue(p.TryRecruit(20)); + AssertGroupsAddUp(p, "новобранцы в Казармах"); + + Assert.IsTrue(p.CompleteTraining(0, 20)); + Assert.AreEqual(0, p.InTraining); + Assert.AreEqual(infantryBefore + 20, army.GetTroops(0), "в Армии +20 пехоты"); + Assert.AreEqual(300, p.Total, "людей столько же"); + AssertGroupsAddUp(p, "обучение закончено"); + } + + [Test] + public void Training_ArmyRejects_RecruitsStayInBarracks() + { + var p = StartCity(); + p.TryRecruit(20); + int soldiersBefore = p.Soldiers; + + Assert.IsFalse(p.CompleteTraining(99, 20), "такого типа войск нет -- Армия не приняла"); + Assert.AreEqual(20, p.InTraining, "новобранцы не пропали"); + Assert.AreEqual(soldiersBefore, p.Soldiers, "и воинов из ничего не появилось"); + Assert.AreEqual(300, p.Total); + AssertGroupsAddUp(p, "Армия не приняла"); + } + + [Test] + public void Training_RepeatedCompletion_DoesNotDuplicate() + { + var p = StartCity(); + p.TryRecruit(20); + Assert.IsTrue(p.CompleteTraining(0, 20)); + Assert.IsFalse(p.CompleteTraining(0, 20), "второй раз новобранцев уже нет"); + Assert.AreEqual(50, p.Soldiers, "30 стартовых + 20, а не 70"); + Assert.AreEqual(300, p.Total); + } + + [Test] + public void Training_NoArmyModule_NothingChanges() + { + var p = StartCity(); + p.TryRecruit(20); + GameServices.Unregister(army); + Assert.IsFalse(p.CompleteTraining(0, 20)); + Assert.AreEqual(20, p.InTraining, "без Армии передавать некуда -- новобранцы ждут"); + } + + [Test] + public void Training_PartOfRecruits_OthersKeepTraining() + { + var p = StartCity(); + p.TryRecruit(20); + Assert.IsTrue(p.CompleteTraining(1, 8)); // 8 лучников готовы + Assert.AreEqual(12, p.InTraining); + Assert.AreEqual(8, army.GetTroops(1)); + Assert.AreEqual(300, p.Total); + AssertGroupsAddUp(p, "часть обучена"); + } + [Test] public void Reserve_DependsOnTownHallLevel_10_20_30() { diff --git a/Assets/Scripts/Modules/Tests/ProductionTests.cs b/Assets/Scripts/Modules/Tests/ProductionTests.cs index a7b54f5..ceaac09 100644 --- a/Assets/Scripts/Modules/Tests/ProductionTests.cs +++ b/Assets/Scripts/Modules/Tests/ProductionTests.cs @@ -174,13 +174,33 @@ public class ProductionTests } [Test] - public void AutoFill_NewBuilding_TakesFreeWorkers() + public void NewBuilding_SlotsStayEmpty_UntilPlayerAssigns() { 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)); + population.AutoFillAll(); // стартовая расстановка -- один раз + Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer)); + + production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1))); // построили вторую ферму + population.ClampJobs(); + Assert.AreEqual(60, production.Slots(WorkJob.Farmer)); + Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer), "новые места пустые -- сами не заполняются"); + + Assert.AreEqual(60, population.SetWorkers(WorkJob.Farmer, 60), "игрок ставит сам"); + } + + [Test] + public void StartPlacement_HappensOnlyOnce() + { + production.SetBuildings(Buildings((WorkJob.Farmer, 1))); + population.AutoFillAll(); + Assert.IsTrue(population.JobsInitialized); + population.SetWorkers(WorkJob.Farmer, 0); // игрок снял всех + + // Сохранение и загрузка: расстановка уже была, повторно не нужна + var copy = new PopulationService(new PopulationRules()); + copy.Restore(population.Capture()); + Assert.IsTrue(copy.JobsInitialized, "второй раз CitySync расстановку не вызовет"); + Assert.AreEqual(0, copy.GetWorkers(WorkJob.Farmer)); } [Test] diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index 3844a2d..a313e59 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -58,6 +58,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | `IPopulation.CompleteTraining(type, n)` — передача новобранцев в Армию одним действием. | | 31.10.2026 | Claude | `IPopulation.Workforce` — все жители (ограничения «40% всех людей» больше нет), `CivilianReserve` — по уровню Ратуши. Население — самостоятельный модуль. | | 28.10.2026 | Claude | `IPopulation`: `WasStarvingAt` (история голода для боя), найм — `CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`. | | 28.10.2026 | Claude | Бой: `BattleCondition` (голод, усталость), `BattleRequest.AttackerConditions` / `DefenderConditions`, `BattleOutcome.AttackerConditionPercent` / `DefenderConditionPercent`. | diff --git a/Docs/Modules/Economy.md b/Docs/Modules/Economy.md index c0768e2..5a3a792 100644 --- a/Docs/Modules/Economy.md +++ b/Docs/Modules/Economy.md @@ -50,7 +50,8 @@ - **Дом** (здание «Население», `resourceType = Population`) — не рабочее место: каждый уровень +50 к пределу жилья. - **Кристалл** — рабочих нет, синий кристалл добывается в разломах на карте мира (этап 6). - **Какие здания стоят,** модулю сообщает `App/CitySync`: раз в полсекунды смотрит город и передаёт места, Дома и уровни Складов. Модуль запоминает места в сохранении, поэтому добыча идёт и на карте мира, и офлайн. -- **Новые места заполняются сами:** построили или улучшили здание — свободные рабочие встают туда. Старое сохранение при первом запуске — рабочие расставляются поровну по всем местам. +- **Новые места сами не заполняются** (регламент 31.10): построили или улучшили здание — места пустые, пока игрок не поставит рабочих в окне здания. Автоматически — только стартовая расстановка один раз (новая игра или первая загрузка старого сохранения): до 120 рабочих поровну по местам. +- **Голод в офлайне** считается по тем же шагам, что и в игре: Население досчитывает время кусками по 10 минут, и в каждом куске добыча берёт голод на этот момент. - **Проверка из документа** (тест `BalancedCity_…`): 45 фермеров, 25 лесорубов, 15 каменотёсов, 15 шахтёров → 90 еды, 50 дерева, 22.5 камня, 15 металла в час. ## События @@ -108,6 +109,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Новые места после стройки и улучшения больше не заполняются сами (`App/CitySync`), только стартовая расстановка один раз. | | 31.10.2026 | Claude | Голод: добыча всего, кроме еды, ×0.5 (`ProductionRules.FamineNonFoodMultiplier`, ТЕСТ). Тест `Famine_HalvesNonFoodProduction_…`. | | 27.10.2026 | Claude | Этапы 1 и 3: лимиты склада (`GetCapacity`, Склад, Амбар), «запас / лимит» и прирост на панели; добыча через рабочих (`Bogatyri.Economy.Rules`, `IProduction`), кликер убран, окно здания и рабочих, улучшение зданий. Исправлены лишние `ResourceUIText` «Люди» на ячейках панели. | | 26.10.2026 | Claude | Таблица баланса `EconomyConfig`. Стартовые ресурсы новой игры. Ресурс «Люди» отражает модуль Населения. | diff --git a/Docs/Modules/Population.md b/Docs/Modules/Population.md index 7974683..d35559b 100644 --- a/Docs/Modules/Population.md +++ b/Docs/Modules/Population.md @@ -12,7 +12,8 @@ - **Люди города = жители + новобранцы в Казармах + воины.** Жителей и новобранцев хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах. - **Жители:** работать может любой житель — общего ограничения нет (регламент 31.10). Рабочих ограничивают свободные жители и рабочие места в зданиях. Рабочие стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота), остальные свободны. Стартовый город: 120 рабочих из 270 жителей — при первой расстановке (`AutoFillAll`) больше `StartWorkers` (120) не ставится. - **Найм воинов** (решение команды от 19.10.2026). Воины набираются из свободных жителей (не на работе), гражданский резерв не отдаётся (`Recruitable` = свободные − резерв). Пример команды: 150 свободных, резерв 30 — нанять можно не больше 120. Воин стоит `PeoplePerSoldier` жителей (1). Человек уходит из жителей в начале обучения (`TryRecruit`), учится в Казармах (`InTraining`, ест как воин) и после обучения переходит в Армию (`RecruitsTrained`). Погибшие в бою воины уменьшают население — их больше нет в Армии. Резерв зависит от уровня Ратуши (регламент 31.10, ТЕСТ): уровни 1–2 — 10, 3–5 — 20, 6–10 — 30 (`EconomyConfig`, `civilianReserveByTownHallLevel`). -- **Рабочих ставят** окно здания (`SetWorkers`) и `App/CitySync` (новые места заполняются свободными сами). Больше, чем мест в зданиях (`IProduction.Slots`) и свободных, не поставить. +- **Группы людей не пересекаются** (регламент 31.10). Каждый человек ровно в одной группе: свободный житель (`Idle`), рабочий (`Workers`), новобранец (`InTraining`), воин (`Soldiers`, хранит Армия). `Total` — их сумма. На работу ставятся только свободные жители, в войско берутся только свободные жители сверх резерва. +- **Рабочих ставит игрок** в окне здания (`SetWorkers`): не больше, чем мест в зданиях (`IProduction.Slots`) и свободных жителей. Автоматически места заполняются только один раз — стартовая расстановка (`AutoFillAll`, до 120 рабочих) в новой игре или при первой загрузке старого сохранения. Новые места после постройки или улучшения остаются пустыми, пока игрок не поставит рабочих. Мест стало меньше — лишних рабочих снимают (`ClampJobs`). - **Еда в час:** 0.1 на жителя, 0.2 на воина. Стартовый город (270 жителей + 30 воинов) ест 33 еды в час. - **Прирост:** +2.5 человека в час, только если еды хватило и население ниже предела жилья. - **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Добыча всего, кроме еды, ×0.5 (модуль Экономики, регламент 31.10, ТЕСТ), еда добывается без штрафа. Бой ослабляет голодное войско (модуль Боя, −50%). Штраф снимается сразу, как только город снова сыт. Смерти от голода нет. @@ -38,6 +39,7 @@ | `Recruitable` | Сколько воинов можно нанять прямо сейчас: (свободные − резерв) / жителей на воина. | | `InTraining` | Людей, взятых из жителей и ещё обучающихся в Казармах. | | `TryRecruit(n)`, `RecruitsTrained(n)` | Забрать людей в Казармы в начале обучения; обучение закончено — их считает Армия. | +| `CompleteTraining(type, n)` | Передать n новобранцев в Армию одним действием: люди уходят из Населения, только если Армия их приняла. Иначе ничего не меняется — никто не теряется и не дублируется. Так заканчивают обучение Казармы (`GameProgress`). | | `WasStarvingAt(unixTime)` | Голодал ли город в этот момент (секунды Unix, время `GameClock`). Позже последнего расчёта -- как сейчас; старше 48 часов -- нет. | | `Tick(unixNow)` | Досчитать население до момента `unixNow`. | @@ -110,6 +112,8 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | `CompleteTraining`: новобранцы переходят в Армию одним действием с откатом, если Армия не приняла. Заказ обучения помечает, брали ли под него людей (`TrainingOrder.recruited`); старые заказы — по-старому. Интеграционные тесты с настоящей Армией: переход без потерь, отказ Армии, повтор, нет Армии, часть заказа. | +| 31.10.2026 | Claude | Автозаполнение новых мест убрано (`AutoFill` удалён, `App/CitySync` больше не ставит рабочих после стройки и улучшения), осталась только стартовая расстановка один раз. При загрузке рабочих не больше, чем жителей. Тесты: группы людей не пересекаются и в сумме дают всех, рабочих не больше свободных, новые места пустые, стартовая расстановка один раз. | | 31.10.2026 | Claude | По регламенту 31.10: Население снова самостоятельный модуль (`Modules/Population`, сборка `Bogatyri.Population`); убрано ограничение «40% всех людей» — работать может любой житель, первая расстановка — 120 рабочих; резерв по уровню Ратуши 10/20/30 (ТЕСТ, `civilianReserveByTownHallLevel` вместо `civilianReserve`); при голоде добыча кроме еды ×0.5 (ТЕСТ). Тесты. | | 28.10.2026 | Claude | Население стало частью Поселения: папка `Modules/Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом (`CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`), строка `training=`, `Workforce` — 40% всех людей. Тесты. | | 28.10.2026 | Claude | История голода: `IPopulation.WasStarvingAt`, строка `famine=` в сохранении, тесты (вопрос Саши п. 7). | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 3c8f9f5..1e9132b 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -104,6 +104,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Окна зданий: подписи над зданиями убраны, у каждого здания (добывающие, Дом, Склад, Амбар, Ратуша, Кристалл) своё окно по тапу — состояние, добыча и склад, улучшение и его цена. Казармы тоже получили окно и уровни (открывают новые войска вместе с Ратушей; уровни 1/2/3/5 — ТЕСТ). Ратуша улучшается кнопкой в окне, а не тапом. Обновлён Settlement. | | 31.10.2026 | Claude | По регламенту 31.10: откат — Население снова самостоятельный модуль (`Modules/Population`, `Bogatyri.Population`), схема и «Кто отвечает» как у Саши. Работать может любой житель (без «40%»), резерв по уровню Ратуши 10/20/30, голод режет добычу кроме еды вдвое. Обновлены Population, Settlement, Economy, Core. | | 28.10.2026 | Claude | Население стало частью Поселения, как на схеме Димы: `Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом, история голода для боя. Обновлены Settlement, Population, Core. | | 28.10.2026 | Claude | Схема модулей обновлена: десять модулей (добавлено Население), «Не путать» (Поселение и Население, Армия и Боевая система), кто за что отвечает. | diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md index 35cbda2..1d43cf1 100644 --- a/Docs/Modules/Settlement.md +++ b/Docs/Modules/Settlement.md @@ -82,6 +82,8 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Ратуша: тап больше не улучшает сразу, а открывает окно Ратуши (событие `TownHall.Tapped`, окно — `App/BuildingWindow`). Улучшение — кнопкой в окне, с ценой и тем, что откроется. | +| 31.10.2026 | Claude | Казармы стали уровневым зданием (`ILeveledBuilding`, уровень сохраняется как у остальных). Тап открывает окно Казарм: очередь обучения, какие войска открыты и что нужно для остальных, улучшение, кнопка «Нанять войска». Войско открывается, когда хватает и уровня Ратуши, и уровня Казарм; считаются самые прокачанные Казармы, каждые Казармы по-прежнему дают +1 место в очереди. Уровни Казарм: пехота 1, лучники 2, конница 3, осадные 5 (`EconomyConfig`, `soldier.requiredBarracksLevel`) — **ТЕСТ, согласовать с Димой**; цена улучшения общая, тоже не утверждена. | | 31.10.2026 | Claude | Откат: Население снова самостоятельный модуль (`Modules/Population`), из Поселения убрано. | | 28.10.2026 | Claude | Население стало частью Поселения (`Settlement/Population`, сборка `Bogatyri.Settlement.Population`) — как на схеме Димы. Подробности — [Population.md](Population.md). | | 27.10.2026 | Claude | Свободная стройка на сетке: `BuildGrid`, `BuildGhost`, `BuildingData.footprint`, `BuildManager.BuildAt` / `CanBuildAt` / `FindData`. Участки и площадки «Застройка» прячутся. Новый `BuildModeController` (призрак, «Поставить / Другое / Отмена»), камера не двигается, пока тащат призрак. |