using System.Collections.Generic; using Bogatyri.World; using TMPro; using UnityEngine; using UnityEngine.UI; // Сцена карты мира: рисует замок и города, открывает окно атаки, показывает войска в походе // и итоги боёв. Правил тут нет -- они в WorldSimulation, походы ведёт MarchManager. public class WorldMapController : MonoBehaviour { [Header("Камера")] public MapCameraController mapCamera; [Header("Верхняя панель")] public TMP_Text soldiersText; public Button backButton; public Button myCityButton; [Header("Подсказка")] public TMP_Text hintText; [Header("Рейтинг")] public Button ratingButton; public RatingPanel ratingPanel; [Header("Нижняя панель (пока без функционала)")] public Button worldMapNavButton; public Button eventsNavButton; public Button circleNavButton; public Button settlementNavButton; [Header("Окно атаки")] public GameObject attackPanel; public TMP_Text attackTitleText; public TMP_Text attackInfoText; public Slider soldiersSlider; public TMP_Text soldiersToSendText; public Button sendButton; public Button cancelButton; [Header("Уведомление (итог боя / возвращение)")] public GameObject popupPanel; public TMP_Text popupTitleText; public TMP_Text popupBodyText; public Button popupOkButton; const string DefaultHint = "Нажми на замок, соседний город или лагерь разбойников, чтобы напасть. Карту можно двигать и приближать"; const float CastleSize = 2.2f; const float CitySize = 1.1f; const float CampSize = 0.8f; const float StartZoom = 8f; static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f); static readonly Color CastleColor = new Color(0.55f, 0.15f, 0.15f); static readonly Color PlayerCityColor = new Color(0.25f, 0.5f, 0.95f); static readonly Color NeighborCityColor = new Color(0.75f, 0.55f, 0.3f); static readonly Color CampColor = new Color(0.45f, 0.2f, 0.45f); static readonly Color CampClearedColor = new Color(0.35f, 0.35f, 0.35f, 0.6f); private Transform mapRoot; private readonly Dictionary marchViews = new Dictionary(); private readonly Queue<(string title, string body)> popupQueue = new Queue<(string, string)>(); private readonly List staleMarchIds = new List(); private MapNode selectedTarget; // Лагеря меняют вид: разбит -- серый, с таймером до появления private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>(); private float nextCampRefresh; WorldSimulation World => MarchManager.Instance.World; void Start() { mapRoot = new GameObject("Map").transform; BuildMap(); SetUpCamera(); backButton.onClick.AddListener(SceneNavigator.ReturnToCity); if (myCityButton != null) myCityButton.onClick.AddListener(CenterOnMyCity); if (ratingButton != null) ratingButton.onClick.AddListener(ratingPanel.Open); HookUpComingSoon(worldMapNavButton, "Карта мира"); HookUpComingSoon(eventsNavButton, "События"); if (circleNavButton != null) circleNavButton.onClick.AddListener(CircleWindow.Open); // модуль Круга HookUpComingSoon(settlementNavButton, "Поселение"); sendButton.onClick.AddListener(OnSendPressed); cancelButton.onClick.AddListener(CloseAttackPanel); popupOkButton.onClick.AddListener(ShowNextPopup); soldiersSlider.onValueChanged.AddListener(_ => UpdateSoldiersToSendText()); attackPanel.SetActive(false); popupPanel.SetActive(false); if (ratingPanel != null) ratingPanel.Close(); hintText.text = DefaultHint; if (ResourceManager.Instance == null) hintText.text = "Запусти игру из сцены города (MVP1), иначе войско неоткуда брать"; } void OnEnable() { if (MarchManager.Instance == null) return; MarchManager.Instance.OnBattleResolved += HandleBattleResolved; MarchManager.Instance.OnMarchReturned += HandleMarchReturned; } void OnDisable() { if (MarchManager.Instance == null) return; MarchManager.Instance.OnBattleResolved -= HandleBattleResolved; MarchManager.Instance.OnMarchReturned -= HandleMarchReturned; } void Update() { HandleMapTap(); SyncMarchViews(); RefreshCamps(); // Воины, а не отряды: "В походе: 1" читалось как один воин int inMarches = 0, garrison = 0; if (GameServices.TryGet(out IArmy army)) { inMarches = army.TroopsInMarches; garrison = army.GarrisonTotal; } soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {World.Marches.Count})"; } void HookUpComingSoon(Button button, string sectionName) { if (button == null) return; button.onClick.AddListener(() => hintText.text = $"Раздел «{sectionName}» пока в разработке"); } // ---------------- КАРТА ---------------- void BuildMap() { float width = WorldMapLayout.HalfWidth / 1000f * 2f; float height = WorldMapLayout.HalfHeight / 1000f * 2f; CreateSprite("Ground", PrimitiveSprites.Square, GroundColor, Vector3.zero, new Vector3(width, height, 1f), -10); foreach (var node in World.Nodes) { Vector3 pos = ToWorld(node); switch (node.type) { case MapNodeType.BossCastle: CreateSprite(node.id, PrimitiveSprites.Square, CastleColor, pos, Vector3.one * CastleSize, 0); CreateSprite(node.id + "_Keep", PrimitiveSprites.Square, new Color(0.3f, 0.08f, 0.08f), pos, Vector3.one * CastleSize * 0.5f, 1); CreateLabel(node.displayName, pos + new Vector3(0, CastleSize / 2f + 0.4f, 0), 4f); CreateLabel($"Гарнизон: {node.garrison}", pos - new Vector3(0, CastleSize / 2f + 0.35f, 0), 3f); break; case MapNodeType.PlayerCity: // Белая рамка -- чтобы свой город сразу бросался в глаза CreateSprite(node.id + "_Frame", PrimitiveSprites.Square, Color.white, pos, Vector3.one * (CitySize + 0.25f), 0); CreateSprite(node.id, PrimitiveSprites.Square, PlayerCityColor, pos, Vector3.one * CitySize, 1); CreateLabel(node.displayName, pos - new Vector3(0, CitySize / 2f + 0.35f, 0), 3.2f); break; case MapNodeType.NeighborCity: CreateSprite(node.id, PrimitiveSprites.Square, NeighborCityColor, pos, Vector3.one * CitySize, 0); CreateLabel($"{node.displayName}\nГарнизон: {node.garrison}", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f); break; case MapNodeType.Camp: // Круг, а не квадрат: лагерь не путается с городом var sprite = CreateSprite(node.id, PrimitiveSprites.Circle, CampColor, pos, Vector3.one * CampSize, 0); var label = CreateLabel("", pos - new Vector3(0, CampSize / 2f + 0.5f, 0), 2.3f); campViews.Add((node, sprite, label)); break; } } } void SetUpCamera() { // Сцена собрана старой версией сборщика -- добавляем контроллер камеры сами if (mapCamera == null) { Camera cam = Camera.main; mapCamera = cam.GetComponent(); if (mapCamera == null) mapCamera = cam.gameObject.AddComponent(); } mapCamera.mapHalfSize = new Vector2(WorldMapLayout.HalfWidth / 1000f, WorldMapLayout.HalfHeight / 1000f); mapCamera.GetComponent().orthographicSize = StartZoom; CenterOnMyCity(); } void CenterOnMyCity() { MapNode home = World.GetNode(WorldMapLayout.PlayerCityId); if (home != null) mapCamera.CenterOn(ToWorld(home)); } SpriteRenderer CreateSprite(string name, Sprite sprite, Color color, Vector3 position, Vector3 scale, int order) { var go = new GameObject(name); go.transform.SetParent(mapRoot, false); go.transform.position = position; go.transform.localScale = scale; var sr = go.AddComponent(); sr.sprite = sprite; sr.color = color; sr.sortingOrder = order; return sr; } TextMeshPro CreateLabel(string text, Vector3 position, float fontSize) { var go = new GameObject("Label"); go.transform.SetParent(mapRoot, false); go.transform.position = position; var tmp = go.AddComponent(); tmp.text = text; tmp.fontSize = fontSize; tmp.alignment = TextAlignmentOptions.Center; tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f); tmp.sortingOrder = 10; return tmp; } void RefreshCamps() { if (Time.unscaledTime < nextCampRefresh) return; nextCampRefresh = Time.unscaledTime + 1f; long now = MarchManager.NowMs; foreach (var (node, sprite, label) in campViews) { long left = World.CampRespawnInMs(node.id, now); sprite.color = left > 0 ? CampClearedColor : CampColor; label.text = left > 0 ? $"{node.displayName}\nразбит, вернётся через {FormatMinutes(left)}" : $"{node.displayName}, ур. {node.level}\nГарнизон: {node.garrison}"; } } static string FormatMinutes(long ms) { long sec = (ms + 999) / 1000; return sec >= 60 ? $"{(sec + 59) / 60} мин" : $"{sec} сек"; } public static Vector3 ToWorld(MapNode node) { return new Vector3(node.x / 1000f, node.y / 1000f, 0f); } // ---------------- ВЫБОР ЦЕЛИ ---------------- void HandleMapTap() { if (attackPanel.activeSelf || popupPanel.activeSelf) return; if (ratingPanel != null && ratingPanel.gameObject.activeSelf) return; if (!mapCamera.TryGetTap(out Vector3 world)) return; MapNode node = FindNodeAt(world); if (node == null) return; if (node.type == MapNodeType.PlayerCity) hintText.text = "Это твой город. Нажми НАЗАД, чтобы вернуться в него"; else if (node.type == MapNodeType.Camp && World.IsCampCleared(node.id, MarchManager.NowMs)) hintText.text = $"{node.displayName} уже разбит. Появится снова через {FormatMinutes(World.CampRespawnInMs(node.id, MarchManager.NowMs))}"; else if (node.canBeAttacked) OpenAttackPanel(node); } MapNode FindNodeAt(Vector3 world) { foreach (var node in World.Nodes) { // Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize : CitySize; float halfSize = size / 2f + 0.2f; Vector3 pos = ToWorld(node); if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node; } return null; } // ---------------- ОКНО АТАКИ ---------------- void OpenAttackPanel(MapNode target) { selectedTarget = target; int available = GetSoldiersAtHome(); MapNode home = World.GetNode(WorldMapLayout.PlayerCityId); int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f); attackTitleText.text = target.type == MapNodeType.NeighborCity ? $"Город игрока {target.displayName}" : target.type == MapNodeType.Camp ? $"{target.displayName}, ур. {target.level}" : target.displayName; attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает attackInfoText.fontSizeMin = 18; attackInfoText.fontSizeMax = 32; attackInfoText.text = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})\n" + $"Путь в одну сторону: {travelSeconds} сек" + (WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") + $"Награда за победу: {MarchManager.FormatLoot(target.rewards)}"; soldiersSlider.minValue = available > 0 ? 1 : 0; soldiersSlider.maxValue = available; soldiersSlider.wholeNumbers = true; soldiersSlider.value = available; soldiersSlider.interactable = available > 0; sendButton.interactable = available > 0; UpdateSoldiersToSendText(); attackPanel.SetActive(true); } void UpdateSoldiersToSendText() { int available = GetSoldiersAtHome(); // Две строки: сколько отправить и ожидаемый исход -- в одну строку оценка не влезала и обрезалась soldiersToSendText.enableAutoSizing = true; soldiersToSendText.fontSizeMin = 18; soldiersToSendText.fontSizeMax = 32; soldiersToSendText.overflowMode = TMPro.TextOverflowModes.Truncate; var size = soldiersToSendText.rectTransform.sizeDelta; if (size.y < 76) soldiersToSendText.rectTransform.sizeDelta = new Vector2(size.x, 76); soldiersToSendText.text = available > 0 ? $"Отправить: {(int)soldiersSlider.value} из {available}\n{PreviewText((int)soldiersSlider.value)}" : GameServices.TryGet(out IArmy army) && army.GarrisonTotal > 0 ? "Свободных воинов нет: все в гарнизоне (окно «Войска» в городе)" : "Дома нет солдат"; } // Ожидаемый исход боя: тот же расчёт, что сделает модуль Боя, с составом, который заберёт Армия string PreviewText(int soldiers) { if (selectedTarget == null || soldiers <= 0) return ""; IArmy army = GameServices.Get(); var troops = new int[TroopType.Count]; for (int i = 0; i < TroopType.Count; i++) troops[i] = army.GetTroops(i) - army.GetGarrison(i); // гарнизон в поход не идёт int[] units = ArmyMath.TakeProportional(troops, soldiers); if (units == null) return ""; BattleOutcome o = GameServices.Get().Resolve(new BattleRequest { AttackerUnits = units, DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget), Defenders = selectedTarget.garrison, DefenderFortified = WorldMapLayout.IsFortified(selectedTarget) }); string result = o.Victory ? "скорее победа" : "скорее поражение"; return $"сила {o.AttackerPower / 100} против {o.DefenderPower / 100} -- {result}, потери ~{o.AttackerLosses}"; } static string UnitsText(int[] units) { return $"пех. {units[TroopType.Infantry]}, луч. {units[TroopType.Archers]}, " + $"кав. {units[TroopType.Cavalry]}, осад. {units[TroopType.Siege]}"; } void CloseAttackPanel() { attackPanel.SetActive(false); selectedTarget = null; } void OnSendPressed() { if (selectedTarget == null) return; int soldiers = (int)soldiersSlider.value; if (MarchManager.Instance.TryStartMarch(selectedTarget.id, soldiers, out string error)) { hintText.text = $"Войско выступило: {soldiers} солдат идут на {selectedTarget.displayName}"; } else { hintText.text = "Поход не начат: " + error; } CloseAttackPanel(); } static int GetSoldiersAtHome() { return ResourceManager.Instance != null ? GameServices.Get().AvailableForMarch // свободные воины всех типов: гарнизон охраняет город : 0; } // ---------------- ВОЙСКА НА КАРТЕ ---------------- void SyncMarchViews() { long now = MarchManager.NowMs; foreach (var march in World.Marches) { if (!marchViews.TryGetValue(march.id, out MarchView view)) { view = MarchView.Create(mapRoot, march); marchViews[march.id] = view; } view.Refresh(World, march, now); } // Убираем войска, которые уже вернулись staleMarchIds.Clear(); foreach (var id in marchViews.Keys) { bool stillActive = false; foreach (var march in World.Marches) { if (march.id == id) { stillActive = true; break; } } if (!stillActive) staleMarchIds.Add(id); } foreach (var id in staleMarchIds) { Destroy(marchViews[id].gameObject); marchViews.Remove(id); } } // ---------------- УВЕДОМЛЕНИЯ ---------------- void HandleBattleResolved(MarchData march) { MapNode target = World.GetNode(march.targetNodeId); if (march.targetEmpty) { EnqueuePopup("ЛАГЕРЬ ПУСТ", $"{target.displayName} уже разбит\nБоя не было, домой идут все {march.survivors} воинов"); return; } string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ"; string body = $"{target.displayName}\n" + $"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" + $"Твои потери: {march.soldiers - march.survivors}, потери врага: {march.defenderLosses}\n" + $"Домой идут: {march.survivors}\n"; body += march.victory ? $"Добыча (придёт с войском): {MarchManager.FormatLoot(march.loot)}" : "Добычи нет"; EnqueuePopup(title, body); } void HandleMarchReturned(MarchData march) { string body = $"В город вернулись {march.survivors} солдат\n" + $"Добыча: {MarchManager.FormatLoot(march.loot)}"; EnqueuePopup("ВОЙСКО ДОМА", body); } void EnqueuePopup(string title, string body) { popupQueue.Enqueue((title, body)); if (!popupPanel.activeSelf) ShowNextPopup(); } void ShowNextPopup() { if (popupQueue.Count == 0) { popupPanel.SetActive(false); return; } var (title, body) = popupQueue.Dequeue(); popupTitleText.text = title; popupTitleText.color = title == "ПОРАЖЕНИЕ" ? new Color(0.9f, 0.35f, 0.3f) : new Color(1f, 0.85f, 0.3f); popupBodyText.text = body; popupPanel.SetActive(true); } }