From a7416ac99d9a88821d48c9a5917fc3598ac4e4e8 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 31 Oct 2026 16:38:41 +0100 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D0=B0?= =?UTF-8?q?=D1=80=D0=BC=D0=B8=D0=B9=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=B5=20=D0=BC=D0=B8=D1=80=D0=B0=20(=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=BB=D0=B0=D0=BC=D0=B5=D0=BD=D1=82=2031.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кнопка «Армии» под «Рейтингом» открывает окно WorldMap/ArmiesWindow.cs: - вверху: дома свободно, гарнизон, походов N из M - по каждому войску: куда и что делает (в бой / на сбор / собирает / возвращается), состав по типам, груз, время до цели или до конца сбора и когда будет дома - таймеры обновляются сами, пока окно открыто Окно атаки: для боя — длительность всего задания «туда и обратно ~X» (для сбора уже была). Только наш слой карты мира; модули и код Валдемара не менялись. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/WorldMap/ArmiesWindow.cs | 202 ++++++++++++++++++ Assets/Scripts/WorldMap/ArmiesWindow.cs.meta | 11 + Assets/Scripts/WorldMap/WorldMapController.cs | 23 +- Docs/Modules/README.md | 1 + Docs/Modules/World.md | 3 + 5 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 Assets/Scripts/WorldMap/ArmiesWindow.cs create mode 100644 Assets/Scripts/WorldMap/ArmiesWindow.cs.meta diff --git a/Assets/Scripts/WorldMap/ArmiesWindow.cs b/Assets/Scripts/WorldMap/ArmiesWindow.cs new file mode 100644 index 0000000..73fbb0d --- /dev/null +++ b/Assets/Scripts/WorldMap/ArmiesWindow.cs @@ -0,0 +1,202 @@ +using System.Collections.Generic; +using Bogatyri.World; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +// Окно «Армии» (слой приложения): где каждое войско игрока, что делает, состав, груз и сколько ждать. +// Регламент 31.10, раздел 7: «Панель армий нужна всем игрокам: местонахождение, действие, состав, груз, +// время марша, добычи и ожидаемого возвращения». Данные -- модули Мира (походы) и Армии (дома, гарнизон, слоты). +// Документ: Docs/Modules/World.md +// +// Открыть: ArmiesWindow.Open() -- кнопка «Армии» на карте мира. Таймеры обновляются сами, пока окно открыто. +public static class ArmiesWindow +{ + const float ListHeight = 620f; + + static GameObject root; + + public static bool IsOpen => root != null; + + public static void Open() + { + Close(); + if (MarchManager.Instance == null) return; + RectTransform card = UIKit.Modal("Армии", out root); + var view = root.AddComponent(); + view.Build(card); + + var close = UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 90, 30); + UIKit.Size(close, -1, 90); + } + + public static void Close() + { + if (root != null) Object.Destroy(root); + root = null; + } + + // Живая часть окна: строки походов обновляются 2 раза в секунду, список -- когда походы меняются + class ArmiesView : MonoBehaviour + { + TextMeshProUGUI summary; + RectTransform list; + readonly List<(int marchId, TextMeshProUGUI text)> rows = new List<(int, TextMeshProUGUI)>(); + string shownKey; + float nextRefresh; + + WorldSimulation World => MarchManager.Instance.World; + + public void Build(RectTransform card) + { + summary = UIKit.Text(card, "", 26, UIKit.Theme.Text, TextAlignmentOptions.Center); + var holder = UIKit.Make("ListHolder", card); + UIKit.Size(holder, -1, ListHeight); + list = UIKit.ScrollList(holder, out ScrollRect _, 10); + Refresh(); + } + + void Update() + { + if (Time.unscaledTime < nextRefresh) return; + nextRefresh = Time.unscaledTime + 0.5f; + Refresh(); + } + + void Refresh() + { + long now = MarchManager.NowMs; + var mine = new List(); + foreach (var m in World.Marches) + if (m.ownerId == MarchManager.LocalPlayerId) mine.Add(m); + + // Дома: свободные, гарнизон, слоты похода + if (GameServices.TryGet(out IArmy army)) + summary.text = $"Дома свободно: {army.AvailableForMarch} • гарнизон: {army.GarrisonTotal} • " + + $"походов: {army.ActiveMarches} из {army.MarchSlots}"; + + // Состав списка поменялся -- строим заново, иначе только обновляем тексты + string key = string.Join(",", mine.ConvertAll(m => m.id + ":" + (int)m.state)); + if (key != shownKey) + { + shownKey = key; + UIKit.Clear(list); + rows.Clear(); + if (mine.Count == 0) + UIKit.Text(list, "Все войска дома. Отправьте войско: нажмите на цель на карте.", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + foreach (var m in mine) + { + var row = UIKit.Row(list, 150, 14, UIKit.Theme.Card, 14); + UIKit.Icon(row, StateColor(m), StateLetter(m), 72); + var text = UIKit.Text(row, "", 22, UIKit.Theme.Text); + UIKit.Size(text, -1, 140, 1); + rows.Add((m.id, text)); + } + } + + foreach (var (id, text) in rows) + { + MarchData m = mine.Find(x => x.id == id); + if (m != null) text.text = Describe(m, now); + } + } + + // Четыре строки: куда и что делает, состав, груз, сколько ждать + string Describe(MarchData m, long now) + { + string target = World.TargetName(m); + int soldiers = m.state == MarchState.Returning ? m.survivors : m.soldiers; + int[] units = m.state == MarchState.Returning && m.survivorUnits != null ? m.survivorUnits : m.units; + + string action; + string timer; + switch (m.state) + { + case MarchState.Outbound: + action = m.gather ? $"идёт на сбор: {target}" : $"идёт в бой: {target}"; + timer = $"до цели {Clock(m.arriveMs - now)}, вернётся примерно через {Clock(ExpectedReturn(m) - now)}"; + break; + case MarchState.Gathering: + action = $"собирает: {target}"; + timer = $"сбор ещё {Clock(m.gatherEndMs - now)}, вернётся через {Clock(ExpectedReturn(m) - now)}"; + break; + default: + action = (m.gather ? "возвращается со сбора: " : m.victory ? "возвращается с победой: " : "возвращается: ") + $"{target}"; + timer = $"дома через {Clock(m.returnMs - now)}"; + break; + } + + string cargo = m.state == MarchState.Outbound + ? (m.gather ? "груз: будет известен на точке" : "груз: нет") + : "груз: " + MarchManager.FormatLoot(m.state == MarchState.Gathering ? CargoAsLoot(m) : m.loot); + + return $"{action}\n" + + $"{soldiers} воинов: {UnitsText(units)}\n" + + $"{cargo}\n" + + $"{timer}"; + } + + // Когда войско будет дома: после боя -- обратный путь, после сбора -- сбор и обратный путь + long ExpectedReturn(MarchData m) + { + if (m.state == MarchState.Returning) return m.returnMs; + MapNode home = World.GetNode(m.fromNodeId); + MapNode target = World.GetNode(m.targetNodeId); + if (home == null || target == null) return m.returnMs; + if (m.state == MarchState.Gathering) return m.gatherEndMs + World.TravelTimeMs(target, home, m.units); + long gather = 0; + if (m.gather) + { + World.PreviewGather(m.targetNodeId, m.units, m.arriveMs, out _, out gather); + } + return m.arriveMs + gather + World.TravelTimeMs(target, home, m.units); // после боя -- если все живы + } + + List CargoAsLoot(MarchData m) + { + ResourcePoint p = World.GetPoint(m.targetNodeId); + var loot = new List(); + if (p != null && m.cargo > 0) loot.Add(new RewardEntry { resource = ResourcePointRules.Resources[p.kind], amount = m.cargo }); + return loot; + } + + static string UnitsText(int[] u) + { + if (u == null || u.Length < TroopType.Count) return "состав неизвестен"; + var parts = new List(); + if (u[TroopType.Infantry] > 0) parts.Add($"пех. {u[TroopType.Infantry]}"); + if (u[TroopType.Archers] > 0) parts.Add($"луч. {u[TroopType.Archers]}"); + if (u[TroopType.Cavalry] > 0) parts.Add($"кав. {u[TroopType.Cavalry]}"); + if (u[TroopType.Siege] > 0) parts.Add($"осад. {u[TroopType.Siege]}"); + return parts.Count > 0 ? string.Join(", ", parts) : "никого"; + } + + // Реальное время: «1 ч 05 мин», меньше часа -- «12:30» + static string Clock(long ms) + { + long sec = System.Math.Max(0, (ms + 999) / 1000); + if (sec >= 3600) return $"{sec / 3600} ч {sec / 60 % 60:00} мин"; + return $"{sec / 60}:{sec % 60:00}"; + } + + static Color StateColor(MarchData m) + { + switch (m.state) + { + case MarchState.Outbound: return m.gather ? new Color(0.25f, 0.55f, 0.3f) : new Color(0.7f, 0.25f, 0.2f); + case MarchState.Gathering: return new Color(0.75f, 0.6f, 0.15f); + default: return new Color(0.25f, 0.45f, 0.75f); + } + } + + static string StateLetter(MarchData m) + { + switch (m.state) + { + case MarchState.Outbound: return m.gather ? "С" : "Б"; // на сбор / в бой + case MarchState.Gathering: return "Д"; // добыча + default: return "В"; // возвращается + } + } + } +} diff --git a/Assets/Scripts/WorldMap/ArmiesWindow.cs.meta b/Assets/Scripts/WorldMap/ArmiesWindow.cs.meta new file mode 100644 index 0000000..c9c06aa --- /dev/null +++ b/Assets/Scripts/WorldMap/ArmiesWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aae71fe15d5b42cabbc00038dab0e9fe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index 9e646dd..858354b 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -95,6 +95,7 @@ public class WorldMapController : MonoBehaviour backButton.onClick.AddListener(SceneNavigator.ReturnToCity); if (myCityButton != null) myCityButton.onClick.AddListener(CenterOnMyCity); if (ratingButton != null) ratingButton.onClick.AddListener(ratingPanel.Open); + CreateArmiesButton(); HookUpComingSoon(worldMapNavButton, "Карта мира"); HookUpComingSoon(eventsNavButton, "События"); if (circleNavButton != null) circleNavButton.onClick.AddListener(CircleWindow.Open); // модуль Круга @@ -143,6 +144,22 @@ public class WorldMapController : MonoBehaviour soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {slots})"; } + // Кнопка «Армии» -- копия кнопки «Рейтинг» прямо под ней: тот же вид, без правки сцены + void CreateArmiesButton() + { + if (ratingButton == null) return; + var copy = Instantiate(ratingButton.gameObject, ratingButton.transform.parent); + copy.name = "ArmiesButton"; + var rt = (RectTransform)copy.transform; + var src = (RectTransform)ratingButton.transform; + rt.anchoredPosition = src.anchoredPosition - new Vector2(0, src.rect.height + 12); + var label = copy.GetComponentInChildren(); + if (label != null) label.text = "Армии"; + var button = copy.GetComponent