From d30fd34810f712944ea5b3c71ff52c910399c159 Mon Sep 17 00:00:00 2001 From: Valdemar Date: Mon, 2 Nov 2026 00:01:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D1=81=D0=B5=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5:=20=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D1=81=D1=82=D1=80=D0=BE=D0=B8=D1=82=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?,=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=20=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BF=D0=BE=20=D0=A0=D0=B0=D1=82=D1=83=D1=88?= =?UTF-8?q?=D0=B5,=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20=D0=B8?= =?UTF-8?q?=20=D1=81=D0=BD=D0=BE=D1=81,=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=20=D1=80=D0=B5=D1=81=D1=83=D1=80=D1=81=D0=BE=D0=B2;=20?= =?UTF-8?q?=D0=A2=D0=B5=D1=80=D0=B5=D0=BC=20=D0=B1=D0=BE=D0=B3=D0=B0=D1=82?= =?UTF-8?q?=D1=8B=D1=80=D1=8F;=20=D0=BD=D0=B0=D0=B9=D0=BC=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Scripts/App/BuildingWindow.cs | 258 +++++++++++++++++- Assets/Scripts/App/CitySync.cs | 5 +- Assets/Scripts/App/EconomyConfig.cs | 108 ++++++++ Assets/Scripts/App/GameModules.cs | 29 ++ Assets/Scripts/App/ResourceProtection.cs | 27 ++ Assets/Scripts/App/ResourceProtection.cs.meta | 2 + .../Scripts/Meta/Build/BuildModeController.cs | 129 ++++++++- Assets/Scripts/Meta/Build/HeroCastle.cs | 52 ++++ Assets/Scripts/Meta/Build/HeroCastle.cs.meta | 2 + .../Scripts/Meta/Build/HeroCastleBootstrap.cs | 65 +++++ .../Meta/Build/HeroCastleBootstrap.cs.meta | 2 + Assets/Scripts/Meta/Build/HeroWalker.cs | 203 ++++++++++++++ Assets/Scripts/Meta/Build/HeroWalker.cs.meta | 2 + .../Modules/Core/Contracts/ModuleContracts.cs | 9 + .../Scripts/Modules/Settlement/BuildGrid.cs | 4 + .../Modules/Settlement/BuildManager.cs | 7 + .../Scripts/Modules/Settlement/BuildTimer.cs | 169 ++++++++++++ .../Modules/Settlement/BuildTimer.cs.meta | 2 + .../Modules/Settlement/SettlementModule.cs | 53 +++- Docs/Modules/Core.md | 2 + Docs/Modules/Economy.md | 1 + Docs/Modules/README.md | 1 + Docs/Modules/Settlement.md | 13 +- 23 files changed, 1120 insertions(+), 25 deletions(-) create mode 100644 Assets/Scripts/App/ResourceProtection.cs create mode 100644 Assets/Scripts/App/ResourceProtection.cs.meta create mode 100644 Assets/Scripts/Meta/Build/HeroCastle.cs create mode 100644 Assets/Scripts/Meta/Build/HeroCastle.cs.meta create mode 100644 Assets/Scripts/Meta/Build/HeroCastleBootstrap.cs create mode 100644 Assets/Scripts/Meta/Build/HeroCastleBootstrap.cs.meta create mode 100644 Assets/Scripts/Meta/Build/HeroWalker.cs create mode 100644 Assets/Scripts/Meta/Build/HeroWalker.cs.meta create mode 100644 Assets/Scripts/Modules/Settlement/BuildTimer.cs create mode 100644 Assets/Scripts/Modules/Settlement/BuildTimer.cs.meta diff --git a/Assets/Scripts/App/BuildingWindow.cs b/Assets/Scripts/App/BuildingWindow.cs index 78418ac..57447b0 100644 --- a/Assets/Scripts/App/BuildingWindow.cs +++ b/Assets/Scripts/App/BuildingWindow.cs @@ -14,6 +14,9 @@ using UnityEngine.UI; // - Кристалл: где добывается, запас / лимит; // - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска), // кнопка "Нанять войска" (панель "Войска"). +// - Стройка и улучшение идут по времени (BuildTimer, 01.11, ТЕСТ): полоска, сколько осталось, ускорение за кристаллы. +// Пока здание строится, в окне только стройка; при улучшении здание работает на старом уровне. +// - У построенных зданий (кроме Ратуши) -- кнопки "Перенести" (режим стройки) и "Снести" (часть цены возвращается). // Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям. // Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики. // Пока окно открыто, цифры обновляются сами (раз в полсекунды). @@ -55,19 +58,32 @@ public static class BuildingWindow var townHall = focus != null ? focus.GetComponent() : null; var barracks = focus != null ? focus.GetComponent() : null; var hospital = focus != null ? focus.GetComponent() : null; + var heroCastle = focus != null ? focus.GetComponent() : null; + + bool constructing = focus != null && BuildTimer.IsConstructing(focus.transform); + if (focus != null) TimerBlock(card, focus); if (focus == null) WorkersList(card); + else if (constructing) UIKit.Text(card, "Пока здание строится, оно не работает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); else if (townHall != null) TownHallSection(card, townHall); else if (barracks != null) BarracksSection(card, barracks); else if (hospital != null) HospitalSection(card, hospital); + else if (heroCastle != null) HeroCastleSection(card); 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); + if (focus != null) ActionsBlock(card, focus); UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28); } + // Стройка закончилась (BuildTimer.Completed): окно этого здания показывает новый уровень + internal static void OnBuildCompleted(BuildTimer timer) + { + if (root != null && timer != null && focus == timer.gameObject) Rebuild(); + } + internal static void Tick() { if (root == null) return; @@ -192,6 +208,26 @@ public static class BuildingWindow } else StorageBar(card, economy, ResourceType.Food); UIKit.Text(card, "Сверх лимита ресурс не прибавляется — пропадает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + + if (GameServices.TryGet(out IResourceProtection protection)) + { + Header(card, "Защита от грабежа"); + var p = EconomyConfig.Load().protection; + ResourceType[] kinds = warehouse + ? new[] { ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold } + : new[] { ResourceType.Food }; + Live(card, () => + { + var lines = new List(); + foreach (var k in kinds) + lines.Add($"{GameData.ResName(k)}: спрятано {protection.Protected(k):0}, можно унести {protection.Lootable(k):0}"); + return string.Join("\n", lines.ToArray()); + }, 22, UIKit.Theme.Text); + UIKit.Text(card, warehouse + ? $"Каждый уровень Склада прячет ещё {p.perWarehouseLevel:0} каждого ресурса — в набеге их не унесут." + : $"Каждый уровень Амбара прячет ещё {p.perGranaryLevel:0} еды — в набеге её не унесут.", + 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + } } UpgradeBlock(card, storage.level, $"лимит +{perLevel * storage.level:0} → +{perLevel * (storage.level + 1):0}", @@ -235,20 +271,47 @@ public static class BuildingWindow string unlocks = UnlocksAt(next); if (unlocks.Length > 0) UIKit.Text(card, "Откроется: " + unlocks, 24, UIKit.Theme.Gold, TextAlignmentOptions.Center); + string more = MoreBuildingsAt(level, next); + if (more.Length > 0) UIKit.Text(card, "Больше зданий: " + more, 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + if (TimerRunning(townHall.gameObject)) return; // улучшение уже идёт -- полоска сверху + var cost = BuildingData.ToAmounts(townHall.GetUpgradeCost()); UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center); + long ms = config.ConstructionMs(true, next); + TimeText(card, ms); bool canPay = townHall.CanUpgrade(); var b = UIKit.Button(card, $"Улучшить до {next} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () => { if (!townHall.CanUpgrade()) { UIKit.Toast("Не хватает ресурсов"); return; } - townHall.Upgrade(); - if (CitySync.Instance != null) CitySync.Instance.SyncNow(); - UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня"); + if (ms > 0 && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; } + if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; } + if (ms <= 0) BuildTimer.Begin(townHall.gameObject, BuildTimer.Kind.Upgrade, next, 0, 0).Complete(); + else + { + long now = GameClock.UnixNowMs; + BuildTimer.Begin(townHall.gameObject, BuildTimer.Kind.Upgrade, next, now, now + ms); + UIKit.Toast($"Ратуша улучшается до {next} уровня"); + } Rebuild(); }, -1, 80, 26); b.interactable = canPay; } + // Каких зданий на следующем уровне Ратуши можно построить больше: "Лес 2 → 3, Дом 3 → 4" + static string MoreBuildingsAt(int level, int next) + { + var config = EconomyConfig.Load(); + var parts = new List(); + if (config.buildingLimits != null) + foreach (var l in config.buildingLimits) + { + if (l == null) continue; + int a = config.BuildingLimitFor(l.buildingName, level), b = config.BuildingLimitFor(l.buildingName, next); + if (b > a) parts.Add($"{l.buildingName} {a} → {b}"); + } + return string.Join(", ", parts.ToArray()); + } + // ---------------- Казармы ---------------- static void BarracksSection(RectTransform card, Barracks barracks) @@ -340,6 +403,72 @@ public static class BuildingWindow () => hospital.Level = hospital.level + 1, Hospital.MaxBuildLevel); } + // ---------------- Терем богатыря: окно героя ---------------- + // Числа героя -- те же, что в панели "Герой" (GameProgress / GameData), механика не меняется. + + static void HeroCastleSection(RectTransform card) + { + var gp = GameProgress.Instance; + UIKit.Text(card, "В Тереме живёт богатырь -- верховный командир. Он гуляет по городу; тапните по нему, чтобы поговорить.", + 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + if (gp == null) return; + + int lvl = gp.Data.heroLevel, maxLvl = gp.HeroMaxLevel; + var head = UIKit.Row(card, 140, 16); + UIKit.Icon(head, new Color(0.55f, 0.4f, 0.2f), "Б", 130); // место под портрет + var info = UIKit.Card(head, new Color(0, 0, 0, 0), 0, 4); + UIKit.Size(info, -1, -1, 1); + UIKit.Text(info, $"Уровень {lvl} / {maxLvl}", 30, UIKit.Theme.Gold).fontStyle = FontStyles.Bold; + UIKit.Text(info, $"Сила героя: {GameData.HeroPower(lvl)}", 24, UIKit.Theme.Text); + UIKit.Text(info, $"Бонус к силе армии: +{Mathf.RoundToInt(gp.ArmyBonus * 100)}%", 24, UIKit.Theme.Text); + + Header(card, "Навыки"); + for (int i = 0; i < GameData.HeroSkillNames.Length; i++) + { + bool has = gp.HasHeroSkill(i); + UIKit.Text(card, has ? $"✓ {GameData.HeroSkillNames[i]}" + : $"{GameData.HeroSkillNames[i]} — с {GameData.HeroSkillLevels[i]} ур.", + 22, has ? UIKit.Theme.Text : UIKit.Theme.TextDim); + } + + Header(card, "Улучшение героя"); + if (lvl >= maxLvl) + { + UIKit.Text(card, $"Предел уровня. Улучшите Ратушу до {gp.TownHallLevel + 1} ур., чтобы поднять предел до " + + $"{GameData.HeroMaxLevel(gp.TownHallLevel + 1)}.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + } + else + { + var cost = GameData.HeroUpgradeCost(lvl); + Live(card, () => + { + var parts = new List(); + foreach (var c in cost) + { + bool enough = gp.Res(c.type) >= c.amount; + parts.Add($"{GameData.ResName(c.type)} {c.amount:N0}"); + } + return "Цена: " + string.Join(", ", parts.ToArray()); + }, 22, UIKit.Theme.Text); + UIKit.Text(card, $"Дальше: сила {GameData.HeroPower(lvl)} → {GameData.HeroPower(lvl + 1)}, " + + $"бонус армии +{GameData.HeroArmyBonusPercent(lvl)}% → +{GameData.HeroArmyBonusPercent(lvl + 1)}%", + 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + var up = UIKit.Button(card, $"Улучшить до {lvl + 1} ур.", UIKit.Theme.Green, () => + { + if (!gp.CanUpgradeHero) { UIKit.Toast("Не хватает ресурсов"); return; } + gp.UpgradeHero(); + Rebuild(); + }, -1, 80, 26); + live.Add(() => up.interactable = gp.CanUpgradeHero); + } + + UIKit.Button(card, "Подробнее о герое", UIKit.Theme.Blue, () => + { + Close(); + if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Hero"); + }, -1, 80, 26); + } + // Резерв для найма на уровне Ратуши (те же числа, что у модуля Населения) static int ReserveAt(int townHallLevel) { @@ -372,21 +501,138 @@ public static class BuildingWindow } Header(card, $"Улучшение до {level + 1} ур."); UIKit.Text(card, effect, 24, UIKit.Theme.Text, TextAlignmentOptions.Center); + if (focus != null && TimerRunning(focus)) return; // улучшение уже идёт -- полоска сверху var cost = config.UpgradeCost(level); UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center); + long ms = config.ConstructionMs(false, level + 1); + TimeText(card, ms); + var building = focus; bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost); var b = UIKit.Button(card, $"Улучшить до {level + 1} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () => { + bool timed = ms > 0 && building != null; + if (timed && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; } if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; } - raiseLevel(); - if (CitySync.Instance != null) CitySync.Instance.SyncNow(); - UIKit.Toast($"Улучшено до {level + 1} уровня"); + if (timed) + { + long now = GameClock.UnixNowMs; + BuildTimer.Begin(building, BuildTimer.Kind.Upgrade, level + 1, now, now + ms); + UIKit.Toast($"Улучшение до {level + 1} уровня началось"); + } + else + { + raiseLevel(); + if (CitySync.Instance != null) CitySync.Instance.SyncNow(); + UIKit.Toast($"Улучшено до {level + 1} уровня"); + } Rebuild(); }, -1, 80, 26); b.interactable = canPay; } + // ---------------- Стройка по времени (BuildTimer) ---------------- + + static bool TimerRunning(GameObject building) + { + var t = BuildTimer.Of(building); + return t != null && BuildTimer.All.Contains(t); + } + + static void TimeText(RectTransform card, long ms) + { + if (ms <= 0) return; + Live(card, () => $"Время: {GameData.FormatTime(ms / 1000.0)} • строителей свободно: {BuildTimer.FreeBuilders} из {BuildTimer.Builders}", + 22, UIKit.Theme.TextDim); + } + + // Полоска стройки или улучшения, сколько осталось, ускорение за кристаллы + static void TimerBlock(RectTransform card, GameObject building) + { + var t = BuildTimer.Of(building); + if (t == null || !BuildTimer.All.Contains(t)) return; + Header(card, t.IsConstruction ? "Идёт стройка" : $"Идёт улучшение до {t.targetLevel} ур."); + var bar = UIKit.Bar(card, t.Progress, UIKit.Theme.Gold, 26); + live.Add(() => { if (t != null) UIKit.SetBar(bar, t.Progress); }); + Live(card, () => t != null ? "Осталось " + GameData.FormatTime(t.SecondsLeft) : "Готово", 24, UIKit.Theme.Text); + + int perGem = Mathf.Max(1, EconomyConfig.Load().construction.secondsPerGem); + Func gems = () => t != null ? Mathf.Max(1, Mathf.CeilToInt((float)t.SecondsLeft / perGem)) : 0; + var speed = UIKit.Button(card, "", UIKit.Theme.Blue, () => + { + if (t == null) return; + int price = gems(); + if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gems, price)) + { + UIKit.Toast($"Нужно {price} кристаллов"); + return; + } + t.Complete(); // окно перестроит OnBuildCompleted + }, -1, 80, 26); + live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true); }); + UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true); + } + + // ---------------- Перенос и снос ---------------- + + static void ActionsBlock(RectTransform card, GameObject building) + { + var id = building.GetComponent(); + if (id == null || string.IsNullOrEmpty(id.buildingName)) return; // Ратуша и стартовые постройки сцены + var row = UIKit.Row(card, 80, 12); + var move = UIKit.Button(row, "Перенести", UIKit.Theme.Blue, () => + { + Close(); + if (BuildModeController.Instance != null) BuildModeController.Instance.StartMove(building); + }, -1, 80, 26); + UIKit.Size(move, -1, 80, 1); + + float share = EconomyConfig.Load().construction.demolishRefund; + var demolish = UIKit.Button(row, "Снести", UIKit.Theme.Red, () => + { + UIKit.Confirm("Снести здание?", + $"«{id.buildingName}» будет снесено. Вернётся {Mathf.RoundToInt(share * 100)}% цены постройки: {RefundText(id.buildingName, share)}.", + "Снести", () => Demolish(building)); + }, -1, 80, 26); + UIKit.Size(demolish, -1, 80, 1); + } + + static ResourceAmount[] Refund(string buildingName, float share) + { + var data = BuildManager.Instance != null ? BuildManager.Instance.FindData(buildingName) : null; + if (data == null || data.cost == null) return new ResourceAmount[0]; + var result = new ResourceAmount[data.cost.Length]; + for (int i = 0; i < result.Length; i++) result[i] = new ResourceAmount(data.cost[i].type, Mathf.Floor(data.cost[i].amount * share)); + return result; + } + + static string RefundText(string buildingName, float share) + { + var parts = new List(); + foreach (var a in Refund(buildingName, share)) if (a.Amount > 0) parts.Add($"{a.Amount:0} {GameData.ResName(a.Type)}"); + return parts.Count > 0 ? string.Join(", ", parts.ToArray()) : "ничего"; + } + + static void Demolish(GameObject building) + { + if (building == null) return; + var id = building.GetComponent(); + if (id == null || string.IsNullOrEmpty(id.buildingName)) return; + string name = id.buildingName; + if (GameServices.TryGet(out IEconomy economy)) + foreach (var a in Refund(name, EconomyConfig.Load().construction.demolishRefund)) + if (a.Amount > 0) economy.Add(a.Type, a.Amount); + + var timer = building.GetComponent(); + if (timer != null) timer.Discard(); // строитель свободен сразу + id.buildingName = null; // сохранение и пересчёт города в этом кадре его уже не видят + UnityEngine.Object.Destroy(building); + Close(); + if (CitySync.Instance != null) CitySync.Instance.SyncNow(); + if (BuildGrid.Instance != null) BuildGrid.Instance.Refresh(); + UIKit.Toast($"Снесено: {name}"); + } + // Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть static string CostText(ResourceAmount[] cost) { diff --git a/Assets/Scripts/App/CitySync.cs b/Assets/Scripts/App/CitySync.cs index c0250c4..3d6bb37 100644 --- a/Assets/Scripts/App/CitySync.cs +++ b/Assets/Scripts/App/CitySync.cs @@ -5,7 +5,8 @@ using UnityEngine; // Слой приложения: сообщает модулям, какие здания стоят в городе. // - рабочие места (Лес, Еда, Камень, Металл, Золото) и их уровни -> модуль добычи (места и эффективность); // - Дома (здание "Население") -> модуль Населения (предел жилья); -// - Склады и Амбары -> лимиты склада в Экономике. +// - Склады и Амбары -> лимиты склада в Экономике и защита ресурсов от грабежа (App/ResourceProtection). +// Здания, которые ещё строятся (BuildTimer), не считаются: они пока не работают. // Модули сами здания не ищут: они без Unity и живут и на карте мира, где города нет. // // Проверяет город раз в полсекунды и применяет изменения, только когда картина устоялась @@ -67,6 +68,7 @@ public class CitySync : MonoBehaviour foreach (var placed in FindObjectsByType(FindObjectsSortMode.None)) { if (string.IsNullOrEmpty(placed.buildingName)) continue; // убирается в этом кадре + if (BuildTimer.IsConstructing(placed)) continue; // ещё строится -- не работает var rc = placed.GetComponent(); if (rc != null) @@ -112,6 +114,7 @@ public class CitySync : MonoBehaviour 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)); } + ResourceProtection.SetLevels(city.WarehouseLevels, city.GranaryLevels); // защита от грабежа var production = GameModules.Production; var population = GameModules.Population; diff --git a/Assets/Scripts/App/EconomyConfig.cs b/Assets/Scripts/App/EconomyConfig.cs index 9682259..205d2cd 100644 --- a/Assets/Scripts/App/EconomyConfig.cs +++ b/Assets/Scripts/App/EconomyConfig.cs @@ -123,6 +123,47 @@ public class EconomyConfig : ScriptableObject public int[] requiredBarracksLevel = { 1, 2, 3, 5 }; } + [Serializable] + public class ConstructionSettings // время стройки и улучшения, строители (01.11, ТЕСТ) + { + [Tooltip("Сколько строек и улучшений идёт одновременно (строители). ТЕСТ")] + public int builders = 2; + [Tooltip("Постройка нового здания, секунд. ТЕСТ")] + public int buildSeconds = 60; + [Tooltip("Улучшение до уровня 2, 3, 4... (секунд): [0] -- до 2 ур., [1] -- до 3 ур. ... ТЕСТ")] + public int[] upgradeSecondsToLevel = { 60, 120, 300, 600, 1200, 1800, 3600, 5400, 7200 }; + [Tooltip("Ратуша строится дольше остальных: множитель времени улучшения. ТЕСТ")] + public float townHallTimeMultiplier = 2f; + [Tooltip("Ускорение: секунд стройки за 1 кристалл. ТЕСТ")] + public int secondsPerGem = 30; + [Tooltip("Снос здания возвращает такую долю цены постройки (0.5 -- половину). ТЕСТ")] + [Range(0f, 1f)] public float demolishRefund = 0.5f; + } + + [Serializable] + public class BuildingLimit + { + public string buildingName; + [Tooltip("Сколько таких зданий можно иметь на Ратуше 1, 2, 3 ... уровня. 0 -- нельзя")] + public int[] maxByTownHall; + + public BuildingLimit() { } + public BuildingLimit(string name, params int[] max) { buildingName = name; maxByTownHall = max; } + } + + [Serializable] + public class ProtectionSettings // защита ресурсов от грабежа (01.11, ТЕСТ) + { + [Tooltip("Защищено без Складов: дерево, камень, металл, золото. ТЕСТ")] + public float baseGoods = 100f; + [Tooltip("+ к защите дерева, камня, металла, золота за каждый уровень Склада. ТЕСТ")] + public float perWarehouseLevel = 200f; + [Tooltip("Защищено без Амбаров: еда. ТЕСТ")] + public float baseFood = 100f; + [Tooltip("+ к защите еды за каждый уровень Амбара. ТЕСТ")] + public float perGranaryLevel = 300f; + } + [Serializable] public class StartResources { @@ -156,6 +197,30 @@ public class EconomyConfig : ScriptableObject [Header("Солдат T1 (этап 5)")] public SoldierSettings soldier = new SoldierSettings(); + [Header("Стройка: время, строители, снос (ТЕСТ)")] + public ConstructionSettings construction = new ConstructionSettings(); + + [Header("Лимит зданий по уровню Ратуши (ТЕСТ). Здания не из списка -- без лимита")] + public BuildingLimit[] buildingLimits = + { + // Ратуша: 1 2 3 4 5 6 7 8 9 10 + new BuildingLimit("Лес", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7), + new BuildingLimit("Еда", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7), + new BuildingLimit("Камень", 2, 2, 3, 3, 4, 4, 5, 5, 6, 6), + new BuildingLimit("Металл", 0, 0, 1, 1, 2, 2, 3, 3, 4, 4), + new BuildingLimit("Золото", 0, 1, 1, 2, 2, 3, 3, 3, 4, 4), + new BuildingLimit("Население", 3, 4, 5, 6, 7, 8, 9, 10, 11, 12), + new BuildingLimit("Склад", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4), + new BuildingLimit("Амбар", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4), + new BuildingLimit("Казармы", 1, 1, 2, 2, 3, 3, 3, 3, 3, 3), + new BuildingLimit("Госпиталь", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + new BuildingLimit("Терем богатыря", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 1, 1), + }; + + [Header("Защита ресурсов от грабежа (ТЕСТ)")] + public ProtectionSettings protection = new ProtectionSettings(); + // ---------------- Загрузка ---------------- static EconomyConfig cached; @@ -189,6 +254,49 @@ public class EconomyConfig : ScriptableObject } } + // Сколько зданий с таким именем можно иметь на этом уровне Ратуши. -1 -- без лимита (здания нет в списке) + public int BuildingLimitFor(string buildingName, int townHallLevel) + { + if (buildingLimits == null || string.IsNullOrEmpty(buildingName)) return -1; + foreach (var l in buildingLimits) + { + if (l == null || l.buildingName != buildingName || l.maxByTownHall == null || l.maxByTownHall.Length == 0) continue; + return Mathf.Max(0, l.maxByTownHall[Mathf.Clamp(townHallLevel, 1, l.maxByTownHall.Length) - 1]); + } + return -1; + } + + // Время стройки (targetLevel = 1) или улучшения до targetLevel, мс. Ратуша -- с множителем + public long ConstructionMs(bool townHall, int targetLevel) + { + var c = construction; + double seconds; + if (targetLevel <= 1) seconds = c.buildSeconds; + else + { + var t = c.upgradeSecondsToLevel; + seconds = t == null || t.Length == 0 ? c.buildSeconds : t[Mathf.Clamp(targetLevel - 2, 0, t.Length - 1)]; + } + if (townHall) seconds *= Mathf.Max(0.01f, c.townHallTimeMultiplier); + return (long)(Math.Max(0, seconds) * 1000.0); + } + + // Сколько ресурса не отнять при грабеже: часть запаса, которую прячут Склады и Амбары + public float GetProtected(ResourceType type, int warehouseLevels, int granaryLevels) + { + var p = protection; + switch (type) + { + case ResourceType.Wood: + case ResourceType.Stone: + case ResourceType.Iron: + case ResourceType.Gold: return p.baseGoods + p.perWarehouseLevel * warehouseLevels; + case ResourceType.Food: return p.baseFood + p.perGranaryLevel * granaryLevels; + case ResourceType.Gems: return float.PositiveInfinity; // кристаллы не грабятся + default: return 0f; + } + } + // Цена улучшения здания с уровня level на level + 1 public ResourceAmount[] UpgradeCost(int level) { diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index c588a24..55eaa2b 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -80,6 +80,17 @@ public static class GameModules StorageBuilding.Tapped += BuildingWindow.OnStorageTapped; TownHall.Tapped -= BuildingWindow.OnTownHallTapped; TownHall.Tapped += BuildingWindow.OnTownHallTapped; + + // Стройка и улучшение по времени, строители (01.11, ТЕСТ): числа -- EconomyConfig, раздел "Стройка" + BuildTimer.Builders = Mathf.Max(1, config.construction.builders); + BuildTimer.DurationMs = (townHall, level) => EconomyConfig.Load().ConstructionMs(townHall, level); + BuildTimer.Tapped -= OnBuildTimerTapped; + BuildTimer.Tapped += OnBuildTimerTapped; + BuildTimer.Completed -= OnBuildCompleted; + BuildTimer.Completed += OnBuildCompleted; + + // Защита ресурсов от грабежа (01.11, ТЕСТ): сколько у города нельзя унести в набеге + GameServices.Register(new ResourceProtection()); EventBus.Subscribe(_ => population.OnGameLoaded(GameClock.UnixNow)); // офлайн-прогресс EventBus.Subscribe(e => population.Tick(e.UnixNow)); // перемотка (отладка) EventBus.Subscribe(_ => population.Changed()); // воины -- тоже люди @@ -103,6 +114,9 @@ public static class GameModules GameServices.Register(new HospitalBeds()); EventBus.Subscribe(_ => HospitalBootstrap.Ensure()); + // Терем богатыря: дом героя, герой гуляет по городу (Meta/Build/HeroCastle, HeroWalker) + EventBus.Subscribe(_ => HeroCastleBootstrap.Ensure()); + // Статистика для заданий (GameProgress) -- по событиям Мира EventBus.Subscribe(OnMarchStarted); EventBus.Subscribe(OnMarchReturned); @@ -114,6 +128,21 @@ public static class GameModules // Сцена загружена, Awake всех объектов прошли, Start -- ещё нет (сохранение грузится в Start) [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void OnBuildTimerTapped(BuildTimer timer) + { + if (timer != null) BuildingWindow.Open(timer.gameObject); + } + + static void OnBuildCompleted(BuildTimer timer) + { + if (timer == null) return; + if (CitySync.Instance != null) CitySync.Instance.SyncNow(); + var id = timer.GetComponent(); + string name = timer.GetComponent() != null ? "Ратуша" : id != null ? id.buildingName : timer.name; + UIKit.Toast(timer.IsConstruction ? $"Построено: {name}" : $"{name}: улучшено до {timer.targetLevel} уровня"); + BuildingWindow.OnBuildCompleted(timer); + } + static void MigrateLegacyMarches() { // Раньше походы хранились в progress.json (GameProgress.Data.sashaMarches). diff --git a/Assets/Scripts/App/ResourceProtection.cs b/Assets/Scripts/App/ResourceProtection.cs new file mode 100644 index 0000000..cc3b8da --- /dev/null +++ b/Assets/Scripts/App/ResourceProtection.cs @@ -0,0 +1,27 @@ +using UnityEngine; + +// Защита ресурсов от грабежа (контракт IResourceProtection, 01.11, ТЕСТ). +// Часть запаса прячут Склады и Амбары: EconomyConfig, раздел "Защита ресурсов". Уровни зданий сообщает +// App/CitySync; на карте мира, где города нет, действуют последние известные уровни. +public sealed class ResourceProtection : IResourceProtection +{ + public static int WarehouseLevels { get; private set; } + public static int GranaryLevels { get; private set; } + + public static void SetLevels(int warehouseLevels, int granaryLevels) + { + WarehouseLevels = Mathf.Max(0, warehouseLevels); + GranaryLevels = Mathf.Max(0, granaryLevels); + } + + public float Protected(ResourceType type) + { + return EconomyConfig.Load().GetProtected(type, WarehouseLevels, GranaryLevels); + } + + public float Lootable(ResourceType type) + { + if (!GameServices.TryGet(out IEconomy economy)) return 0f; + return Mathf.Max(0f, economy.Get(type) - Protected(type)); + } +} diff --git a/Assets/Scripts/App/ResourceProtection.cs.meta b/Assets/Scripts/App/ResourceProtection.cs.meta new file mode 100644 index 0000000..2d99796 --- /dev/null +++ b/Assets/Scripts/App/ResourceProtection.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cea38890d8f5d8d4f804c095c4c113b4 \ No newline at end of file diff --git a/Assets/Scripts/Meta/Build/BuildModeController.cs b/Assets/Scripts/Meta/Build/BuildModeController.cs index b57e557..06d1c31 100644 --- a/Assets/Scripts/Meta/Build/BuildModeController.cs +++ b/Assets/Scripts/Meta/Build/BuildModeController.cs @@ -13,6 +13,10 @@ using TMPro; // 4. Внизу панель: "Поставить" (если место свободно и хватает ресурсов), "Другое здание", "Отмена". // 5. После постройки режим остаётся включённым -- можно строить дальше. "Выйти" -- сетка прячется. // +// 6. Лимит зданий по уровню Ратуши (EconomyConfig, ТЕСТ): в списке видно "2 / 3", сверх лимита строить нельзя. +// 7. Стройка идёт по времени и занимает строителя (BuildTimer): все заняты -- поставить нельзя. +// 8. Перенос (кнопка "Перенести" в окне здания): тот же призрак, но здание переезжает бесплатно. +// // Вешается билдером (Tools -> Build HUD Panels) на HUD_Canvas, кнопка Nav_Build вызывает ToggleMode(). [DefaultExecutionOrder(-50)] // раньше камеры: захват пальца призраком должен быть виден ей в том же кадре public class BuildModeController : MonoBehaviour @@ -33,6 +37,7 @@ public class BuildModeController : MonoBehaviour Button placeButton; BuildGhost ghost; + GameObject moving; // здание, которое переносим (null -- строим новое) bool dragging; Vector2 grabOffset; Vector2 downPos; @@ -145,6 +150,32 @@ public class BuildModeController : MonoBehaviour // ---------------- призрак ---------------- + // Перенос построенного здания: призрак на его месте, "Сюда" -- переезжает бесплатно + public void StartMove(GameObject building) + { + var id = building != null ? building.GetComponent() : null; + var data = id != null && BuildManager.Instance != null ? BuildManager.Instance.FindData(id.buildingName) : null; + if (data == null) { UIKit.Toast("Это здание перенести нельзя"); return; } + + if (!IsActive) + { + IsActive = true; + UpdateButtonLabel(); + BuildGrid.Instance.ShowOverlay(true); + if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll(); + if (banner == null) banner = CreateBanner(); + } + CloseChooser(); + CancelGhost(); + + moving = building; + BuildGrid.IgnoredBuilding = building; // его клетки считаются свободными, пока выбираем новое место + BuildGrid.Instance.Refresh(); + ghost = BuildGhost.Create(data); + ghost.MoveNear((Vector2)building.transform.position + ghost.Footprint.anchorOffset); + CreatePlacePanel(); + } + void StartGhost(BuildingData data) { CancelGhost(); @@ -166,6 +197,12 @@ public class BuildModeController : MonoBehaviour void CancelGhost() { StopDrag(); + if (moving != null || BuildGrid.IgnoredBuilding != null) + { + moving = null; + BuildGrid.IgnoredBuilding = null; // здание остаётся на старом месте + if (BuildGrid.Instance != null) BuildGrid.Instance.Refresh(); + } if (ghost != null) Destroy(ghost.gameObject); ghost = null; if (placePanel != null) Destroy(placePanel); @@ -178,11 +215,24 @@ public class BuildModeController : MonoBehaviour var d = ghost.Data; ghost.Recheck(); if (!ghost.CellsFree) { UIKit.Toast("Здесь строить нельзя: клетки заняты"); return; } + + if (moving != null) + { + var building = moving; + building.transform.position = ghost.PrefabPosition; + CancelGhost(); // снимает "игнор" клеток -- здание уже на новом месте + UIKit.Toast("Перенесено: " + d.buildingName); + return; + } + + string limit = LimitProblem(d); + if (limit != null) { UIKit.Toast(limit); return; } + if (BuildTimer.DurationMs(false, 1) > 0 && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; } if (!d.CanAfford()) { UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost))); return; } if (BuildManager.Instance.BuildAt(d, ghost.Cell) != null) { - UIKit.Toast("Построено: " + d.buildingName); + UIKit.Toast(BuildTimer.DurationMs(false, 1) > 0 ? "Стройка началась: " + d.buildingName : "Построено: " + d.buildingName); CancelGhost(); // режим остаётся: "Здания" в подсказке сверху -- строить дальше } else UIKit.Toast("Не получилось построить"); @@ -202,8 +252,8 @@ public class BuildModeController : MonoBehaviour placeInfo = UIKit.Text(bar, "", 26, Color.white, TextAlignmentOptions.Left); UIKit.Size(placeInfo, -1, -1, 1); - placeButton = UIKit.Button(bar, "Поставить", UIKit.Theme.Green, Place, 230, 90, 28); - UIKit.Button(bar, "Другое", UIKit.Theme.Blue, OpenChooser, 180, 90, 26); + placeButton = UIKit.Button(bar, moving != null ? "Сюда" : "Поставить", UIKit.Theme.Green, Place, 230, 90, 28); + if (moving == null) UIKit.Button(bar, "Другое", UIKit.Theme.Blue, OpenChooser, 180, 90, 26); UIKit.Button(bar, "Отмена", UIKit.Theme.Gray, CancelGhost, 180, 90, 26); RefreshPlacePanel(); } @@ -212,12 +262,22 @@ public class BuildModeController : MonoBehaviour { if (ghost == null || placeInfo == null) return; var d = ghost.Data; - bool afford = d.CanAfford(); string where = ghost.CellsFree ? "место свободно" : "клетки заняты"; + if (moving != null) + { + placeInfo.text = "Перенос: " + d.buildingName + " " + ghost.Footprint.size.x + "×" + ghost.Footprint.size.y + + " кл. • " + where + "\nБесплатно"; + UIKit.SetButton(placeButton, ghost.CellsFree ? "Сюда" : "Занято", ghost.CellsFree); + return; + } + bool afford = d.CanAfford(); + string limit = LimitProblem(d); + bool builders = BuildTimer.DurationMs(false, 1) <= 0 || BuildTimer.FreeBuilders > 0; placeInfo.text = "" + d.buildingName + " " + ghost.Footprint.size.x + "×" + ghost.Footprint.size.y + - " кл. • " + where + "\n" + CostText(d); - bool ok = ghost.CellsFree && afford; - UIKit.SetButton(placeButton, ok ? "Поставить" : (ghost.CellsFree ? "Мало ресурсов" : "Занято"), ok); + " кл. • " + where + "\n" + CostText(d) + BuildTimeText(); + bool ok = ghost.CellsFree && afford && limit == null && builders; + string label = ok ? "Поставить" : !ghost.CellsFree ? "Занято" : limit != null ? "Лимит" : !builders ? "Строители заняты" : "Мало ресурсов"; + UIKit.SetButton(placeButton, label, ok); } // ---------------- окно выбора здания ---------------- @@ -239,7 +299,8 @@ public class BuildModeController : MonoBehaviour card.offsetMin = Vector2.zero; card.offsetMax = Vector2.zero; int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1; - UIKit.Text(card, "Ратуша " + th + " ур. • выберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + UIKit.Text(card, "Ратуша " + th + " ур. • строителей свободно: " + BuildTimer.FreeBuilders + " из " + BuildTimer.Builders + + "\nвыберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); var holder = UIKit.Make("ListHolder", card); UIKit.Size(holder, -1, -1).flexibleHeight = 1; @@ -254,7 +315,10 @@ public class BuildModeController : MonoBehaviour foreach (var data in all) { var d = data; - bool locked = d.requiredTownHallLevel > th; + int max = EconomyConfig.Load().BuildingLimitFor(d.buildingName, th); + int have = CountBuilt(d.buildingName); + bool full = max >= 0 && have >= max; + bool locked = d.requiredTownHallLevel > th || full; bool afford = !locked && d.CanAfford(); var size = BuildFootprint.Of(d).size; @@ -265,13 +329,15 @@ public class BuildModeController : MonoBehaviour var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4); UIKit.Size(info, -1, -1, 1); - UIKit.Text(info, d.buildingName + " " + size.x + "×" + size.y + " кл.", 30, + string count = max >= 0 ? " построено " + have + " / " + max + "" : ""; + UIKit.Text(info, d.buildingName + " " + size.x + "×" + size.y + " кл." + count, 30, locked ? UIKit.Theme.TextDim : UIKit.Theme.Text).fontStyle = FontStyles.Bold; - UIKit.Text(info, locked ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня" : CostText(d), 24, - locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim); + string why = d.requiredTownHallLevel > th ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня" + : full ? NextLimitText(d.buildingName, th, have) : CostText(d); + UIKit.Text(info, why, 24, locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim); // выбрать можно и без ресурсов: место присмотреть, а "Поставить" загорится, когда хватит - string label = locked ? "Закрыто" : "Выбрать"; + string label = d.requiredTownHallLevel > th ? "Закрыто" : full ? "Лимит" : "Выбрать"; var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () => StartGhost(d), 230, 90, 26); b.interactable = !locked; } @@ -279,6 +345,43 @@ public class BuildModeController : MonoBehaviour UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, CloseChooser, -1, 90, 30); } + // ---------------- лимит зданий по Ратуше (EconomyConfig, ТЕСТ) ---------------- + + static int CountBuilt(string buildingName) + { + int n = 0; + foreach (var id in FindObjectsByType(FindObjectsSortMode.None)) + if (id.buildingName == buildingName) n++; + return n; + } + + // null -- лимит позволяет; иначе текст для игрока + static string LimitProblem(BuildingData d) + { + int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1; + int max = EconomyConfig.Load().BuildingLimitFor(d.buildingName, th); + int have = CountBuilt(d.buildingName); + if (max < 0 || have < max) return null; + return NextLimitText(d.buildingName, th, have); + } + + static string NextLimitText(string buildingName, int th, int have) + { + var config = EconomyConfig.Load(); + for (int lvl = th + 1; lvl <= 10; lvl++) + if (config.BuildingLimitFor(buildingName, lvl) > have) + return "Лимит " + have + " / " + config.BuildingLimitFor(buildingName, th) + ": больше — на Ратуше " + lvl + " ур."; + return "Достигнут лимит: " + have; + } + + static string BuildTimeText() + { + long ms = BuildTimer.DurationMs(false, 1); + if (ms <= 0) return ""; + string free = BuildTimer.FreeBuilders > 0 ? "" : " все строители заняты"; + return " • стройка " + GameData.FormatTime(ms / 1000.0) + free; + } + void CloseChooser() { if (chooser != null) Destroy(chooser); diff --git a/Assets/Scripts/Meta/Build/HeroCastle.cs b/Assets/Scripts/Meta/Build/HeroCastle.cs new file mode 100644 index 0000000..11251d4 --- /dev/null +++ b/Assets/Scripts/Meta/Build/HeroCastle.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using UnityEngine; + +// ТЕРЕМ БОГАТЫРЯ -- дом героя в городе. +// - Пока Терем построен, у его ворот появляется герой и гуляет по свободным клеткам города (HeroWalker). +// Тап по герою или по Терему -- окно героя (App/BuildingWindow): уровень, сила, бонус армии, навыки, улучшение. +// - Механику героя не меняет: уровень, цена улучшения и бонусы те же, что в панели "Герой" (GameProgress). +// Нужен ли Терем для улучшения героя, даёт ли уровни и сколько стоит -- решает Дима (сейчас ТЕСТ). +// - Терем один на город: второй ничего не добавляет, герой живёт у первого. +// Вешается на копию обычного здания (HeroCastleBootstrap). +public class HeroCastle : MonoBehaviour +{ + public static readonly List All = new List(); + public static int Count => All.Count; + public static HeroCastle Main => All.Count > 0 ? All[0] : null; + + void OnEnable() + { + if (!All.Contains(this)) All.Add(this); + } + + void OnDisable() + { + All.Remove(this); + } + + void Start() + { + if (Main == this) HeroWalker.Spawn(this); + } + + void OnDestroy() + { + All.Remove(this); + HeroWalker.OnCastleRemoved(this); + } + + Vector3 downPos; + + void OnMouseDown() + { + downPos = Input.mousePosition; + } + + // Тап (а не перетаскивание камеры) -- окно героя + void OnMouseUpAsButton() + { + if (UIBlocker.IsPointerOverUI()) return; + if ((Input.mousePosition - downPos).magnitude > 25f) return; + BuildingWindow.Open(gameObject); + } +} diff --git a/Assets/Scripts/Meta/Build/HeroCastle.cs.meta b/Assets/Scripts/Meta/Build/HeroCastle.cs.meta new file mode 100644 index 0000000..f5753e1 --- /dev/null +++ b/Assets/Scripts/Meta/Build/HeroCastle.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b3edb04b487c1554a91d69b16f7876e4 \ No newline at end of file diff --git a/Assets/Scripts/Meta/Build/HeroCastleBootstrap.cs b/Assets/Scripts/Meta/Build/HeroCastleBootstrap.cs new file mode 100644 index 0000000..7be33e8 --- /dev/null +++ b/Assets/Scripts/Meta/Build/HeroCastleBootstrap.cs @@ -0,0 +1,65 @@ +using UnityEngine; + +// Добавляет «Терем богатыря» в список стройки САМ при запуске сцены (как Казармы и Госпиталь). +// Срабатывает до восстановления зданий из сохранения (SettlementRestoringEvent), поэтому построенный +// Терем корректно восстанавливается из save.json. Цена -- ТЕСТ (согласовать с Димой). +public static class HeroCastleBootstrap +{ + public const string CastleName = "Терем богатыря"; + public const int CostWood = 400; // ТЕСТ + public const int CostStone = 300; // ТЕСТ + static GameObject runtimeTemplate; + + [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; + + BuildingData template = null; + foreach (var b in bm.allBuildings) + { + if (b == null) continue; + if (b.buildingName == CastleName) return; // уже есть + if (template == null && b.prefab != null) template = b; + } + if (template == null) { Debug.LogWarning("[ТЕРЕМ] Нет ни одного здания-шаблона в BuildManager"); return; } + + // Префаб-шаблон в памяти: копия обычного здания без сбора ресурсов, с компонентом HeroCastle + if (runtimeTemplate == null) + { + runtimeTemplate = Object.Instantiate(template.prefab); + runtimeTemplate.name = "HeroCastle"; + runtimeTemplate.SetActive(false); + Object.DontDestroyOnLoad(runtimeTemplate); + foreach (var c in runtimeTemplate.GetComponents()) Object.DestroyImmediate(c); + foreach (var c in runtimeTemplate.GetComponents()) Object.DestroyImmediate(c); + foreach (var c in runtimeTemplate.GetComponents()) Object.DestroyImmediate(c); + for (int i = runtimeTemplate.transform.childCount - 1; i >= 0; i--) + Object.DestroyImmediate(runtimeTemplate.transform.GetChild(i).gameObject); // полоска запаса ресурса + var sr = runtimeTemplate.GetComponent(); + if (sr != null) sr.color = new Color(1f, 0.85f, 0.35f); // пока нет арта -- отличаем цветом (золотой) + runtimeTemplate.AddComponent(); + } + + var data = ScriptableObject.CreateInstance(); + data.name = "Data_HeroCastle_Runtime"; + data.buildingName = CastleName; + data.icon = template.icon; + data.prefab = runtimeTemplate; + data.requiredTownHallLevel = 1; // ТЕСТ + data.cost = new[] + { + new ResourceCost { type = ResourceType.Wood, amount = CostWood }, + new ResourceCost { type = ResourceType.Stone, amount = CostStone }, + }; + bm.allBuildings.Add(data); + Debug.Log("[ТЕРЕМ] Добавлен в список стройки"); + } +} diff --git a/Assets/Scripts/Meta/Build/HeroCastleBootstrap.cs.meta b/Assets/Scripts/Meta/Build/HeroCastleBootstrap.cs.meta new file mode 100644 index 0000000..86fb8bd --- /dev/null +++ b/Assets/Scripts/Meta/Build/HeroCastleBootstrap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b1671b6bc9bd9174f9a8f213d07ef757 \ No newline at end of file diff --git a/Assets/Scripts/Meta/Build/HeroWalker.cs b/Assets/Scripts/Meta/Build/HeroWalker.cs new file mode 100644 index 0000000..233bb71 --- /dev/null +++ b/Assets/Scripts/Meta/Build/HeroWalker.cs @@ -0,0 +1,203 @@ +using UnityEngine; + +// ГЕРОЙ В ГОРОДЕ -- живёт у Терема богатыря и гуляет по свободным клеткам рядом с ним. +// - Пока без арта: фигурка из простых форм (тело, голова, щит) цвета героя. Арт подставим в BuildFigure. +// - Идёт к случайной свободной клетке в радиусе WalkRadius от Терема, стоит 1-3 с, идёт дальше. +// - Тап по герою -- он отвечает фразой и открывается окно героя. +// - В режиме стройки герой прячется, чтобы не мешать ставить здания. +// Это только оживление города: на экономику, бой и сохранение не влияет. +public class HeroWalker : MonoBehaviour +{ + public static HeroWalker Instance { get; private set; } + + const float Speed = 1.3f; // клеток в секунду + const int WalkRadius = 6; // насколько далеко уходит от Терема + const int SortingOrder = 40; // поверх зданий + + static readonly string[] Phrases = + { + "Город под защитой!", + "Веди, воевода, — не подведу.", + "Силушка богатырская при мне.", + "Дозором обхожу владения.", + "Есть ли где ворог? Только скажи.", + }; + + HeroCastle castle; + Vector3 target; + float waitLeft; + Transform figure; + Vector3 downPos; + BoxCollider2D tapArea; + + public static void Spawn(HeroCastle home) + { + if (Instance != null) { Instance.castle = home; return; } + var go = new GameObject("Hero"); + var w = go.AddComponent(); + w.castle = home; + w.BuildFigure(); + go.transform.position = w.Gate(); + w.target = go.transform.position; + w.waitLeft = 1f; + Instance = w; + } + + public static void OnCastleRemoved(HeroCastle removed) + { + if (Instance == null || Instance.castle != removed) return; + var next = HeroCastle.Main; + if (next != null) Instance.castle = next; + else Destroy(Instance.gameObject); + } + + void OnDestroy() + { + if (Instance == this) Instance = null; + } + + // ---------------- Внешний вид (заглушка) ---------------- + + void BuildFigure() + { + figure = new GameObject("Figure").transform; + figure.SetParent(transform, false); + Part("Body", Square(), new Color(0.55f, 0.4f, 0.2f), new Vector2(0f, 0.28f), new Vector2(0.38f, 0.48f), 0); + Part("Head", Circle(), new Color(1f, 0.85f, 0.7f), new Vector2(0f, 0.66f), new Vector2(0.3f, 0.3f), 1); + Part("Helmet", Circle(), new Color(0.75f, 0.75f, 0.8f), new Vector2(0f, 0.76f), new Vector2(0.32f, 0.16f), 2); + Part("Shield", Circle(), new Color(0.8f, 0.15f, 0.15f), new Vector2(0.2f, 0.28f), new Vector2(0.22f, 0.3f), 3); + Part("Shadow", Circle(), new Color(0f, 0f, 0f, 0.3f), new Vector2(0f, 0.02f), new Vector2(0.45f, 0.14f), -1); + + // Коллайдер для тапа -- чуть больше фигурки, ближе к камере, чем здания + tapArea = gameObject.AddComponent(); + tapArea.size = new Vector2(0.7f, 1f); + tapArea.offset = new Vector2(0f, 0.45f); + var p = transform.position; p.z = -0.5f; transform.position = p; + } + + SpriteRenderer Part(string name, Sprite sprite, Color color, Vector2 pos, Vector2 size, int order) + { + var t = new GameObject(name).transform; + t.SetParent(figure, false); + t.localPosition = pos; + t.localScale = new Vector3(size.x, size.y, 1f); + var sr = t.gameObject.AddComponent(); + sr.sprite = sprite; + sr.color = color; + sr.sortingOrder = SortingOrder + order; + return sr; + } + + static Sprite square, circle; + + static Sprite Square() + { + if (square != null) return square; + var tex = new Texture2D(4, 4, TextureFormat.RGBA32, false); + var px = new Color32[16]; + for (int i = 0; i < px.Length; i++) px[i] = new Color32(255, 255, 255, 255); + tex.SetPixels32(px); tex.Apply(); + square = Sprite.Create(tex, new Rect(0, 0, 4, 4), new Vector2(0.5f, 0.5f), 4); + return square; + } + + static Sprite Circle() + { + if (circle != null) return circle; + const int n = 64; + var tex = new Texture2D(n, n, TextureFormat.RGBA32, false); + var px = new Color32[n * n]; + float r = n * 0.5f; + for (int y = 0; y < n; y++) + for (int x = 0; x < n; x++) + { + float d = Mathf.Sqrt((x + 0.5f - r) * (x + 0.5f - r) + (y + 0.5f - r) * (y + 0.5f - r)); + byte a = (byte)(Mathf.Clamp01(r - d) * 255); + px[y * n + x] = new Color32(255, 255, 255, a); + } + tex.SetPixels32(px); tex.Apply(); + circle = Sprite.Create(tex, new Rect(0, 0, n, n), new Vector2(0.5f, 0.5f), n); + return circle; + } + + // ---------------- Прогулка ---------------- + + Vector3 Gate() + { + var c = castle != null ? castle.transform.position : Vector3.zero; + var grid = BuildGrid.Instance; + if (grid != null && grid.FindFreeNear(c, Vector2Int.one, out Vector2Int cell)) + { + Vector2 p = grid.RectCenter(cell, Vector2Int.one); + return new Vector3(p.x, p.y - 0.4f, -0.5f); + } + return new Vector3(c.x, c.y - 1f, -0.5f); + } + + Vector3 PickTarget() + { + var grid = BuildGrid.Instance; + if (grid == null || castle == null) return transform.position; + grid.Refresh(); + Vector2Int home = grid.WorldToCell(castle.transform.position); + for (int attempt = 0; attempt < 20; attempt++) + { + int x = home.x + Random.Range(-WalkRadius, WalkRadius + 1); + int y = home.y + Random.Range(-WalkRadius, WalkRadius + 1); + if (grid.IsBusy(x, y)) continue; + Vector2 p = grid.RectCenter(new Vector2Int(x, y), Vector2Int.one); + return new Vector3(p.x, p.y - 0.4f, -0.5f); + } + return Gate(); + } + + void Update() + { + bool building = BuildModeController.Instance != null && BuildModeController.Instance.IsActive; + if (figure.gameObject.activeSelf == building) + { + figure.gameObject.SetActive(!building); + if (tapArea != null) tapArea.enabled = !building; + } + if (building) return; + + if (waitLeft > 0f) { waitLeft -= Time.deltaTime; return; } + + Vector3 pos = transform.position; + Vector3 delta = target - pos; + delta.z = 0f; + if (delta.magnitude < 0.05f) + { + target = PickTarget(); + waitLeft = Random.Range(1f, 3f); + return; + } + Vector3 step = delta.normalized * Speed * Time.deltaTime; + if (step.magnitude > delta.magnitude) step = delta; + transform.position = pos + step; + if (Mathf.Abs(delta.x) > 0.01f) + { + var s = figure.localScale; + s.x = delta.x < 0 ? -1f : 1f; // смотрит туда, куда идёт + figure.localScale = s; + } + // лёгкое покачивание при ходьбе + figure.localPosition = new Vector3(0f, Mathf.Abs(Mathf.Sin(Time.time * 10f)) * 0.05f, 0f); + } + + // ---------------- Тап ---------------- + + void OnMouseDown() + { + downPos = Input.mousePosition; + } + + void OnMouseUpAsButton() + { + if (UIBlocker.IsPointerOverUI()) return; + if ((Input.mousePosition - downPos).magnitude > 25f) return; + UIKit.Toast("Богатырь: " + Phrases[Random.Range(0, Phrases.Length)]); + waitLeft = 2f; // остановился поговорить + if (castle != null) BuildingWindow.Open(castle.gameObject); + } +} diff --git a/Assets/Scripts/Meta/Build/HeroWalker.cs.meta b/Assets/Scripts/Meta/Build/HeroWalker.cs.meta new file mode 100644 index 0000000..7a40f74 --- /dev/null +++ b/Assets/Scripts/Meta/Build/HeroWalker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: caa21d91fd476684ba0fd70c830fe04f \ No newline at end of file diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 6580fdf..e2a4d32 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -22,6 +22,15 @@ public interface IEconomy float GetCapacity(ResourceType type); } +// Защита ресурсов от грабежа (01.11, ТЕСТ). Сколько ресурса нельзя отнять в набеге: часть запаса прячут +// Склады и Амбары. Добыча набега по ресурсу = Lootable. Реализует слой приложения (App/ResourceProtection) +// по зданиям города; модули Боя и Мира берут отсюда, сколько можно унести у города. +public interface IResourceProtection +{ + float Protected(ResourceType type); // сколько спрятано (кристаллы -- всё) + float Lootable(ResourceType type); // сколько можно унести: max(0, запас - спрятано) +} + // Добыча через рабочих (этап 3). Реализует модуль Экономики (ProductionService). public interface IProduction { diff --git a/Assets/Scripts/Modules/Settlement/BuildGrid.cs b/Assets/Scripts/Modules/Settlement/BuildGrid.cs index 497cd8a..704826e 100644 --- a/Assets/Scripts/Modules/Settlement/BuildGrid.cs +++ b/Assets/Scripts/Modules/Settlement/BuildGrid.cs @@ -34,6 +34,9 @@ public class BuildGrid : MonoBehaviour [Header("Где можно строить")] [Tooltip("Посчитать область самому: старые участки + Ратуша + запас")] public bool autoArea = true; + + // Здание, которое сейчас переносят: его клетки считаются свободными (режим стройки, перенос) + public static GameObject IgnoredBuilding; [Tooltip("Запас вокруг старых участков, клеток")] public int padding = 3; public Vector2 areaMin = new Vector2(-34, -10); @@ -218,6 +221,7 @@ public class BuildGrid : MonoBehaviour foreach (var id in FindObjectsByType(FindObjectsSortMode.None)) { if (string.IsNullOrEmpty(id.buildingName)) continue; // здание сносится в этом кадре + if (IgnoredBuilding != null && id.gameObject == IgnoredBuilding) continue; // его переносят BuildingData data = bm != null ? bm.FindData(id.buildingName) : null; Rect r; if (data != null) diff --git a/Assets/Scripts/Modules/Settlement/BuildManager.cs b/Assets/Scripts/Modules/Settlement/BuildManager.cs index 2ab74ea..f4f32fb 100644 --- a/Assets/Scripts/Modules/Settlement/BuildManager.cs +++ b/Assets/Scripts/Modules/Settlement/BuildManager.cs @@ -170,6 +170,8 @@ public class BuildManager : MonoBehaviour var grid = BuildGrid.Instance; grid.Refresh(); // занятость на этот момент, а не 0.3 с назад if (!CanBuildAt(data, cell)) return null; + long buildMs = BuildTimer.DurationMs(false, 1); + if (buildMs > 0 && BuildTimer.FreeBuilders == 0) return null; // все строители заняты data.PayCost(); @@ -180,6 +182,11 @@ public class BuildManager : MonoBehaviour GameObject built = Instantiate(data.prefab, pos, Quaternion.identity); if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным built.AddComponent().buildingName = data.buildingName; + if (buildMs > 0) + { + long now = GameClock.UnixNowMs; + BuildTimer.Begin(built, BuildTimer.Kind.Build, 1, now, now + buildMs); // стоит, но не работает, пока строится + } Debug.Log($"[СТРОЙКА] Построено: {data.buildingName}, клетки {cell} размер {fp.size}"); EventBus.Publish(new BuildingPlacedEvent { BuildingName = data.buildingName }); diff --git a/Assets/Scripts/Modules/Settlement/BuildTimer.cs b/Assets/Scripts/Modules/Settlement/BuildTimer.cs new file mode 100644 index 0000000..ec37add --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/BuildTimer.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md +// Стройка или улучшение здания, которое ещё идёт (01.11, числа -- ТЕСТ). +// - Постройка (Kind.Build): здание стоит полупрозрачным и не работает -- его скрипты выключены +// (не добывает, не даёт жильё и места, Казармы не учат). Тап по нему -- окно стройки. +// - Улучшение (Kind.Upgrade): здание работает на старом уровне, новый уровень -- когда время выйдет. +// - Одновременно идёт не больше Builders строек (строители). Ратуша -- тоже стройка. +// - Время -- часы игры (GameClock.UnixNowMs): идёт и пока игра закрыта, и при перемотке DEV. +// - Сохраняет модуль Поселения вместе со зданием (startMs, endMs, вид, целевой уровень). +// Сколько длится стройка и сколько строителей -- задаёт слой приложения (EconomyConfig): модуль чисел не знает. +public class BuildTimer : MonoBehaviour +{ + public enum Kind { Build = 1, Upgrade = 2 } + + public static readonly List All = new List(); + + // Задаёт слой приложения (App/GameModules) из EconomyConfig + public static int Builders = 2; + // Длительность в мс: (это Ратуша?, до какого уровня; 1 -- постройка). 0 -- мгновенно + public static Func DurationMs = (townHall, level) => 0; + public static int Busy => All.Count; + public static int FreeBuilders => Mathf.Max(0, Builders - Busy); + + // Тап по зданию, которое строится (его собственные скрипты выключены) -- интерфейс откроет окно + public static event Action Tapped; + // Стройка закончилась: интерфейс пересчитает город и покажет сообщение + public static event Action Completed; + + public Kind kind; + public long startMs; + public long endMs; + public int targetLevel = 1; + + readonly List paused = new List(); + readonly List> tinted = new List>(); + bool finished; + Vector3 downPos; + + public bool IsConstruction => kind == Kind.Build; + public double SecondsLeft => Math.Max(0, endMs - GameClock.UnixNowMs) / 1000.0; + public float Progress => endMs <= startMs ? 1f : Mathf.Clamp01((float)(GameClock.UnixNowMs - startMs) / (endMs - startMs)); + + public static BuildTimer Of(GameObject building) => building != null ? building.GetComponent() : null; + + public static bool IsConstructing(Component building) + { + var t = building != null ? building.GetComponent() : null; + return t != null && !t.finished && t.kind == Kind.Build; + } + + // Начать стройку или улучшение. Цену платит тот, кто вызывает. null -- уже идёт стройка этого здания + public static BuildTimer Begin(GameObject building, Kind kind, int targetLevel, long startMs, long endMs) + { + if (building == null) return null; + var old = building.GetComponent(); + if (old != null && !old.finished) return null; + if (old != null) DestroyImmediate(old); // закончившийся, ещё не убранный Destroy + var t = building.AddComponent(); + t.kind = kind; + t.targetLevel = Mathf.Max(1, targetLevel); + t.startMs = startMs; + t.endMs = Math.Max(startMs, endMs); + if (kind == Kind.Build) t.PauseBuilding(); + if (!All.Contains(t)) All.Add(t); + return t; + } + + // Закончить сейчас (ускорение за кристаллы или время вышло) + public void Complete() + { + if (finished) return; + finished = true; + All.Remove(this); + ResumeBuilding(); + + if (kind == Kind.Upgrade) + { + var townHall = GetComponent(); + if (townHall != null) + { + townHall.currentLevel = Mathf.Min(townHall.maxLevel, targetLevel); + EventBus.Publish(new TownHallLevelChangedEvent { Level = townHall.currentLevel }); + } + else + { + var leveled = GetComponent(); + if (leveled != null) leveled.Level = targetLevel; + } + } + Completed?.Invoke(this); + Destroy(this); + } + + // Убрать без результата (загрузка другого сохранения, новая игра) + public void Cancel() + { + finished = true; + All.Remove(this); + ResumeBuilding(); + DestroyImmediate(this); + } + + // Здание сносится вместе со стройкой: строитель свободен сразу, скрипты не включаем + public void Discard() + { + finished = true; + All.Remove(this); + } + + void OnEnable() + { + if (!finished && !All.Contains(this)) All.Add(this); + } + + void OnDestroy() + { + All.Remove(this); + } + + void Update() + { + if (!finished && GameClock.UnixNowMs >= endMs) Complete(); + } + + // ---------------- Здание, которое строится, не работает ---------------- + + void PauseBuilding() + { + foreach (var mb in GetComponents()) + { + if (mb == null || mb == this || !mb.enabled) continue; + if (mb is PlacedBuildingId || mb is BuildingSlotLink) continue; + mb.enabled = false; + paused.Add(mb); + } + foreach (var sr in GetComponentsInChildren(true)) + { + tinted.Add(new KeyValuePair(sr, sr.color)); + var c = sr.color; + sr.color = new Color(c.r * 0.7f, c.g * 0.7f, c.b * 0.7f, c.a * 0.55f); + } + } + + void ResumeBuilding() + { + foreach (var mb in paused) if (mb != null) mb.enabled = true; + paused.Clear(); + foreach (var kv in tinted) if (kv.Key != null) kv.Key.color = kv.Value; + tinted.Clear(); + } + + // ---------------- Тап по зданию, которое строится ---------------- + + void OnMouseDown() + { + downPos = Input.mousePosition; + } + + void OnMouseUpAsButton() + { + if (kind != Kind.Build || finished) return; // при улучшении окно открывает само здание + if (UIBlocker.IsPointerOverUI()) return; + if ((Input.mousePosition - downPos).magnitude > 25f) return; + Tapped?.Invoke(this); + } +} diff --git a/Assets/Scripts/Modules/Settlement/BuildTimer.cs.meta b/Assets/Scripts/Modules/Settlement/BuildTimer.cs.meta new file mode 100644 index 0000000..703495e --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/BuildTimer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dbfaba8641b05a9499eabe36c804fb22 \ No newline at end of file diff --git a/Assets/Scripts/Modules/Settlement/SettlementModule.cs b/Assets/Scripts/Modules/Settlement/SettlementModule.cs index 18c9370..6fd6fb2 100644 --- a/Assets/Scripts/Modules/Settlement/SettlementModule.cs +++ b/Assets/Scripts/Modules/Settlement/SettlementModule.cs @@ -16,6 +16,11 @@ public static class SettlementModule public float posX; public float posY; public int level; + // Стройка или улучшение, которое ещё идёт (01.11): 0 -- нет, 1 -- постройка, 2 -- улучшение + public int timerKind; + public long timerStartMs; + public long timerEndMs; + public int timerTargetLevel; } // Имена полей совпадают со старым save.json -- старые сохранения читаются без переделки @@ -23,6 +28,10 @@ public static class SettlementModule public class SettlementSaveData { public int townHallLevel = 1; + // Улучшение Ратуши, которое ещё идёт (01.11): timerEndMs 0 -- нет + public long townHallTimerStartMs; + public long townHallTimerEndMs; + public int townHallTimerTargetLevel; public List placedBuildings = new List(); } @@ -46,18 +55,34 @@ public static class SettlementModule static SettlementSaveData CaptureScene() { var data = new SettlementSaveData(); - if (TownHall.Instance != null) data.townHallLevel = TownHall.Instance.currentLevel; + if (TownHall.Instance != null) + { + data.townHallLevel = TownHall.Instance.currentLevel; + var thTimer = BuildTimer.Of(TownHall.Instance.gameObject); + if (thTimer != null && BuildTimer.All.Contains(thTimer)) + { + data.townHallTimerStartMs = thTimer.startMs; + data.townHallTimerEndMs = thTimer.endMs; + data.townHallTimerTargetLevel = thTimer.targetLevel; + } + } foreach (var placed in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) { if (string.IsNullOrEmpty(placed.buildingName)) continue; // уже убрано, Destroy сработает в конце кадра var leveled = placed.GetComponent(); + var timer = placed.GetComponent(); + bool timerOn = timer != null && BuildTimer.All.Contains(timer); data.placedBuildings.Add(new PlacedBuildingSaveEntry { buildingName = placed.buildingName, posX = placed.transform.position.x, posY = placed.transform.position.y, - level = leveled != null ? leveled.Level : 1 + level = leveled != null ? leveled.Level : 1, + timerKind = timerOn ? (int)timer.kind : 0, + timerStartMs = timerOn ? timer.startMs : 0, + timerEndMs = timerOn ? timer.endMs : 0, + timerTargetLevel = timerOn ? timer.targetLevel : 0 }); } return data; @@ -74,7 +99,15 @@ public static class SettlementModule EventBus.Publish(new SettlementRestoringEvent()); // например, Казармы должны попасть в список стройки - if (TownHall.Instance != null) TownHall.Instance.currentLevel = cached.townHallLevel; + if (TownHall.Instance != null) + { + TownHall.Instance.currentLevel = cached.townHallLevel; + var oldTimer = BuildTimer.Of(TownHall.Instance.gameObject); + if (oldTimer != null) oldTimer.Cancel(); + if (cached.townHallTimerEndMs > 0) + BuildTimer.Begin(TownHall.Instance.gameObject, BuildTimer.Kind.Upgrade, cached.townHallTimerTargetLevel, + cached.townHallTimerStartMs, cached.townHallTimerEndMs); + } // Сначала убираем уже заспавненные здания, чтобы не задвоить постройки ClearPlacedBuildings(); @@ -98,6 +131,11 @@ public static class SettlementModule var leveled = built.GetComponent(); if (leveled != null) leveled.Level = entry.level; + // Стройка или улучшение продолжается (если время уже вышло -- закончится в первом же кадре) + if (entry.timerKind == (int)BuildTimer.Kind.Build || entry.timerKind == (int)BuildTimer.Kind.Upgrade) + BuildTimer.Begin(built, (BuildTimer.Kind)entry.timerKind, Mathf.Max(1, entry.timerTargetLevel), + entry.timerStartMs, entry.timerEndMs); + // Помечаем слот занятым, чтобы поверх нельзя было поставить ещё одно здание BuildSlot slot = BuildManager.Instance.FindSlotAtPosition(pos); if (slot != null) @@ -124,6 +162,8 @@ public static class SettlementModule link.linkedSlot = null; } e.buildingName = null; // чтобы сохранение в этом же кадре уже не видело здание + var timer = e.GetComponent(); + if (timer != null) timer.Discard(); // строитель свободен сразу, а не в конце кадра UnityEngine.Object.Destroy(e.gameObject); } } @@ -167,7 +207,12 @@ public static class SettlementModule if (!CityLoaded) return; ClearPlacedBuildings(); - if (TownHall.Instance != null) TownHall.Instance.currentLevel = 1; + if (TownHall.Instance != null) + { + TownHall.Instance.currentLevel = 1; + var thTimer = BuildTimer.Of(TownHall.Instance.gameObject); + if (thTimer != null) thTimer.Cancel(); + } appliedToBuildManager = BuildManager.Instance; // город уже в состоянии новой игры EventBus.Publish(new TownHallLevelChangedEvent { Level = 1 }); } diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index 655b4d4..d57f593 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -36,6 +36,7 @@ | `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход; начать поход «Разведка» (`TryStartScout`). | | `IWorldMap` | Мир (`MarchManager`) | Для Разведки: границы карты, где поселение, все армии в момент времени (`ArmyOnMap`). | | `IIntelligence` | Разведка (`IntelligenceService`) | Туман (`GetVisibility`), «Обзор», известные чужие армии (`KnownContact`), `ObserveUntil`. | +| `IResourceProtection` | Город — Склады и Амбары (`App/ResourceProtection`, регистрирует `App/GameModules`) | Сколько ресурса спрятано от грабежа (`Protected`) и сколько можно унести в набеге (`Lootable`). 01.11, ТЕСТ. | | `IHospital` | Город — здание «Госпиталь» (`HospitalBeds`, регистрирует `App/GameModules`) | Мест для лечения (`Beds`) по уровню самого прокачанного госпиталя; 0 — госпиталя нет. | | `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`: состав сторон по типам, стены, бонусы. | | `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. | @@ -61,6 +62,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude | Контракт `IResourceProtection` (защита ресурсов от грабежа): для будущих набегов на города — Бой и Мир берут отсюда, сколько можно унести. | | 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `IHospital`, `HealingInfo`; `IArmy`: `WoundedTotal`, `GetWoundedWaiting`, `Healing`, `HospitalBeds`, `ReturnUnits(…, woundedUnits, nowMs)`, `ProcessHealing`; события `SoldiersHealedEvent`, `TroopsChangedEvent.Wounded`, `MarchReturnedEvent.Wounded`. | | 01.11.2026 | Claude | `IArmy`: очередь обучения (`Training`, `CanQueueTraining`, `TryQueueTraining`, `ProcessTraining`, `FinishAllTraining`), `TrainingOrderInfo`, событие `TroopsTrainedEvent`; `GameClock.UnixNowMs` (решения 31.10, п. 8). | | 01.11.2026 | Claude | `IWorld.TryStartScout`; `IsScout` у `MarchStartedEvent` и `MarchReturnedEvent` (решения 31.10, п. 2). | diff --git a/Docs/Modules/Economy.md b/Docs/Modules/Economy.md index 5a3a792..b37736a 100644 --- a/Docs/Modules/Economy.md +++ b/Docs/Modules/Economy.md @@ -109,6 +109,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude | `EconomyConfig`: разделы «Стройка» (время, строители, ускорение, возврат при сносе), «Лимит зданий по уровню Ратуши», «Защита ресурсов от грабежа» — всё ТЕСТ. Защита: без Складов 100 каждого ресурса, +200 за уровень Склада; еда 100, +300 за уровень Амбара; кристаллы не грабятся. Контракт `IResourceProtection` (`App/ResourceProtection`), в окне Склада и Амбара — «спрятано / можно унести». | | 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` «Люди» на ячейках панели. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index ac2589d..bcc41e2 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -105,6 +105,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude (Валдемар) | Поселение: стройка и улучшение по времени, 2 строителя, ускорение; лимит зданий по Ратуше; перенос и снос; защита ресурсов от грабежа (`IResourceProtection`). Терем богатыря и герой в городе. Казармы: найм карточками в ряд. Числа — ТЕСТ. Обновлены Settlement, Economy, Core. | | 01.11.2026 | Claude | Здание «Госпиталь» (решения 31.10, п. 7). Обновлены Army, Core, Settlement. | | 01.11.2026 | Claude | Решения 31.10, п. 7: раненые и госпиталь (логика; здание — следующий шаг). Обновлены Battle, Army, Population, World, Core, Settlement. | | 01.11.2026 | Claude | Решения 31.10, п. 8: очередь обучения — у Армии. Обновлены Army, Core, Population. | diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md index 8281aa7..068d591 100644 --- a/Docs/Modules/Settlement.md +++ b/Docs/Modules/Settlement.md @@ -26,6 +26,16 @@ 4. Нижняя панель: «Поставить» (место свободно и хватает ресурсов), «Другое», «Отмена». 5. После постройки режим остаётся; «Здания» сверху — строить дальше, «Выйти» — сетка прячется. +## Стройка по времени, лимиты, перенос и снос (01.11.2026, числа — ТЕСТ) + +- **Стройка и улучшение идут по времени** (`BuildTimer`). Постройка — `buildSeconds` (60 с), улучшение до уровня N — `upgradeSecondsToLevel` (60 с … 2 ч), Ратуша ×2. Время — часы игры (`GameClock.UnixNowMs`): идёт и в офлайне, и при перемотке DEV. +- **Пока здание строится,** оно полупрозрачное и не работает: его скрипты выключены, `App/CitySync` его не считает (нет мест, жилья, лимитов; Казармы не учат, Терем без героя). При улучшении здание работает на старом уровне, новый — когда время выйдет. +- **Строители:** одновременно не больше `builders` (2) строек и улучшений, Ратуша тоже занимает строителя. +- **Ускорение:** 1 кристалл за `secondsPerGem` (30) секунд оставшегося времени — кнопка в окне здания. +- **Лимит зданий по уровню Ратуши** (`EconomyConfig.buildingLimits`): в списке стройки «построено 2 / 3», сверх лимита — «Лимит: больше на Ратуше N ур.». Здания не из таблицы — без лимита. В окне Ратуши — каких зданий станет больше на следующем уровне. +- **Перенос:** кнопка «Перенести» в окне здания — режим стройки с призраком на месте здания, «Сюда» — здание переезжает бесплатно (`BuildModeController.StartMove`, `BuildGrid.IgnoredBuilding`). Стройка или улучшение продолжаются. +- **Снос:** кнопка «Снести» (с подтверждением) — возвращается `demolishRefund` (50%) цены постройки; строитель, если шла стройка, освобождается сразу. Ратушу не сносят и не переносят. + ## Контракт `ISettlement` | Свойство | Что значит | @@ -48,7 +58,7 @@ ## Сохранение -Секция `"settlement"`: `{ "townHallLevel", "placedBuildings": [ { "buildingName", "posX", "posY", "level" } ] }`. +Секция `"settlement"`: `{ "townHallLevel", "townHallTimerStartMs", "townHallTimerEndMs", "townHallTimerTargetLevel", "placedBuildings": [ { "buildingName", "posX", "posY", "level", "timerKind", "timerStartMs", "timerEndMs", "timerTargetLevel" } ] }`. Поля таймера (01.11) — стройка или улучшение, которое ещё идёт (`timerKind`: 0 — нет, 1 — постройка, 2 — улучшение). В старых сохранениях их нет — значит, ничего не строится. Сброс (`ResetState`): Ратуша 1 уровня, постройки убраны, участки освобождены, память модуля очищена. Иначе при следующем входе в город вернулись бы старые здания. @@ -82,6 +92,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 01.11.2026 | Claude | Стройка и улучшение по времени (`BuildTimer`), 2 строителя, ускорение за кристаллы, офлайн; лимит зданий по уровню Ратуши; перенос и снос зданий из окна. Сохранение: поля таймера у зданий и Ратуши (старые сохранения читаются). Терем богатыря (`HeroCastle`) и гуляющий герой. Числа — ТЕСТ, `EconomyConfig`. | | 01.11.2026 | Claude (Саша) | Новое здание «Госпиталь» в списке стройки (`Meta/Build/HospitalBootstrap.cs`, как Казармы): уровни I–V сохраняются как у остальных (`ILeveledBuilding`), тап — окно госпиталя. Места — в `HospitalConfig`. Арта нет: копия обычного здания светло-голубого цвета. | | 01.11.2026 | Claude (Саша) | Для госпиталя (решения 31.10, п. 7) в ядре есть контракт `IHospital.Beds` — его будет регистрировать здание госпиталя (уровни I–V, 20–60 мест). Здания пока нет. | | 31.10.2026 | Claude | Ратуша: тап больше не улучшает сразу, а открывает окно Ратуши (событие `TownHall.Tapped`, окно — `App/BuildingWindow`). Улучшение — кнопкой в окне, с ценой и тем, что откроется. |