diff --git a/Assets/Scripts/App/EconomyConfig.cs b/Assets/Scripts/App/EconomyConfig.cs index d4338e2..351ec30 100644 --- a/Assets/Scripts/App/EconomyConfig.cs +++ b/Assets/Scripts/App/EconomyConfig.cs @@ -39,6 +39,11 @@ public class EconomyConfig : ScriptableObject [Tooltip("Офлайн-прогресс считается не больше чем за столько часов. Не утверждено")] public int maxOfflineHours = 48; + + [Tooltip("Гражданский резерв: столько свободных жителей нельзя забрать в войско (решение команды от 19.10: 150 свободных, резерв 30 -> 120)")] + public int civilianReserve = 30; + [Tooltip("Прибавка к резерву за каждый следующий уровень Ратуши. Не утверждено")] + public int civilianReservePerTownHallLevel = 0; } [Serializable] @@ -104,8 +109,9 @@ public class EconomyConfig : ScriptableObject } [Serializable] - public class SoldierSettings // этап 5: цена солдата (пока не используется) + public class SoldierSettings // этап 5: цена солдата { + [Tooltip("Сколько жителей стоит один воин (берутся из жителей при начале обучения)")] public int people = 1; public float food = 15f; public float iron = 5f; diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index 5077ba5..c383fb2 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -146,7 +146,10 @@ public static class GameModules HousingBase = p.housingBase, HousingPerTownHallLevel = p.housingPerTownHallLevel, HousingPerHouseLevel = p.housingPerHouseLevel, - MaxOfflineHours = p.maxOfflineHours + MaxOfflineHours = p.maxOfflineHours, + CivilianReserve = p.civilianReserve, + CivilianReservePerTownHallLevel = p.civilianReservePerTownHallLevel, + PeoplePerSoldier = Mathf.Max(1, config.soldier.people) }; } diff --git a/Assets/Scripts/Camera/Cameve_Move.cs b/Assets/Scripts/Camera/Cameve_Move.cs index 85dde36..647d9a4 100644 --- a/Assets/Scripts/Camera/Cameve_Move.cs +++ b/Assets/Scripts/Camera/Cameve_Move.cs @@ -16,7 +16,9 @@ public class CameraController : MonoBehaviour void Update() { // --- Перемещение по зажатой левой кнопке мыши --- - if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI()) // не тащим камеру, когда листаем панель + if (UIBlocker.WorldInputCaptured) _isDragging = false; // палец тащит призрак здания -- камера стоит + + if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI() && !UIBlocker.WorldInputCaptured) // не тащим камеру, когда листаем панель { _lastMousePosition = Input.mousePosition; _isDragging = true; diff --git a/Assets/Scripts/Meta/Build/BuildModeController.cs b/Assets/Scripts/Meta/Build/BuildModeController.cs index 678f6c5..b57e557 100644 --- a/Assets/Scripts/Meta/Build/BuildModeController.cs +++ b/Assets/Scripts/Meta/Build/BuildModeController.cs @@ -3,14 +3,18 @@ using UnityEngine; using UnityEngine.UI; using TMPro; -// НОВАЯ СХЕМА СТРОЙКИ (как в Rise of Kingdoms): -// 1. Кнопка "СТРОЙКА" включает режим строительства -- появляются свободные участки и подсказка сверху. -// 2. Тап по свободному участку -> окно со зданиями (цена, что доступно по уровню Ратуши). -// 3. "Построить" -> здание встаёт на участок. Режим остаётся включённым, можно строить дальше. -// 4. Кнопка "ВЫЙТИ" (та же кнопка или в подсказке) -- выключает режим, участки снова прячутся. -// Вне режима пустые участки не видны и не мешают, при запуске игры ничего не открывается. +// СВОБОДНАЯ СТРОЙКА НА СЕТКЕ (игрок сам выбирает место): +// 1. Кнопка "СТРОЙКА" включает режим: на земле появляется сетка (красные клетки заняты), сверху подсказка, +// и сразу открывается список зданий. +// 2. Выбрали здание -> появляется призрак: полупрозрачное здание и пятно его клеток. +// Зелёное пятно -- можно ставить, красное -- занято или за границей сетки. +// 3. Призрак двигают пальцем (тащить за само пятно) или тапом по земле -- он прыгает туда. +// Камера в это время не ездит. Размер у зданий разный (BuildingData.footprint). +// 4. Внизу панель: "Поставить" (если место свободно и хватает ресурсов), "Другое здание", "Отмена". +// 5. После постройки режим остаётся включённым -- можно строить дальше. "Выйти" -- сетка прячется. // // Вешается билдером (Tools -> Build HUD Panels) на HUD_Canvas, кнопка Nav_Build вызывает ToggleMode(). +[DefaultExecutionOrder(-50)] // раньше камеры: захват пальца призраком должен быть виден ей в том же кадре public class BuildModeController : MonoBehaviour { public static BuildModeController Instance { get; private set; } @@ -24,14 +28,27 @@ public class BuildModeController : MonoBehaviour GameObject banner; GameObject chooser; + GameObject placePanel; + TextMeshProUGUI placeInfo; + Button placeButton; + + BuildGhost ghost; + bool dragging; + Vector2 grabOffset; Vector2 downPos; bool downOverUI; - float nextVisualRefresh; + bool downOnGhost; + float nextPanelRefresh; - static readonly Color SlotColor = new Color(0.3f, 1f, 0.4f, 0.75f); + const float TapThreshold = 25f; void Awake() { Instance = this; } - void OnDestroy() { if (Instance == this) Instance = null; } + + void OnDestroy() + { + if (Instance == this) Instance = null; + UIBlocker.WorldInputCaptured = false; + } void Start() { @@ -45,85 +62,172 @@ public class BuildModeController : MonoBehaviour { IsActive = active; CloseChooser(); - ApplySlotVisuals(); + CancelGhost(); UpdateButtonLabel(); + BuildGrid.Instance.ShowOverlay(active); if (banner != null) { Destroy(banner); banner = null; } if (active) { if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll(); banner = CreateBanner(); - if (FreeSlots().Count == 0) UIKit.Toast("Свободных участков нет. Улучшайте Ратушу и сносите старое."); + OpenChooser(); } } void Update() { - if (Time.unscaledTime >= nextVisualRefresh) - { - nextVisualRefresh = Time.unscaledTime + 0.3f; - ApplySlotVisuals(); // участки могли освободиться/заняться (снос, загрузка сохранения) - } if (!IsActive) return; // открыли любую панель HUD -- выходим из режима стройки if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "") { SetMode(false); return; } - if (chooser != null) return; + if (chooser != null || ghost == null) { StopDrag(); return; } + + HandlePointer(); + + if (Time.unscaledTime >= nextPanelRefresh) + { + nextPanelRefresh = Time.unscaledTime + 0.3f; + ghost.Recheck(); // клетки могли заняться (загрузка сохранения) или ресурсы -- прийти + RefreshPlacePanel(); + } + } + + // ---------------- палец: тащим призрак или тапаем по земле ---------------- + + void HandlePointer() + { + Vector2 world = PointerWorld(); - // Тап = нажали и отпустили почти на том же месте (чтобы перетаскивание камеры не считалось тапом) if (Input.GetMouseButtonDown(0)) { downPos = Input.mousePosition; downOverUI = UIBlocker.IsPointerOverUI(); + downOnGhost = !downOverUI && ghost.Contains(world); + if (downOnGhost) + { + dragging = true; + grabOffset = (Vector2)ghost.transform.position - world; + UIBlocker.WorldInputCaptured = true; // камера не поедет + } } + + if (dragging && Input.GetMouseButton(0)) + { + var before = ghost.Cell; + ghost.MoveNear(world + grabOffset); + if (ghost.Cell != before) RefreshPlacePanel(); + } + if (Input.GetMouseButtonUp(0)) { - Vector2 up = Input.mousePosition; - if (downOverUI || UIBlocker.IsPointerOverUI() || (up - downPos).magnitude > 25f) return; - HandleTap(up); + bool wasDragging = dragging; + StopDrag(); + if (wasDragging || downOverUI || UIBlocker.IsPointerOverUI()) return; + if (((Vector2)Input.mousePosition - downPos).magnitude > TapThreshold) return; // тащили камеру + ghost.MoveNear(world); // тап по земле -- призрак прыгает сюда + RefreshPlacePanel(); } } - void HandleTap(Vector2 screenPos) + void StopDrag() { - if (BuildManager.Instance == null) return; - var slot = BuildManager.Instance.SlotAtScreen(screenPos); - if (slot == null) return; - if (slot.isOccupied) + dragging = false; + UIBlocker.WorldInputCaptured = false; + } + + static Vector2 PointerWorld() + { + var cam = Camera.main; + if (cam == null) return Vector2.zero; + Vector3 w = cam.ScreenToWorldPoint(Input.mousePosition); + return new Vector2(w.x, w.y); + } + + // ---------------- призрак ---------------- + + void StartGhost(BuildingData data) + { + CancelGhost(); + CloseChooser(); + ghost = BuildGhost.Create(data); + + // ставим в центр экрана, а если там занято -- на ближайшее свободное место + var cam = Camera.main; + Vector2 center = cam != null ? (Vector2)cam.transform.position : Vector2.zero; + if (BuildGrid.Instance.FindFreeNear(center, ghost.Footprint.size, out var cell)) ghost.MoveToCell(cell); + else { - var id = slot.occupyingBuilding != null ? slot.occupyingBuilding.GetComponent() : null; - UIKit.Toast("Участок занят" + (id != null ? ": " + id.buildingName : "")); - return; + ghost.MoveNear(center); + UIKit.Toast("Свободного места под это здание нет"); } - OpenChooser(slot); + CreatePlacePanel(); } - // ---------------- участки ---------------- - - List FreeSlots() + void CancelGhost() { - var list = new List(); - foreach (var s in FindObjectsByType(FindObjectsSortMode.None)) if (!s.isOccupied) list.Add(s); - return list; + StopDrag(); + if (ghost != null) Destroy(ghost.gameObject); + ghost = null; + if (placePanel != null) Destroy(placePanel); + placePanel = null; } - void ApplySlotVisuals() + void Place() { - foreach (var s in FindObjectsByType(FindObjectsSortMode.None)) + if (ghost == null || BuildManager.Instance == null) return; + var d = ghost.Data; + ghost.Recheck(); + if (!ghost.CellsFree) { UIKit.Toast("Здесь строить нельзя: клетки заняты"); return; } + if (!d.CanAfford()) { UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost))); return; } + + if (BuildManager.Instance.BuildAt(d, ghost.Cell) != null) { - var sr = s.GetComponent(); - if (sr == null) continue; - bool show = IsActive && !s.isOccupied; - if (sr.enabled != show) sr.enabled = show; - if (show) sr.color = SlotColor; + UIKit.Toast("Построено: " + d.buildingName); + CancelGhost(); // режим остаётся: "Здания" в подсказке сверху -- строить дальше } + else UIKit.Toast("Не получилось построить"); + } + + // ---------------- нижняя панель: Поставить / Другое здание / Отмена ---------------- + + void CreatePlacePanel() + { + if (placePanel != null) Destroy(placePanel); + var bar = UIKit.Row(UIKit.Overlay, -1, 14, new Color(0.08f, 0.1f, 0.14f, 0.94f), 14); + bar.name = "BuildPlacePanel"; + bar.anchorMin = new Vector2(0.04f, 0); bar.anchorMax = new Vector2(0.96f, 0); + bar.pivot = new Vector2(0.5f, 0); + bar.sizeDelta = new Vector2(0, 130); bar.anchoredPosition = new Vector2(0, 40); + placePanel = bar.gameObject; + + 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); + UIKit.Button(bar, "Отмена", UIKit.Theme.Gray, CancelGhost, 180, 90, 26); + RefreshPlacePanel(); + } + + void RefreshPlacePanel() + { + if (ghost == null || placeInfo == null) return; + var d = ghost.Data; + bool afford = d.CanAfford(); + string where = ghost.CellsFree ? "место свободно" : "клетки заняты"; + 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); } // ---------------- окно выбора здания ---------------- - void OpenChooser(BuildSlot slot) + void OpenChooser() { CloseChooser(); + StopDrag(); var bm = BuildManager.Instance; + if (bm == null) return; GameObject root; var card = UIKit.Modal("Что построить?", out root); chooser = root; @@ -135,7 +239,7 @@ public class BuildModeController : MonoBehaviour card.offsetMin = Vector2.zero; card.offsetMax = Vector2.zero; int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1; - UIKit.Text(card, "Участок: " + slot.name + " • Ратуша " + th + " ур.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + UIKit.Text(card, "Ратуша " + th + " ур. • выберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); var holder = UIKit.Make("ListHolder", card); UIKit.Size(holder, -1, -1).flexibleHeight = 1; @@ -144,7 +248,7 @@ public class BuildModeController : MonoBehaviour // доступные сверху, закрытые по уровню Ратуши -- ниже var all = new List(bm.allBuildings); - all.RemoveAll(b => b == null); + all.RemoveAll(b => b == null || b.prefab == null); all.Sort((a, b) => a.requiredTownHallLevel.CompareTo(b.requiredTownHallLevel)); foreach (var data in all) @@ -152,6 +256,7 @@ public class BuildModeController : MonoBehaviour var d = data; bool locked = d.requiredTownHallLevel > th; bool afford = !locked && d.CanAfford(); + var size = BuildFootprint.Of(d).size; var row = UIKit.Row(list, 120, 14, locked ? UIKit.Theme.CardDark : UIKit.Theme.Card, 10); var icon = UIKit.Box(row, d.icon != null ? Color.white : UIKit.Theme.Gray); @@ -160,26 +265,18 @@ 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, 30, locked ? UIKit.Theme.TextDim : UIKit.Theme.Text).fontStyle = FontStyles.Bold; + UIKit.Text(info, d.buildingName + " " + size.x + "×" + size.y + " кл.", 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 label = locked ? "Закрыто" : (afford ? "Построить" : "Мало ресурсов"); - var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () => - { - if (BuildManager.Instance.BuildOnSlot(d, slot)) - { - UIKit.Toast("Построено: " + d.buildingName); - CloseChooser(); - ApplySlotVisuals(); - if (FreeSlots().Count == 0) SetMode(false); - } - else UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost))); - }, 230, 90, 26); + // выбрать можно и без ресурсов: место присмотреть, а "Поставить" загорится, когда хватит + string label = locked ? "Закрыто" : "Выбрать"; + var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () => StartGhost(d), 230, 90, 26); b.interactable = !locked; } - UIKit.Button(card, "Отмена", UIKit.Theme.Gray, CloseChooser, -1, 90, 30); + UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, CloseChooser, -1, 90, 30); } void CloseChooser() @@ -216,12 +313,13 @@ public class BuildModeController : MonoBehaviour // полоска сверху экрана (не модальная: остальной экран остаётся кликабельным) var bar = UIKit.Row(UIKit.Overlay, -1, 16, new Color(0.1f, 0.25f, 0.12f, 0.92f), 14); bar.name = "BuildModeBanner"; - bar.anchorMin = new Vector2(0.15f, 1); bar.anchorMax = new Vector2(0.85f, 1); + bar.anchorMin = new Vector2(0.1f, 1); bar.anchorMax = new Vector2(0.9f, 1); bar.pivot = new Vector2(0.5f, 1); bar.sizeDelta = new Vector2(0, 110); bar.anchoredPosition = new Vector2(0, -150); - var t = UIKit.Text(bar, "РЕЖИМ СТРОЙКИ: нажмите на зелёный участок", 30, Color.white, TextAlignmentOptions.Left); + var t = UIKit.Text(bar, "РЕЖИМ СТРОЙКИ: двигайте здание по сетке, красное -- занято", 26, Color.white, TextAlignmentOptions.Left); UIKit.Size(t, -1, -1, 1); - UIKit.Button(bar, "Выйти", UIKit.Theme.Red, () => SetMode(false), 180, 80, 28); + UIKit.Button(bar, "Здания", UIKit.Theme.Green, OpenChooser, 180, 80, 28); + UIKit.Button(bar, "Выйти", UIKit.Theme.Red, () => SetMode(false), 160, 80, 28); return bar.gameObject; } diff --git a/Assets/Scripts/Meta/Core/GameProgress.cs b/Assets/Scripts/Meta/Core/GameProgress.cs index 7cec5ca..777218d 100644 --- a/Assets/Scripts/Meta/Core/GameProgress.cs +++ b/Assets/Scripts/Meta/Core/GameProgress.cs @@ -622,6 +622,12 @@ public class GameProgress : MonoBehaviour // Войска хранит модуль Армии (Docs/Modules/Army.md) -- здесь только обращение к нему static IArmy Army => GameServices.Get(); + // Люди города -- модуль Поселения (часть «Население»). null -- модуль не подключён + static IPopulation Population => GameServices.TryGet(out IPopulation p) ? p : null; + + // Сколько воинов можно нанять из жителей прямо сейчас (для кнопок найма) + public int Recruitable => Population != null ? Population.Recruitable : int.MaxValue; + public int TroopCount(int type) { return Army.GetTroops(type); } public int TotalTroops @@ -637,7 +643,17 @@ public class GameProgress : MonoBehaviour if (!TroopUnlocked(index)) { UIKit.Toast("Нужна Ратуша " + def.requiredTownHall + " уровня"); return; } if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; } if (Data.training.Count >= Barracks.QueueSize) { UIKit.Toast("Очередь обучения заполнена. Больше казарм -- длиннее очередь"); return; } + // Воин -- житель города (модуль Поселения, часть «Население»): берутся свободные жители, резерв не отдаётся + var population = Population; + if (population != null && population.Recruitable < count) + { + UIKit.Toast(population.Recruitable > 0 + ? "Жителей хватит только на " + population.Recruitable + " воинов" + : "Некого нанять: свободных жителей нет (резерв " + population.CivilianReserve + "). Снимите рабочих или стройте Дома"); + return; + } if (!Pay(def.cost, count)) return; + if (population != null) population.TryRecruit(count); long start = Data.training.Count > 0 ? Data.training[Data.training.Count - 1].endTicks : Now; if (start < Now) start = Now; @@ -681,6 +697,7 @@ 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); Data.troopsTrained += o.count; Data.dailyTroops += o.count; diff --git a/Assets/Scripts/Meta/Panels/TroopsPanel.cs b/Assets/Scripts/Meta/Panels/TroopsPanel.cs index 86a152c..83e656c 100644 --- a/Assets/Scripts/Meta/Panels/TroopsPanel.cs +++ b/Assets/Scripts/Meta/Panels/TroopsPanel.cs @@ -24,6 +24,13 @@ public class TroopsPanel : BasePanel ProfilePanel.Header(army, "Армия"); ProfilePanel.KV(army, "Всего воинов в городе", GP.TotalTroops.ToString("N0")); ProfilePanel.KV(army, "В походе на карте мира", GameServices.Get().TroopsInMarches.ToString("N0")); + if (GameServices.TryGet(out IPopulation population)) + { + // Воины берутся из свободных жителей (модуль Поселения), резерв не нанять + ProfilePanel.KV(army, "Обучаются в Казармах", population.InTraining.ToString("N0")); + ProfilePanel.KV(army, "Можно нанять из жителей", population.Recruitable.ToString("N0") + + " (резерв " + population.CivilianReserve.ToString("N0") + ")"); + } ProfilePanel.KV(army, "Сила войск", GP.TroopsPower.ToString("N0")); ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0")); UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26); @@ -90,7 +97,7 @@ public class TroopsPanel : BasePanel foreach (int amount in Amounts) { int a = amount; - bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && d.training.Count < Barracks.QueueSize; + bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && d.training.Count < Barracks.QueueSize && GP.Recruitable >= a; var b = UIKit.Button(btns, "+" + a, can ? UIKit.Theme.Green : UIKit.Theme.Gray, () => GP.Train(idx, a), -1, 80, 30); UIKit.Size(b, -1, 80, 1); } diff --git a/Assets/Scripts/Modules/Core.Unity/UIBlocker.cs b/Assets/Scripts/Modules/Core.Unity/UIBlocker.cs index 6000f5f..a8db6f5 100644 --- a/Assets/Scripts/Modules/Core.Unity/UIBlocker.cs +++ b/Assets/Scripts/Modules/Core.Unity/UIBlocker.cs @@ -6,6 +6,10 @@ using UnityEngine.EventSystems; // не собирал ресурсы со здания под панелью, не улучшал Ратушу, не двигал камеру. public static class UIBlocker { + // Палец сейчас занят миром (например, игрок тащит призрак здания в режиме стройки) -- + // камера в это время не двигается. Ставит и снимает тот, кто захватил ввод. + public static bool WorldInputCaptured; + public static bool IsPointerOverUI() { if (EventSystem.current == null) return false; diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 1d90873..774d962 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -46,14 +46,14 @@ public interface ILeveledBuilding int Level { get; set; } } -// ---------------- Население: жители, еда, прирост ---------------- +// ---------------- Население (часть модуля Поселения): жители, еда, прирост, найм ---------------- public interface IPopulation { - int Total { get; } // все люди города: жители + воины (в городе и в походах) + int Total { get; } // все люди города: жители + обучаются в Казармах + воины (в городе и в походах) int Civilians { get; } // жители (не воины) int Workers { get; } // жители на работе (все профессии вместе) int Idle { get; } // остальные жители: Civilians - Workers - int Workforce { get; } // сколько жителей вообще могут работать (взрослые; стартовый город -- 120) + int Workforce { get; } // сколько жителей могут работать: 40% всех людей города (стартовый город -- 120 из 300) int FreeWorkers { get; } // могут работать, но без работы: Workforce - Workers int Soldiers { get; } // воины -- их хранит модуль Армии int HousingCap { get; } // предел населения: выше него прирост не идёт @@ -61,6 +61,16 @@ public interface IPopulation float FoodPerHour { get; } // сколько город съедает в час float GrowthPerHour { get; } // прирост в час сейчас (0 -- если голод или упёрлись в предел) bool IsStarving { get; } // еды не хватило на последний расчёт + // Голодал ли город в момент unixTime (секунды). Для боёв, прошедших, пока игра была закрыта: + // история хранится за последние MaxOfflineHours (48 ч), точность -- 10 минут (шаг расчёта). + bool WasStarvingAt(long unixTime); + + // Найм воинов (решение команды от 19.10.2026): воины берутся из свободных жителей, гражданский резерв не отдаётся. + int CivilianReserve { get; } // гражданский резерв: столько свободных жителей в войско не взять + int Recruitable { get; } // сколько воинов можно нанять прямо сейчас: (свободные - резерв) / жителей на воина + int InTraining { get; } // людей, взятых из жителей и ещё обучающихся в Казармах + bool TryRecruit(int soldiers); // забрать людей в Казармы (в начале обучения); false -- столько жителей нет + void RecruitsTrained(int soldiers); // обучение закончено: этих людей теперь считает Армия // Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке) void Tick(long unixNow); diff --git a/Assets/Scripts/Modules/Settlement/BuildGhost.cs b/Assets/Scripts/Modules/Settlement/BuildGhost.cs new file mode 100644 index 0000000..82f9b4e --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/BuildGhost.cs @@ -0,0 +1,115 @@ +using UnityEngine; + +// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md +// Призрак здания в режиме стройки: полупрозрачная копия картинки здания и пятно его клеток под ней. +// Пятно зелёное -- сюда можно ставить, красное -- клетки заняты или за границей. +// Только картинка: скриптов и коллайдеров префаба у призрака нет, экономика его не видит. +public class BuildGhost : MonoBehaviour +{ + public BuildingData Data { get; private set; } + public BuildFootprint Footprint { get; private set; } + public Vector2Int Cell { get; private set; } // левая нижняя клетка на сетке + public bool CellsFree { get; private set; } + + public static readonly Color FreeColor = new Color(0.25f, 1f, 0.35f, 0.45f); + public static readonly Color BlockedColor = new Color(1f, 0.2f, 0.2f, 0.5f); + + SpriteRenderer pad; + static Sprite white; + + public static BuildGhost Create(BuildingData data) + { + var go = new GameObject("BuildGhost_" + data.buildingName); + var g = go.AddComponent(); + g.Data = data; + g.Footprint = BuildFootprint.Of(data); + g.Build(); + return g; + } + + void Build() + { + // пятно клеток: белый квадрат 1×1, растянутый на размер здания + var padGo = new GameObject("Cells"); + padGo.transform.SetParent(transform, false); + pad = padGo.AddComponent(); + pad.sprite = WhiteSprite(); + pad.sortingOrder = 3; + padGo.transform.localScale = new Vector3(Footprint.size.x, Footprint.size.y, 1f); + + // копия картинки здания: позиция центра пятна = позиция префаба + anchorOffset + var art = new GameObject("Art").transform; + art.SetParent(transform, false); + art.localPosition = -(Vector3)Footprint.anchorOffset; + + var prefab = Data.prefab; + if (prefab == null) return; + Vector3 root = prefab.transform.position; + int minOrder = int.MaxValue; + var renderers = prefab.GetComponentsInChildren(true); + foreach (var sr in renderers) if (sr.sprite != null) minOrder = Mathf.Min(minOrder, sr.sortingOrder); + foreach (var sr in renderers) + { + if (sr.sprite == null) continue; + var copy = new GameObject(sr.name).transform; + copy.SetParent(art, false); + copy.localPosition = sr.transform.position - root; + copy.localRotation = sr.transform.rotation; + copy.localScale = sr.transform.lossyScale; + var r = copy.gameObject.AddComponent(); + r.sprite = sr.sprite; + r.flipX = sr.flipX; + r.flipY = sr.flipY; + r.drawMode = sr.drawMode; + if (sr.drawMode != SpriteDrawMode.Simple) r.size = sr.size; + var c = sr.color; c.a *= 0.65f; + r.color = c; + r.sortingOrder = 4 + (sr.sortingOrder - minOrder); + } + } + + static Sprite WhiteSprite() + { + if (white != null) return white; + 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(); + white = Sprite.Create(tex, new Rect(0, 0, 4, 4), new Vector2(0.5f, 0.5f), 4); + return white; + } + + // Поставить призрак на клетку (левая нижняя) и проверить, свободно ли + public void MoveToCell(Vector2Int cell) + { + var grid = BuildGrid.Instance; + Cell = cell; + Vector2 center = grid.RectCenter(cell, Footprint.size); + transform.position = new Vector3(center.x, center.y, 0f); + Recheck(); + } + + // Подвинуть так, чтобы центр пятна был ближе всего к точке мира + public void MoveNear(Vector2 worldCenter) + { + MoveToCell(BuildGrid.Instance.SnapCell(worldCenter, Footprint.size)); + } + + public void Recheck() + { + CellsFree = BuildGrid.Instance.CanPlace(Cell, Footprint.size); + if (pad != null) pad.color = CellsFree ? FreeColor : BlockedColor; + } + + // Точка мира попадает в пятно призрака (с запасом под палец) + public bool Contains(Vector2 world, float margin = 0.4f) + { + Vector2 c = transform.position; + Vector2 half = (Vector2)Footprint.size * 0.5f + Vector2.one * margin; + return Mathf.Abs(world.x - c.x) <= half.x && Mathf.Abs(world.y - c.y) <= half.y; + } + + // Куда ставить префаб, чтобы здание встало ровно на клетки призрака + public Vector3 PrefabPosition => transform.position - (Vector3)Footprint.anchorOffset; +} diff --git a/Assets/Scripts/Modules/Settlement/BuildGhost.cs.meta b/Assets/Scripts/Modules/Settlement/BuildGhost.cs.meta new file mode 100644 index 0000000..3a0e385 --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/BuildGhost.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4342ee63e1ed441786fda6c36b2da960 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Settlement/BuildGrid.cs b/Assets/Scripts/Modules/Settlement/BuildGrid.cs new file mode 100644 index 0000000..497cd8a --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/BuildGrid.cs @@ -0,0 +1,368 @@ +using System.Collections.Generic; +using UnityEngine; + +// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md +// Сетка свободной стройки: игрок сам выбирает, где поставить здание. +// +// - Клетка = 1 единица мира. Сетка прямоугольная (арт города не изометрический). +// - Где можно строить: прямоугольник вокруг старых участков (BuildSlot) и Ратуши с запасом padding. +// Можно задать руками: autoArea = false и areaMin / areaMax. +// - Занятые клетки: под каждым построенным зданием (его размер на сетке, BuildingData.footprint) и под Ратушей. +// Старые сохранения: здание стоит, где стояло, занятость считается по тому, какие клетки оно накрывает. +// - В режиме стройки сетка рисуется поверх земли: свободные клетки -- светлые линии, занятые -- красные. +// - Старые участки и площадки «Застройка» прячутся при запуске (свободная стройка их заменила). +// +// Объект создаётся сам (BuildGrid.Instance), руками на сцену ставить не нужно. +// Если поставить руками -- можно настроить поля в инспекторе. +public class BuildGrid : MonoBehaviour +{ + static BuildGrid instance; + + public static BuildGrid Instance + { + get + { + if (instance == null) + { + instance = FindFirstObjectByType(); + if (instance == null) instance = new GameObject("BuildGrid").AddComponent(); + } + return instance; + } + } + + [Header("Где можно строить")] + [Tooltip("Посчитать область самому: старые участки + Ратуша + запас")] + public bool autoArea = true; + [Tooltip("Запас вокруг старых участков, клеток")] + public int padding = 3; + public Vector2 areaMin = new Vector2(-34, -10); + public Vector2 areaMax = new Vector2(4, 12); + + [Header("Вид сетки")] + public int overlaySortingOrder = 1; + public Color lineColor = new Color(1f, 1f, 1f, 0.28f); + public Color freeColor = new Color(0.4f, 1f, 0.5f, 0.06f); + public Color busyColor = new Color(1f, 0.15f, 0.15f, 0.38f); + + [Tooltip("Спрятать старые участки и площадки «Застройка» при запуске")] + public bool hideLegacySlots = true; + + public Vector2Int Origin { get; private set; } // мировые координаты левого нижнего угла клетки (0, 0) + public int Width { get; private set; } + public int Height { get; private set; } + + bool[,] busy; + bool initialized; + float nextRefresh; + SpriteRenderer overlay; + Texture2D overlayTex; + string lastOverlayKey; + + const int PixelsPerCell = 16; + + void Awake() + { + if (instance != null && instance != this) { Destroy(this); return; } + instance = this; + } + + void OnDestroy() + { + if (instance == this) instance = null; + if (overlayTex != null) Destroy(overlayTex); + } + + void Update() + { + if (overlay != null && overlay.enabled && Time.unscaledTime >= nextRefresh) Refresh(); + } + + // ---------------- область ---------------- + + void EnsureInit() + { + if (initialized) return; + initialized = true; + + Rect area = autoArea ? AutoArea() : Rect.MinMaxRect(areaMin.x, areaMin.y, areaMax.x, areaMax.y); + Origin = new Vector2Int(Mathf.FloorToInt(area.xMin), Mathf.FloorToInt(area.yMin)); + Width = Mathf.Max(1, Mathf.CeilToInt(area.xMax) - Origin.x); + Height = Mathf.Max(1, Mathf.CeilToInt(area.yMax) - Origin.y); + busy = new bool[Width, Height]; + + if (hideLegacySlots) HideLegacySlots(); + Debug.Log($"[СТРОЙКА] Сетка {Width}×{Height} клеток, левый нижний угол {Origin}"); + } + + Rect AutoArea() + { + bool any = false; + Bounds b = new Bounds(); + foreach (var slot in FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) + { + Bounds sb = new Bounds(slot.transform.position, Vector3.one); + var col = slot.GetComponent(); + if (col != null && col.enabled && col.gameObject.activeInHierarchy) sb.Encapsulate(col.bounds); + if (!any) { b = sb; any = true; } else b.Encapsulate(sb); + } + if (TownHall.Instance != null) + { + Bounds tb = TownHallBounds(); + if (!any) { b = tb; b.Expand(new Vector3(24, 16, 0)); any = true; } + else b.Encapsulate(tb); + } + if (!any) b = new Bounds(Vector3.zero, new Vector3(30, 20, 0)); + b.Expand(new Vector3(padding * 2, padding * 2, 0)); + return Rect.MinMaxRect(b.min.x, b.min.y, b.max.x, b.max.y); + } + + static Bounds TownHallBounds() + { + var th = TownHall.Instance; + var col = th.GetComponent(); + if (col != null && col.enabled) return col.bounds; + var sr = th.GetComponentInChildren(); + return sr != null ? sr.bounds : new Bounds(th.transform.position, new Vector3(3, 3, 0)); + } + + static void HideLegacySlots() + { + foreach (var slot in FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) + { + HideOn(slot.gameObject); + var parent = slot.transform.parent; + if (parent != null && parent.name.StartsWith("Застройка")) HideOn(parent.gameObject); + } + } + + // Прячем только сам объект (картинку и коллайдер), не детей -- и сцену не меняем + static void HideOn(GameObject go) + { + foreach (var sr in go.GetComponents()) sr.enabled = false; + foreach (var c in go.GetComponents()) c.enabled = false; + } + + // ---------------- клетки ---------------- + + public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height; + + public bool IsBusy(int x, int y) { EnsureInit(); return !InBounds(x, y) || busy[x, y]; } + + // Клетка мира -> индекс сетки + public Vector2Int WorldToCell(Vector2 world) + { + EnsureInit(); + return new Vector2Int(Mathf.FloorToInt(world.x) - Origin.x, Mathf.FloorToInt(world.y) - Origin.y); + } + + // Центр прямоугольника клеток (x, y, w, h) в мире + public Vector2 RectCenter(Vector2Int cell, Vector2Int size) + { + EnsureInit(); + return new Vector2(Origin.x + cell.x + size.x * 0.5f, Origin.y + cell.y + size.y * 0.5f); + } + + // Левая нижняя клетка для здания размера size, чей центр ближе всего к точке мира + public Vector2Int SnapCell(Vector2 worldCenter, Vector2Int size) + { + EnsureInit(); + return new Vector2Int(Mathf.RoundToInt(worldCenter.x - Origin.x - size.x * 0.5f), + Mathf.RoundToInt(worldCenter.y - Origin.y - size.y * 0.5f)); + } + + // Можно ли поставить: все клетки внутри области и свободны + public bool CanPlace(Vector2Int cell, Vector2Int size) + { + EnsureInit(); + for (int x = cell.x; x < cell.x + size.x; x++) + for (int y = cell.y; y < cell.y + size.y; y++) + if (!InBounds(x, y) || busy[x, y]) return false; + return true; + } + + // Ближайшее свободное место для здания размера size рядом с точкой (по спирали). false -- места нет + public bool FindFreeNear(Vector2 worldCenter, Vector2Int size, out Vector2Int cell) + { + Refresh(); + Vector2Int start = SnapCell(worldCenter, size); + int maxR = Mathf.Max(Width, Height); + for (int r = 0; r <= maxR; r++) + { + for (int dx = -r; dx <= r; dx++) + for (int dy = -r; dy <= r; dy++) + { + if (Mathf.Max(Mathf.Abs(dx), Mathf.Abs(dy)) != r) continue; // только кольцо радиуса r + var c = new Vector2Int(start.x + dx, start.y + dy); + if (CanPlace(c, size)) { cell = c; return true; } + } + } + cell = start; + return false; + } + + // Пересчитать занятые клетки по тому, что стоит в городе сейчас + public void Refresh() + { + EnsureInit(); + nextRefresh = Time.unscaledTime + 0.3f; + System.Array.Clear(busy, 0, busy.Length); + + if (TownHall.Instance != null) + { + Bounds tb = TownHallBounds(); + MarkRect(new Rect(tb.min.x, tb.min.y, tb.size.x, tb.size.y)); + } + + var bm = BuildManager.Instance; + foreach (var id in FindObjectsByType(FindObjectsSortMode.None)) + { + if (string.IsNullOrEmpty(id.buildingName)) continue; // здание сносится в этом кадре + BuildingData data = bm != null ? bm.FindData(id.buildingName) : null; + Rect r; + if (data != null) + { + var fp = BuildFootprint.Of(data); + Vector2 center = (Vector2)id.transform.position + fp.anchorOffset; + r = new Rect(center.x - fp.size.x * 0.5f, center.y - fp.size.y * 0.5f, fp.size.x, fp.size.y); + } + else + { + var col = id.GetComponent(); + if (col == null) continue; + r = new Rect(col.bounds.min.x, col.bounds.min.y, col.bounds.size.x, col.bounds.size.y); + } + MarkRect(r); + } + + if (overlay != null && overlay.enabled) RedrawOverlay(); + } + + // Отметить все клетки, которые прямоугольник мира накрывает (касание краем не считается) + void MarkRect(Rect r) + { + const float eps = 0.05f; + int x0 = Mathf.FloorToInt(r.xMin + eps) - Origin.x, x1 = Mathf.CeilToInt(r.xMax - eps) - Origin.x; + int y0 = Mathf.FloorToInt(r.yMin + eps) - Origin.y, y1 = Mathf.CeilToInt(r.yMax - eps) - Origin.y; + for (int x = Mathf.Max(0, x0); x < Mathf.Min(Width, x1); x++) + for (int y = Mathf.Max(0, y0); y < Mathf.Min(Height, y1); y++) + busy[x, y] = true; + } + + // ---------------- рисунок сетки ---------------- + + public void ShowOverlay(bool show) + { + EnsureInit(); + if (show && overlay == null) CreateOverlay(); + if (overlay == null) return; + overlay.enabled = show; + if (show) Refresh(); + } + + void CreateOverlay() + { + overlayTex = new Texture2D(Width * PixelsPerCell, Height * PixelsPerCell, TextureFormat.RGBA32, false); + overlayTex.filterMode = FilterMode.Point; + overlayTex.wrapMode = TextureWrapMode.Clamp; + var sprite = Sprite.Create(overlayTex, new Rect(0, 0, overlayTex.width, overlayTex.height), Vector2.zero, PixelsPerCell); + + var go = new GameObject("BuildGridOverlay"); + go.transform.SetParent(transform, false); + go.transform.position = new Vector3(Origin.x, Origin.y, 0f); + overlay = go.AddComponent(); + overlay.sprite = sprite; + overlay.sortingOrder = overlaySortingOrder; + lastOverlayKey = null; + } + + void RedrawOverlay() + { + // перерисовываем только если занятость поменялась + var key = new System.Text.StringBuilder(Width * Height); + for (int y = 0; y < Height; y++) for (int x = 0; x < Width; x++) key.Append(busy[x, y] ? '1' : '0'); + string k = key.ToString(); + if (k == lastOverlayKey) return; + lastOverlayKey = k; + + int w = overlayTex.width, h = overlayTex.height; + var px = new Color32[w * h]; + Color32 line = lineColor, free = freeColor, bad = busyColor; + for (int y = 0; y < h; y++) + { + int cy = y / PixelsPerCell, iy = y % PixelsPerCell; + for (int x = 0; x < w; x++) + { + int cx = x / PixelsPerCell, ix = x % PixelsPerCell; + bool edge = ix == 0 || iy == 0 || x == w - 1 || y == h - 1; + px[y * w + x] = busy[cx, cy] ? bad : (edge ? line : free); + if (busy[cx, cy] && edge) px[y * w + x] = new Color32(bad.r, bad.g, bad.b, (byte)Mathf.Min(255, bad.a + 50)); + } + } + overlayTex.SetPixels32(px); + overlayTex.Apply(false); + } +} + +// Размер здания на сетке и где его "пятно" относительно точки, в которую ставится префаб. +// size -- BuildingData.footprint, а если там 0 × 0 -- коллайдер префаба (иначе картинка), округлённый до клеток. +// anchorOffset -- центр пятна минус позиция префаба (у многих картинок точка привязки не в центре). +public struct BuildFootprint +{ + public Vector2Int size; + public Vector2 anchorOffset; + + static readonly Dictionary cache = new Dictionary(); + + public static BuildFootprint Of(BuildingData data) + { + if (data == null) return new BuildFootprint { size = Vector2Int.one }; + if (cache.TryGetValue(data, out var fp)) return fp; + + Rect r = LocalRect(data.prefab); + fp.anchorOffset = r.center; + if (data.footprint.x > 0 && data.footprint.y > 0) fp.size = data.footprint; + else fp.size = new Vector2Int(Mathf.Max(1, Mathf.RoundToInt(r.width)), Mathf.Max(1, Mathf.RoundToInt(r.height))); + cache[data] = fp; + return fp; + } + + // Прямоугольник префаба относительно его позиции, в единицах мира. + // Считаем по трансформам руками: у выключенного шаблона collider.bounds не работает. + static Rect LocalRect(GameObject prefab) + { + if (prefab == null) return new Rect(-0.5f, -0.5f, 1, 1); + Vector3 root = prefab.transform.position; + + var box = prefab.GetComponent(); + if (box == null) box = prefab.GetComponentInChildren(true); + if (box != null) + { + Vector2 half = box.size * 0.5f; + return Corners(box.transform, box.offset - half, box.offset + half, root); + } + + bool any = false; + Rect result = new Rect(); + foreach (var sr in prefab.GetComponentsInChildren(true)) + { + if (sr.sprite == null) continue; + var b = sr.sprite.bounds; + Rect one = Corners(sr.transform, b.min, b.max, root); + if (!any) { result = one; any = true; } + else result = Rect.MinMaxRect(Mathf.Min(result.xMin, one.xMin), Mathf.Min(result.yMin, one.yMin), + Mathf.Max(result.xMax, one.xMax), Mathf.Max(result.yMax, one.yMax)); + } + return any ? result : new Rect(-0.5f, -0.5f, 1, 1); + } + + static Rect Corners(Transform t, Vector2 min, Vector2 max, Vector3 root) + { + Vector3 a = t.TransformPoint(new Vector3(min.x, min.y)) - root; + Vector3 b = t.TransformPoint(new Vector3(max.x, max.y)) - root; + Vector3 c = t.TransformPoint(new Vector3(min.x, max.y)) - root; + Vector3 d = t.TransformPoint(new Vector3(max.x, min.y)) - root; + return Rect.MinMaxRect(Mathf.Min(a.x, b.x, c.x, d.x), Mathf.Min(a.y, b.y, c.y, d.y), + Mathf.Max(a.x, b.x, c.x, d.x), Mathf.Max(a.y, b.y, c.y, d.y)); + } +} diff --git a/Assets/Scripts/Modules/Settlement/BuildGrid.cs.meta b/Assets/Scripts/Modules/Settlement/BuildGrid.cs.meta new file mode 100644 index 0000000..4dfaca8 --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/BuildGrid.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1986efcb01c943548be8d0a24fbc56c9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Settlement/BuildManager.cs b/Assets/Scripts/Modules/Settlement/BuildManager.cs index 25e1c86..2ab74ea 100644 --- a/Assets/Scripts/Modules/Settlement/BuildManager.cs +++ b/Assets/Scripts/Modules/Settlement/BuildManager.cs @@ -2,10 +2,9 @@ using UnityEngine; using System.Collections.Generic; // МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md -// Управляет режимом строительства. Теперь здание можно поставить ТОЛЬКО -// на заранее подготовленный участок (BuildSlot) -- свободное размещение убрано. -// Наведение на пустой слот -> подсветка (можно строить). -// Наведение на занятый слот / мимо слотов -> подсветки нет, клик ничего не делает. +// Список зданий и сама постройка. +// Сейчас стройка свободная, на сетке: игрок сам выбирает место (BuildGrid, BuildGhost, BuildModeController), +// а здание ставит BuildAt. Участки (BuildSlot) остались для старых сцен и сохранений -- BuildGrid их прячет. public class BuildManager : MonoBehaviour { @@ -148,7 +147,47 @@ public class BuildManager : MonoBehaviour Debug.Log("[СТРОЙКА] Размещение отменено."); } - // ===== Новая схема стройки: тап по участку -> выбор здания (BuildModeController) ===== + // ===== Свободная стройка на сетке ===== + + // Данные здания по имени (имя хранится в PlacedBuildingId и в сохранении) + public BuildingData FindData(string buildingName) + { + if (allBuildings == null || string.IsNullOrEmpty(buildingName)) return null; + foreach (var b in allBuildings) if (b != null && b.buildingName == buildingName) return b; + return null; + } + + // Можно ли поставить здание на клетки (левая нижняя cell): клетки свободны и ресурсов хватает + public bool CanBuildAt(BuildingData data, Vector2Int cell) + { + if (data == null || data.prefab == null) return false; + return BuildGrid.Instance.CanPlace(cell, BuildFootprint.Of(data).size) && data.CanAfford(); + } + + // Построить здание на клетках сетки. Возвращает здание или null (занято / мало ресурсов). + public GameObject BuildAt(BuildingData data, Vector2Int cell) + { + var grid = BuildGrid.Instance; + grid.Refresh(); // занятость на этот момент, а не 0.3 с назад + if (!CanBuildAt(data, cell)) return null; + + data.PayCost(); + + var fp = BuildFootprint.Of(data); + Vector2 center = grid.RectCenter(cell, fp.size); + Vector3 pos = new Vector3(center.x - fp.anchorOffset.x, center.y - fp.anchorOffset.y, 0f); + + GameObject built = Instantiate(data.prefab, pos, Quaternion.identity); + if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным + built.AddComponent().buildingName = data.buildingName; + + Debug.Log($"[СТРОЙКА] Построено: {data.buildingName}, клетки {cell} размер {fp.size}"); + EventBus.Publish(new BuildingPlacedEvent { BuildingName = data.buildingName }); + grid.Refresh(); + return built; + } + + // ===== Старая схема: тап по участку -> выбор здания (участки спрятаны, оставлено для совместимости) ===== // Построить здание сразу на указанном участке. true -- получилось. public bool BuildOnSlot(BuildingData data, BuildSlot slot) diff --git a/Assets/Scripts/Modules/Settlement/BuildingData.cs b/Assets/Scripts/Modules/Settlement/BuildingData.cs index 8fbf111..cea8240 100644 --- a/Assets/Scripts/Modules/Settlement/BuildingData.cs +++ b/Assets/Scripts/Modules/Settlement/BuildingData.cs @@ -17,6 +17,10 @@ public class BuildingData : ScriptableObject [Tooltip("Необходимый уровень ратуши")] public int requiredTownHallLevel = 1; + [Header("Размер на сетке стройки")] + [Tooltip("Сколько клеток занимает здание (ширина × высота). 0 × 0 -- посчитать само по коллайдеру префаба")] + public Vector2Int footprint; + [Header("Стоимость")] public ResourceCost[] cost; diff --git a/Assets/Scripts/Modules/Population.meta b/Assets/Scripts/Modules/Settlement/Population.meta similarity index 100% rename from Assets/Scripts/Modules/Population.meta rename to Assets/Scripts/Modules/Settlement/Population.meta diff --git a/Assets/Scripts/Modules/Population/Bogatyri.Population.asmdef b/Assets/Scripts/Modules/Settlement/Population/Bogatyri.Population.asmdef similarity index 87% rename from Assets/Scripts/Modules/Population/Bogatyri.Population.asmdef rename to Assets/Scripts/Modules/Settlement/Population/Bogatyri.Population.asmdef index 867e1b6..d2bc05b 100644 --- a/Assets/Scripts/Modules/Population/Bogatyri.Population.asmdef +++ b/Assets/Scripts/Modules/Settlement/Population/Bogatyri.Population.asmdef @@ -1,5 +1,5 @@ { - "name": "Bogatyri.Population", + "name": "Bogatyri.Settlement.Population", "rootNamespace": "", "references": ["Bogatyri.Core"], "includePlatforms": [], diff --git a/Assets/Scripts/Modules/Population/Bogatyri.Population.asmdef.meta b/Assets/Scripts/Modules/Settlement/Population/Bogatyri.Population.asmdef.meta similarity index 100% rename from Assets/Scripts/Modules/Population/Bogatyri.Population.asmdef.meta rename to Assets/Scripts/Modules/Settlement/Population/Bogatyri.Population.asmdef.meta diff --git a/Assets/Scripts/Modules/Population/PopulationRules.cs b/Assets/Scripts/Modules/Settlement/Population/PopulationRules.cs similarity index 65% rename from Assets/Scripts/Modules/Population/PopulationRules.cs rename to Assets/Scripts/Modules/Settlement/Population/PopulationRules.cs index 8dae766..5bdfbe6 100644 --- a/Assets/Scripts/Modules/Population/PopulationRules.cs +++ b/Assets/Scripts/Modules/Settlement/Population/PopulationRules.cs @@ -1,3 +1,4 @@ +// МОДУЛЬ ПОСЕЛЕНИЯ, часть «Население» (люди города). Документ: Docs/Modules/Population.md // Правила населения -- только числа, без Unity. Заполняются из EconomyConfig (App/GameModules), // в тестах -- напрямую. Значения по умолчанию -- из дизайн-документа (этап 2 плана разработки). public sealed class PopulationRules @@ -21,6 +22,13 @@ public sealed class PopulationRules public int HousingPerTownHallLevel = 50; public int HousingPerHouseLevel = 50; // каждый уровень Дома (здание "Население") + // Найм воинов (решение команды от 19.10.2026): воины набираются из свободных (не работающих) жителей, + // гражданский резерв не отдаётся. Пример команды: 150 свободных, резерв 30 -> нанять можно не больше 120. + // Резерв задаёт уровень поселения: база + прибавка за уровень Ратуши. Прибавка НЕ утверждена. + public int CivilianReserve = 30; + public int CivilianReservePerTownHallLevel = 0; + public int PeoplePerSoldier = 1; // сколько жителей стоит один воин (EconomyConfig, раздел "Солдат") + // Офлайн-прогресс досчитывается не больше чем за столько часов. Число НЕ утверждено. public int MaxOfflineHours = 48; diff --git a/Assets/Scripts/Modules/Population/PopulationRules.cs.meta b/Assets/Scripts/Modules/Settlement/Population/PopulationRules.cs.meta similarity index 100% rename from Assets/Scripts/Modules/Population/PopulationRules.cs.meta rename to Assets/Scripts/Modules/Settlement/Population/PopulationRules.cs.meta diff --git a/Assets/Scripts/Modules/Population/PopulationService.cs b/Assets/Scripts/Modules/Settlement/Population/PopulationService.cs similarity index 73% rename from Assets/Scripts/Modules/Population/PopulationService.cs rename to Assets/Scripts/Modules/Settlement/Population/PopulationService.cs index 91b6c39..28f0f3f 100644 --- a/Assets/Scripts/Modules/Population/PopulationService.cs +++ b/Assets/Scripts/Modules/Settlement/Population/PopulationService.cs @@ -1,13 +1,16 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Text; -// МОДУЛЬ НАСЕЛЕНИЯ: сколько в городе людей, кто где работает, сколько едят и как прирастают. +// МОДУЛЬ ПОСЕЛЕНИЯ, часть «Население»: сколько в городе людей, кто где работает, сколько едят и как прирастают. +// Поселение -- это город: здания (Settlement/*.cs) и люди (эта папка, сборка Bogatyri.Settlement.Population). // Документ: Docs/Modules/Population.md. Без Unity: правила те же, что будут на сервере. // // Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час) // и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах. // Жители: часть может работать (Workforce, стартовый город -- 120 из 270), из них рабочие стоят на профессиях. +// Найм воина: свободный житель уходит в Казармы (InTraining), после обучения его считает Армия. Резерв не нанять. // // Время -- только из GameClock. Расчёт идёт кусками по 10 минут: раз в несколько секунд в игре, // при входе в игру (офлайн-прогресс) и при перемотке в отладочной панели -- правила одни и те же. @@ -28,6 +31,10 @@ public sealed class PopulationService : IPopulation, ISaveParticipant double foodOwed; // съедено, но ещё не списано со склада (меньше 1 еды) long lastTickUnix; // до какого момента население уже посчитано; 0 -- ещё ни разу bool starving; + int inTraining; // люди, взятые из жителей в Казармы и ещё не обученные + // История голода: отрезки [начало, конец) в Unix-секундах, конец 0 -- голод идёт сейчас. + // Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод считается на момент боя. + readonly List famine = new List(); bool startingKitGiven; // стартовое войско и ресурсы новой игры уже выданы (или это старое сохранение) public PopulationService(PopulationRules rules) @@ -53,14 +60,14 @@ public sealed class PopulationService : IPopulation, ISaveParticipant public int Idle => Math.Max(0, civilians - Workers); - // Доля жителей, способных работать: 120 из 270 в стартовом городе + // Сколько людей могут работать: та же доля от всех людей города, что в стартовом городе (120 из 300). + // Считается от всех людей, а не от жителей: найм воина из свободных жителей рабочих не снимает. public int Workforce { get { - int startCiv = Math.Max(1, rules.StartCivilians); - double share = Math.Min(1.0, rules.StartWorkers / (double)startCiv); - return (int)Math.Floor(civilians * share + Epsilon); + double share = Math.Min(1.0, rules.StartWorkers / (double)Math.Max(1, rules.StartTotal)); + return Math.Min(civilians, (int)Math.Floor(Total * share + Epsilon)); } } @@ -75,7 +82,42 @@ public sealed class PopulationService : IPopulation, ISaveParticipant } } - public int Total => civilians + Soldiers; + public int Total => civilians + inTraining + Soldiers; + + // ---------------- Найм воинов ---------------- + + public int InTraining => inTraining; + + public int CivilianReserve + { + get + { + int level = GameServices.TryGet(out ISettlement settlement) ? Math.Max(1, settlement.TownHallLevel) : 1; + return Math.Max(0, rules.CivilianReserve + rules.CivilianReservePerTownHallLevel * (level - 1)); + } + } + + // Берём только свободных (Idle -- не на работе): рабочих сначала надо снять с профессии + public int Recruitable => Math.Max(0, Idle - CivilianReserve) / Math.Max(1, rules.PeoplePerSoldier); + + public bool TryRecruit(int soldiers) + { + if (soldiers <= 0 || soldiers > Recruitable) return false; + int people = soldiers * Math.Max(1, rules.PeoplePerSoldier); + civilians -= people; + inTraining += people; + ClampJobs(); // жителей меньше -- рабочих тоже может стать меньше + Changed(); + return true; + } + + public void RecruitsTrained(int soldiers) + { + if (soldiers <= 0) return; + // Заказы, начатые до найма из жителей (старое сохранение), людей не брали -- уходить в минус нельзя + inTraining = Math.Max(0, inTraining - soldiers * Math.Max(1, rules.PeoplePerSoldier)); + Changed(); + } public int HousingCap { @@ -90,9 +132,33 @@ public sealed class PopulationService : IPopulation, ISaveParticipant public float GrowthPerHour => starving || Total >= HousingCap ? 0f : (float)rules.GrowthPerHour; public bool IsStarving => starving; + public bool WasStarvingAt(long unixTime) + { + if (lastTickUnix == 0 || unixTime >= lastTickUnix) return starving; // ещё не посчитано -- как сейчас + foreach (var f in famine) + if (unixTime >= f[0] && (f[1] == 0 || unixTime < f[1])) return true; + return false; + } + + // Голод начался или кончился в момент unix (вызывается из расчёта на каждом шаге) + void RecordFamine(bool nowStarving, long unix) + { + long[] open = famine.Count > 0 && famine[famine.Count - 1][1] == 0 ? famine[famine.Count - 1] : null; + if (nowStarving && open == null) famine.Add(new[] { unix, 0L }); + else if (!nowStarving && open != null) open[1] = unix; + } + + // Отрезки, закончившиеся раньше, чем 48 часов назад, Бою уже не понадобятся + void PruneFamine(long unixNow) + { + long keepFrom = unixNow - (long)rules.MaxOfflineHours * SecondsPerHour; + famine.RemoveAll(f => f[1] != 0 && f[1] <= keepFrom); + } + double FoodPerHourExact(int soldiers) { - return civilians * rules.FoodPerCivilianPerHour + soldiers * rules.FoodPerSoldierPerHour; + // Обучающиеся в Казармах едят как воины + return civilians * rules.FoodPerCivilianPerHour + (soldiers + inTraining) * rules.FoodPerSoldierPerHour; } public int GetWorkers(WorkJob job) @@ -146,13 +212,14 @@ public sealed class PopulationService : IPopulation, ISaveParticipant long max = (long)rules.MaxOfflineHours * SecondsPerHour; if (elapsed > max) elapsed = max; - Simulate(elapsed); + Simulate(unixNow - elapsed, elapsed); lastTickUnix = unixNow; + PruneFamine(unixNow); } // ---------------- Расчёт ---------------- - void Simulate(long seconds) + void Simulate(long startUnix, long seconds) { if (!GameServices.TryGet(out IEconomy economy)) return; // без склада считать нечего GameServices.TryGet(out IProduction production); @@ -162,6 +229,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant int soldiers = Soldiers; long remaining = seconds; + long stepStart = startUnix; while (remaining > 0) { long step = Math.Min(remaining, StepSeconds); @@ -195,11 +263,13 @@ public sealed class PopulationService : IPopulation, ISaveParticipant fed = false; } starving = !fed; + RecordFamine(starving, stepStart); // голод начался (кончился) в этом куске -- запоминаем его начало + stepStart += step; // 2. Прирост -- только сытым и только до предела жилья. Что ещё бывает при голоде -- решит команда. if (fed) { - int room = HousingCap - (civilians + soldiers); + int room = HousingCap - (civilians + inTraining + soldiers); if (room > 0) { growthProgress += rules.GrowthPerHour * hours; @@ -327,6 +397,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant foodOwed = 0; lastTickUnix = 0; starving = false; + inTraining = 0; + famine.Clear(); startingKitGiven = false; } @@ -348,6 +420,12 @@ public sealed class PopulationService : IPopulation, ISaveParticipant sb.Append("food=").Append(foodOwed.ToString("R", CultureInfo.InvariantCulture)).Append('\n'); sb.Append("lastTick=").Append(lastTickUnix.ToString(CultureInfo.InvariantCulture)).Append('\n'); sb.Append("starving=").Append(starving ? "1" : "0").Append('\n'); + sb.Append("training=").Append(inTraining.ToString(CultureInfo.InvariantCulture)).Append('\n'); + // famine=начало-конец;начало-конец (конец 0 -- голод идёт сейчас) + var spans = new List(); + foreach (var f in famine) + spans.Add(f[0].ToString(CultureInfo.InvariantCulture) + "-" + f[1].ToString(CultureInfo.InvariantCulture)); + sb.Append("famine=").Append(string.Join(";", spans.ToArray())).Append('\n'); sb.Append("kit=").Append(startingKitGiven ? "1" : "0").Append('\n'); return sb.ToString(); } @@ -385,6 +463,17 @@ public sealed class PopulationService : IPopulation, ISaveParticipant if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) lastTickUnix = Math.Max(0, t); break; case "starving": starving = value == "1"; break; + case "training": inTraining = Math.Max(0, ParseInt(value, 0)); break; + case "famine": + foreach (var span in value.Split(';')) + { + string[] se = span.Split('-'); + if (se.Length == 2 + && long.TryParse(se[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long fs) + && long.TryParse(se[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long fe)) + famine.Add(new[] { fs, fe }); + } + break; case "kit": startingKitGiven = value == "1"; break; } } diff --git a/Assets/Scripts/Modules/Population/PopulationService.cs.meta b/Assets/Scripts/Modules/Settlement/Population/PopulationService.cs.meta similarity index 100% rename from Assets/Scripts/Modules/Population/PopulationService.cs.meta rename to Assets/Scripts/Modules/Settlement/Population/PopulationService.cs.meta diff --git a/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef b/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef index f9caf85..559fcd2 100644 --- a/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef +++ b/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef @@ -6,7 +6,7 @@ "UnityEditor.TestRunner", "Bogatyri.Core", "Bogatyri.Army", - "Bogatyri.Population", + "Bogatyri.Settlement.Population", "Bogatyri.Economy.Rules", "Bogatyri.Battle", "Bogatyri.World.Simulation", diff --git a/Assets/Scripts/Modules/Tests/PopulationTests.cs b/Assets/Scripts/Modules/Tests/PopulationTests.cs index 2a4f097..1c1c655 100644 --- a/Assets/Scripts/Modules/Tests/PopulationTests.cs +++ b/Assets/Scripts/Modules/Tests/PopulationTests.cs @@ -118,6 +118,125 @@ public class PopulationTests Assert.IsTrue(p.IsStarving); } + // ---------------- Найм воинов из жителей (решение команды от 19.10.2026) ---------------- + + [Test] + public void Recruit_150Free_Reserve30_Max120() + { + var p = StartCity(); + p.SetWorkers(WorkJob.Farmer, 120); // 120 рабочих -- свободных 150 + Assert.AreEqual(150, p.Idle); + Assert.AreEqual(30, p.CivilianReserve); + Assert.AreEqual(120, p.Recruitable, "пример команды: 150 свободных, резерв 30 -> 120"); + Assert.IsFalse(p.TryRecruit(121)); + Assert.IsTrue(p.TryRecruit(120)); + Assert.AreEqual(0, p.Recruitable); + } + + [Test] + public void Recruit_TakesCivilians_TotalStaysTheSame() + { + var p = StartCity(); + Assert.IsTrue(p.TryRecruit(20)); + Assert.AreEqual(250, p.Civilians); + Assert.AreEqual(20, p.InTraining); + Assert.AreEqual(300, p.Total, "житель стал новобранцем -- людей столько же"); + + p.RecruitsTrained(20); + army.AddTroops(0, 20); // Казармы выпускают воинов в Армию + Assert.AreEqual(0, p.InTraining); + Assert.AreEqual(50, p.Soldiers); + Assert.AreEqual(300, p.Total, "новобранцы не посчитаны дважды"); + } + + [Test] + public void Recruit_TakesOnlyFree_WorkersStayAtWork() + { + var p = StartCity(); + p.SetWorkers(WorkJob.Farmer, 120); + p.TryRecruit(120); + Assert.AreEqual(120, p.Workers, "рабочие остались на работе"); + Assert.AreEqual(120, p.Workforce); + Assert.AreEqual(30, p.Idle, "остался резерв"); + } + + [Test] + public void Recruits_EatLikeSoldiers_AndAreSaved() + { + var p = StartCity(); + p.TryRecruit(10); + Assert.AreEqual(260 * 0.1 + (30 + 10) * 0.2, p.FoodPerHour, 0.001); + + var copy = new PopulationService(new PopulationRules()); + copy.Restore(p.Capture()); + Assert.AreEqual(10, copy.InTraining); + Assert.AreEqual(260, copy.Civilians); + + copy.ResetState(); + Assert.AreEqual(0, copy.InTraining); + } + + [Test] + public void OldTrainingOrder_WithoutRecruits_DoesNotGoNegative() + { + var p = StartCity(); + p.RecruitsTrained(5); // заказ из старого сохранения -- людей тогда не брали + Assert.AreEqual(0, p.InTraining); + Assert.AreEqual(270, p.Civilians); + } + + // ---------------- История голода (вопрос Саши 7): бой, прошедший офлайн, знает голод на свой момент ---------------- + + [Test] + public void FamineHistory_OfflineBattleSeesHungerAtItsMoment() + { + economy.Set(ResourceType.Food, 50); // 33 еды в час -- хватит примерно на полтора часа + var p = StartCity(); + + p.Tick(T0 + 5 * Hour); // игра была закрыта 5 часов + Assert.IsFalse(p.WasStarvingAt(T0 + Hour), "через час еда ещё была"); + Assert.IsTrue(p.WasStarvingAt(T0 + 2 * Hour), "через два часа -- голод"); + Assert.IsTrue(p.WasStarvingAt(T0 + 5 * Hour), "и сейчас голод"); + + economy.Set(ResourceType.Food, 1000); // привезли еду + p.Tick(T0 + 8 * Hour); + Assert.IsFalse(p.IsStarving); + Assert.IsTrue(p.WasStarvingAt(T0 + 3 * Hour), "прошлый голод помнится"); + Assert.IsFalse(p.WasStarvingAt(T0 + 7 * Hour), "после еды голода нет"); + } + + [Test] + public void FamineHistory_SurvivesSave_AndIsClearedByReset() + { + economy.Set(ResourceType.Food, 50); + var p = StartCity(); + p.Tick(T0 + 5 * Hour); + economy.Set(ResourceType.Food, 1000); + p.Tick(T0 + 8 * Hour); + + var copy = new PopulationService(new PopulationRules()); + copy.Restore(p.Capture()); + Assert.IsTrue(copy.WasStarvingAt(T0 + 3 * Hour)); + Assert.IsFalse(copy.WasStarvingAt(T0 + Hour)); + + copy.ResetState(); + Assert.IsFalse(copy.WasStarvingAt(T0 + 3 * Hour), "новая игра -- истории нет"); + } + + [Test] + public void FamineHistory_OlderThan48Hours_IsForgotten() + { + economy.Set(ResourceType.Food, 50); + var p = StartCity(); + p.Tick(T0 + 5 * Hour); + economy.Set(ResourceType.Food, 10000); + p.Tick(T0 + 8 * Hour); + p.Tick(T0 + 60 * Hour); + + Assert.IsFalse(p.WasStarvingAt(T0 + 3 * Hour), "голод 57 часов назад бою уже не нужен"); + Assert.IsFalse(p.IsStarving); + } + [Test] public void FoodReturns_StarvationEnds_GrowthResumes() { diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index 64cfc7f..c9aa323 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -31,7 +31,7 @@ | `IProduction` | Экономика (`ProductionService`) | Места для рабочих по профессиям, эффективность, добыча в час; `Produce` — добыть за время. | | `ISettlement` | Поселение (`SettlementModule`) | Уровень Ратуши, число построек, открыт ли город. | | `ILeveledBuilding` | Здания с уровнем (`ResourceCollector`) | Уровень здания — его сохраняет Поселение. | -| `IPopulation` | Население (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод; `Tick` — досчитать до момента. | +| `IPopulation` | Поселение, часть «Население» (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод и его история (`WasStarvingAt`), найм воинов из жителей; `Tick` — досчитать до момента. | | `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. | | `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. | | `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`. | @@ -58,6 +58,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | `IPopulation`: `WasStarvingAt` (история голода для боя), найм — `CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`. Население — часть Поселения. | | 27.10.2026 | Claude | `WorkJob`; контракт `IProduction`; `IEconomy.GetCapacity`; `IPopulation`: `Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`; событие `WorkersChangedEvent`. | | 26.10.2026 | Claude | `GameClock`; контракт `IPopulation`; события `GameTimeAdvancedEvent`, `PopulationChangedEvent`, `StarvationChangedEvent`, `NewGameStartedEvent`. | | 23.10.2026 | Claude | `IArmy` расширен (типы, `AddTroops`, `LoseFraction`, `TroopsInMarches`), событие `TroopsChangedEvent`. `GameLoadedEvent` публикуется и когда файла сохранения нет. | diff --git a/Docs/Modules/Population.md b/Docs/Modules/Population.md index 651f997..1b6733b 100644 --- a/Docs/Modules/Population.md +++ b/Docs/Modules/Population.md @@ -1,19 +1,22 @@ -# Население +# Поселение: население -Сколько в городе людей, сколько они едят и как прирастают. Этап 2 плана разработки. +Люди города: сколько их, кто работает, сколько едят, как прирастают и кого можно нанять в войско. Это часть модуля [Поселения](Settlement.md): Поселение — это город, его здания и люди. Этап 2 плана разработки. -- **Сборка:** `Bogatyri.Population` (без Unity). -- **Папка:** `Assets/Scripts/Modules/Population`. +- **Ответственный:** Вальдемар. +- **Сборка:** `Bogatyri.Settlement.Population` (без Unity). Отдельная сборка внутри Поселения — как правила добычи внутри Экономики: правила людей без Unity переедут на сервер. +- **Папка:** `Assets/Scripts/Modules/Settlement/Population`. - **Зависит от:** ядра. Еду берёт через `IEconomy`, воинов считает через `IArmy`, уровень Ратуши — через `ISettlement`. ## Как устроено -- **Люди города = жители + воины.** Жителей хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах. -- **Жители:** способные работать (`Workforce` — 120 из 270 в стартовом городе, доля сохраняется при росте) и остальные. Способные работать стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота) или свободны (`FreeWorkers`). +- **Люди города = жители + новобранцы в Казармах + воины.** Жителей и новобранцев хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах. +- **Жители:** способные работать (`Workforce` — 40% всех людей города, в стартовом городе 120 из 300) и остальные. Способные работать стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота) или свободны (`FreeWorkers`). +- **Найм воинов** (решение команды от 19.10.2026). Воины набираются из свободных жителей (не на работе), гражданский резерв не отдаётся: 150 свободных, резерв 30 — нанять можно не больше 120 (`Recruitable`). Воин стоит `PeoplePerSoldier` жителей (1). Человек уходит из жителей в начале обучения (`TryRecruit`), учится в Казармах (`InTraining`, ест как воин) и после обучения переходит в Армию (`RecruitsTrained`). Погибшие в бою воины уменьшают население — их больше нет в Армии. Резерв задаёт уровень поселения: `CivilianReserve` + прибавка за уровень Ратуши (прибавка не утверждена, сейчас 0). - **Рабочих ставят** окно здания (`SetWorkers`) и `App/CitySync` (новые места заполняются свободными сами). Больше, чем мест в зданиях (`IProduction.Slots`) и свободных, не поставить. - **Еда в час:** 0.1 на жителя, 0.2 на воина. Стартовый город (270 жителей + 30 воинов) ест 33 еды в час. - **Прирост:** +2.5 человека в час, только если еды хватило и население ниже предела жилья. -- **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Что ещё происходит при голоде, команда пока не решила. +- **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Бой ослабляет голодное войско (модуль Боя, −50%). +- **История голода:** модуль помнит отрезки голода за последние `MaxOfflineHours` (48 ч) с точностью до шага расчёта (10 минут). Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод берётся на момент боя (`WasStarvingAt`), а не на момент входа в игру. - **Предел жилья:** `HousingBase + HousingPerTownHallLevel × (уровень Ратуши − 1) + HousingPerHouseLevel × уровни Домов` = 300 + 50 + 50. По решению команды от 19.10 стартовый город (300) упирается в предел: прирост пойдёт после постройки Дома. Прибавки не утверждены. - **Добыча** идёт внутри расчёта населения: в каждом куске сначала `IProduction.Produce`, потом еда, потом прирост. Иначе офлайн город «голодал» бы, хотя фермы работали. - **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, расчёт просто продолжается с «сейчас». @@ -31,6 +34,11 @@ | `HousingCap` | Предел населения. | | `FoodPerHour`, `GrowthPerHour` | Расход еды и прирост в час сейчас. | | `IsStarving` | Еды не хватило на последний расчёт. | +| `CivilianReserve` | Гражданский резерв: столько свободных жителей в войско не взять. | +| `Recruitable` | Сколько воинов можно нанять прямо сейчас: (свободные − резерв) / жителей на воина. | +| `InTraining` | Людей, взятых из жителей и ещё обучающихся в Казармах. | +| `TryRecruit(n)`, `RecruitsTrained(n)` | Забрать людей в Казармы в начале обучения; обучение закончено — их считает Армия. | +| `WasStarvingAt(unixTime)` | Голодал ли город в этот момент (секунды Unix, время `GameClock`). Позже последнего расчёта -- как сейчас; старше 48 часов -- нет. | | `Tick(unixNow)` | Досчитать население до момента `unixNow`. | Получить: `GameServices.Get()`. @@ -48,7 +56,7 @@ ## Сохранение -Секция `"population"`, простые строки: `civilians=`, `jobs=` (рабочие по профессиям), `jobsInit=`, `houses=`, `growth=`, `food=`, `lastTick=`, `starving=`, `kit=`. +Секция `"population"`, простые строки: `civilians=`, `jobs=` (рабочие по профессиям), `jobsInit=`, `houses=`, `growth=`, `food=`, `lastTick=`, `starving=`, `training=` (новобранцы в Казармах), `famine=` (история голода: `начало-конец;начало-конец`, конец 0 -- голод идёт), `kit=`. Сброс (`ResetState`): стартовый город, 270 жителей. Стартовое войско (30 пехоты) и стартовые ресурсы выдаёт `App/GameModules` после сброса и при первой игре (`NewGameStartedEvent`) — один раз (`kit=1`). Старым сохранениям без секции населения стартовый набор не выдаётся. @@ -57,6 +65,8 @@ | Файл | Что делает | | --- | --- | | `App/GameModules.cs` | Создаёт модуль с числами из `EconomyConfig`, подписывает на события, выдаёт стартовый набор, держит ресурс «Люди» на верхней панели равным `Total`. | +| `Meta/Core/GameProgress.cs` | Казармы: перед обучением проверяет `Recruitable`, забирает людей (`TryRecruit`), после обучения — `RecruitsTrained`, потом воины в Армию. | +| `Meta/Panels/TroopsPanel.cs` | Панель «Войска»: «Обучаются в Казармах», «Можно нанять из жителей (резерв N)»; кнопки найма серые, если жителей не хватает. | | `App/PopulationTicker.cs` | Раз в 2 секунды вызывает `Tick` (модуль без Unity и не знает про `Update`). | | `App/DebugPanel.cs` | Кнопка «DEV»: «+1 час», «+10 часов», ресурсы, «Еда = 0». Только редактор и Development Build. | @@ -80,18 +90,26 @@ - перемотка идёт через те же правила; - сохранение; - стартовый набор — один раз; -- сброс. +- сброс; +- найм: 150 свободных, резерв 30 → не больше 120; людей столько же, новобранцы не считаются дважды; рабочие остаются на работе; новобранцы едят как воины и сохраняются; старый заказ обучения не уводит в минус; +- история голода: бой офлайн видит голод на свой момент, история сохраняется, сбрасывается и забывается через 48 часов. ## Решения и план - **Голод:** дебаффы (в том числе у защитников) и правило снятия — решение команды от 19.10.2026, числа не утверждены. Модуль уже сообщает `StarvationChangedEvent`. - **Предел жилья:** по решению команды растёт с постройкой Дома — сделано (здание «Население»). - **Этап 3:** сделано 27.10.2026 — рабочие по профессиям, Дома, добыча в общем расчёте. -- **Этап 5:** найм воина забирает человека из жителей (гражданский резерв — решение от 19.10.2026). +- **Вопросы Саши от 28.10.2026 (отчёт по военной части):** + - п. 7, история голода -- сделано (`WasStarvingAt`). Бою нужно перейти с `IsStarving` на `WasStarvingAt(время боя)`. Походы идут по реальному времени, Население -- по `GameClock`: после перемотки в DEV-панели они расходятся. + - п. 8, норма еды на солдата -- сейчас 0.2 еды в час реального времени, игрового календаря нет. Предложение: не вводить; если нужен «игровой месяц» -- 1 месяц = 1 сутки, норма ≈ 5 еды. Решает Дима. +- **Этап 5:** найм воина забирает человека из жителей (гражданский резерв — решение от 19.10.2026) — сделано 28.10.2026. Цену воина в ресурсах (`EconomyConfig`, раздел «Солдат»: 15 еды, 5 металла, 5 золота) Казармы пока не используют — у них своя цена в `GameData.Troops`. +- **Схема модулей (28.10.2026):** население — часть Поселения, как на схеме Димы из 8 модулей. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 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). | | 27.10.2026 | Claude | Рабочие по профессиям (`Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`, автозаполнение), Дома в пределе жилья, базовый предел 300 (решение 19.10), добыча в общем расчёте. | | 26.10.2026 | Claude | Создан модуль: жители, еда, прирост, голод, предел жилья, офлайн-прогресс, секция `population`, тесты. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 7de08fb..a73d10f 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -8,7 +8,7 @@ ┌───────────┬────────────┬───────────┬──────────────┬───────────────┬────────────┬──────────┬──────────┐ Экономика Поселение Армия Глобальный мир Боевая система Сохранения Чат Круг ресурсы, здания, войска, карта, урон, потери, игровое мир и объединение - производство развитие характеристики перемещения исход состояние Круг игроков + производство и люди характеристики перемещения исход состояние Круг игроков ▲ App/GameModules — точка сборки: регистрирует модули и соединяет их события со старыми системами (GameProgress, Казармы, панели UI) @@ -18,9 +18,8 @@ | --- | --- | --- | --- | | Игровое ядро | `Bogatyri.Core` (без Unity), `Bogatyri.Core.Unity` | `Assets/Scripts/Modules/Core`, `Core.Unity` | [Core.md](Core.md) | | Экономика | `Bogatyri.Economy`, `Bogatyri.Economy.Rules` (без Unity) | `Assets/Scripts/Modules/Economy` | [Economy.md](Economy.md) | -| Поселение | `Bogatyri.Settlement` | `Assets/Scripts/Modules/Settlement` | [Settlement.md](Settlement.md) | +| Поселение | `Bogatyri.Settlement` (здания), `Bogatyri.Settlement.Population` (люди, без Unity) | `Assets/Scripts/Modules/Settlement`, люди — `Settlement/Population` | [Settlement.md](Settlement.md), [Population.md](Population.md) | | Армия | `Bogatyri.Army` (без Unity) | `Assets/Scripts/Modules/Army` | [Army.md](Army.md) | -| Население | `Bogatyri.Population` (без Unity) | `Assets/Scripts/Modules/Population` | [Population.md](Population.md) | | Глобальный мир | `Bogatyri.World`, `Bogatyri.World.Simulation` (без Unity) | `Assets/Scripts/Modules/World` | [World.md](World.md) | | Боевая система | `Bogatyri.Battle` (без Unity) | `Assets/Scripts/Modules/Battle` | [Battle.md](Battle.md) | | Сохранения | `Bogatyri.Save` | `Assets/Scripts/Modules/Save` | [Save.md](Save.md) | @@ -52,15 +51,17 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026): - [x] лимит жилья: 300 жителей при лимите 300 — прирост стоит, предел вырос — прирост идёт (`PopulationTests`; пока предел растёт от Ратуши, Дома ещё нет); -- [ ] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120; +- [x] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120 (`PopulationTests`); - [ ] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (основа — тест «один солдат в одном месте» для походов уже есть); -- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу; +- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу (голод в бою — у Саши; история голода — `PopulationTests`); - [ ] предел синих кристаллов: кристаллы не копятся бесконечно, сверх лимита — по общему правилу. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 28.10.2026 | Claude | Население стало частью Поселения, как на схеме Димы: `Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом, история голода для боя. Обновлены Settlement, Population, Core. | +| 27.10.2026 | Claude | Свободная стройка на сетке: призрак здания, красные занятые клетки, разный размер зданий. Обновлён Settlement. | | 27.10.2026 | Claude | Этапы 1 и 3: склады и лимиты, добыча через рабочих, окно здания и рабочих, улучшение зданий, Склад и Амбар. Тесты `ProductionTests`. Обновлены Economy, Population, Core, Save. | | 26.10.2026 | Claude | Модуль Населения ([Population.md](Population.md)), таблица баланса `EconomyConfig` (`Assets/Resources/EconomyConfig.asset`), часы игры `GameClock`, отладочная панель «DEV». Обновлены Core, Economy, Save. | | 23.10.2026 | Claude | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе (`UI/MarchReturnPopup.cs`), обновлён World. | diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md index f2d0389..a44f2f9 100644 --- a/Docs/Modules/Settlement.md +++ b/Docs/Modules/Settlement.md @@ -1,11 +1,33 @@ # Поселение -Здания и развитие города: Ратуша, участки под застройку, постройка зданий, восстановление города из сохранения. +Город: его здания и люди. -- **Сборка:** `Bogatyri.Settlement`. -- **Папка:** `Assets/Scripts/Modules/Settlement`. +- **Здания** (этот документ): Ратуша, свободная стройка на сетке, постройка зданий, восстановление города из сохранения. +- **Люди** — жители, рабочие, еда, голод, прирост, жильё, найм воинов: [Поселение: население](Population.md). + +- **Сборки:** `Bogatyri.Settlement` (здания, Unity), `Bogatyri.Settlement.Population` (люди, без Unity). +- **Папка:** `Assets/Scripts/Modules/Settlement`, люди — `Settlement/Population`. - **Зависит от:** ядра. Ресурсы на стройку берёт через `IEconomy`. +## Стройка на сетке + +Игрок сам выбирает, где строить. Участки (`BuildSlot`) больше не нужны: они и площадки «Застройка» прячутся при запуске, сцену менять не надо. + +- **Сетка (`BuildGrid`).** Клетка — 1 единица мира, сетка прямоугольная. Область стройки — прямоугольник вокруг старых участков и Ратуши с запасом `padding` (3 клетки). Можно задать руками: поставить `BuildGrid` на сцену, `autoArea = false`, `areaMin` / `areaMax`. Объект создаётся сам, если его нет. +- **Занятые клетки:** под Ратушей и под каждым построенным зданием. Пересчёт раз в 0.3 с, пока сетка видна, и перед каждой постройкой. +- **Размер здания:** `BuildingData.footprint` (ширина × высота в клетках). `0 × 0` — посчитать по `BoxCollider2D` префаба (иначе по картинке), округлив до клеток. Размер здания задаётся там же, в карточке здания. +- **Призрак (`BuildGhost`):** полупрозрачная копия картинки здания и пятно клеток под ней — зелёное (свободно) или красное (занято / за границей). Только картинка: скриптов и коллайдеров нет, экономика его не видит. +- **Постройка:** `BuildManager.BuildAt(data, cell)` — проверяет клетки и ресурсы, списывает цену, ставит префаб так, чтобы его коллайдер лёг на клетки призрака, публикует `BuildingPlacedEvent`. `CanBuildAt(data, cell)` — та же проверка без постройки. +- **Старые сохранения:** здания встают на свои позиции, занятость считается по тому, какие клетки здание накрывает. Ничего переносить не нужно. + +Управление — `Meta/Build/BuildModeController` (слой приложения): + +1. «СТРОЙКА» — сетка на земле, подсказка сверху, список зданий (размер в клетках, цена; закрытые по уровню Ратуши — серые). +2. «Выбрать» — призрак встаёт в центр экрана или на ближайшее свободное место. +3. Тащить призрак пальцем (за пятно) или тапнуть по земле — призрак прыгает туда. Камера в это время стоит (`UIBlocker.WorldInputCaptured`). +4. Нижняя панель: «Поставить» (место свободно и хватает ресурсов), «Другое», «Отмена». +5. После постройки режим остаётся; «Здания» сверху — строить дальше, «Выйти» — сетка прячется. + ## Контракт `ISettlement` | Свойство | Что значит | @@ -39,10 +61,12 @@ | Файл | Что это | | --- | --- | | `SettlementModule.cs` | Точка входа: `ISettlement`, сохранение города, восстановление на сцене. | -| `BuildManager.cs` | Режим стройки, список зданий, участки. | -| `BuildingData.cs` | Описание здания (ScriptableObject): префаб, цена, нужный уровень Ратуши. | +| `BuildManager.cs` | Список зданий, постройка на сетке (`BuildAt`), поиск данных здания по имени. | +| `BuildGrid.cs` | Сетка стройки: область, занятые клетки, рисунок сетки. Там же `BuildFootprint` — размер здания в клетках. | +| `BuildGhost.cs` | Призрак здания в режиме стройки. | +| `BuildingData.cs` | Описание здания (ScriptableObject): префаб, размер на сетке, цена, нужный уровень Ратуши. | | `TownHall.cs` | Ратуша и её улучшение. Раньше файл назывался `NewEmptyCSharpScript.cs`. | -| `BuildSlot.cs` | Участок под здание и связь здания с участком. | +| `BuildSlot.cs` | Участок под здание (старая схема, участки прячутся) и связь здания с участком. | | `PlacedBuildingId.cs` | Метка построенного здания. Раньше файл назывался `PlaceBuilding.cs`. | В слое приложения остаются `BuildMenuUi.cs`, `Meta/Build/*` (Казармы, `BuildModeController`). @@ -53,11 +77,14 @@ - Лимит населения = база Ратуши + сумма Домов + бонусы, значения из `EconomyConfig`. - Уровень поселения задаёт доступные уровни и количество жилья, а также минимальный гражданский резерв. - **Этап 3:** рабочие места в зданиях (`BuildingData`), экран распределения рабочих. +- **Стройка на сетке (27.10.2026, по просьбе Володи):** вернулись к свободной стройке — игрок сам выбирает место, сетка показывает границы и красные занятые клетки, у зданий разный размер. - **Этап 4 (утверждён без изменений):** доступность зданий зависит от месторождений позиции. ## Журнал изменений | Дата | Кто | Что | | --- | --- | --- | +| 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` (призрак, «Поставить / Другое / Отмена»), камера не двигается, пока тащат призрак. | | 19.10.2026 | Claude | `ResetState()` для сброса сохранений. Убранные здания помечаются, чтобы запись в том же кадре их не сохранила. | | 19.10.2026 | Claude | Модуль выделен. Стройка и Ратуша платят через `IEconomy`. Сохранение города перенесено из `SaveManager` в `SettlementModule` (секция `"settlement"`). Восстановление защищено от двойного запуска и от освобождения занятого участка. Файлы переименованы под имена классов. |