Files
MVP1/Assets/Scripts/WorldMap/WorldMapController.cs
T
AlexanderandClaude Opus 5.5 0214bdb268 Раненые и госпиталь (решения 31.10, п. 7): логика
Бой делит выбывших на погибших и раненых по соотношению сил стороны (70/45/30/20%), версия правил 6.
Раненые идут домой с войском; Армия кладёт их в госпиталь, лечит 4/4/6/8 игровых часов, лишние ждут места.
Места -- от IHospital (здания пока нет -- HospitalConfig.temporaryBeds), цена -- через Экономику (не утверждена).
Население считает раненых людьми. Здание госпиталя -- следующий шаг.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:32:25 +01:00

776 lines
38 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<int, MarchView> marchViews = new Dictionary<int, MarchView>();
private readonly Queue<(string title, string body)> popupQueue = new Queue<(string, string)>();
private readonly List<int> staleMarchIds = new List<int>();
private MapNode selectedTarget;
// Подписи замка и соседей: гарнизон виден только в зоне обзора (решения 31.10, п. 1)
private readonly List<(MapNode node, TextMeshPro label)> garrisonLabels = new List<(MapNode, TextMeshPro)>();
// Лагеря меняют вид: разбит -- серый, с таймером до появления
private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>();
// Ресурсные точки появляются и исчезают -- виды создаются и убираются по ходу игры
private readonly Dictionary<string, (SpriteRenderer sprite, TextMeshPro label)> pointViews = new Dictionary<string, (SpriteRenderer, TextMeshPro)>();
private readonly List<string> stalePoints = new List<string>();
const float PointSize = 0.7f;
static readonly Color[] PointColors =
{
new Color(0.2f, 0.55f, 0.2f), // дерево
new Color(0.6f, 0.6f, 0.62f), // камень
new Color(0.35f, 0.45f, 0.6f), // металл
new Color(0.95f, 0.78f, 0.2f) // золото
};
static readonly string[] ResourceGenitive = { "дерева", "камня", "металла", "золота" };
private float nextCampRefresh;
// Окно атаки: постоянная часть текста и состояние войска, с которым оно показано
private string attackInfoBase;
private BattleCondition shownConditions;
private bool shownObserved; // цель была в зоне обзора, когда окно атаки открылось
WorldSimulation World => MarchManager.Instance.World;
void Start()
{
mapRoot = new GameObject("Map").transform;
BuildMap();
IntelligenceOverlay.Create(mapRoot, World); // туман и чужие армии (модуль Разведки)
SetUpCamera();
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); // модуль Круга
HookUpComingSoon(settlementNavButton, "Поселение");
sendButton.onClick.AddListener(OnSendPressed);
CreateScoutButton();
cancelButton.onClick.AddListener(CloseAttackPanel);
popupOkButton.onClick.AddListener(ShowNextPopup);
soldiersSlider.onValueChanged.AddListener(_ => { UpdateSoldiersToSendText(); if (attackPanel.activeSelf) RefreshAttackConditions(); });
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.OnGatherStarted += HandleGatherStarted;
MarchManager.Instance.OnScoutArrived += HandleScoutArrived;
MarchManager.Instance.OnMarchReturned += HandleMarchReturned;
}
void OnDisable()
{
if (MarchManager.Instance == null) return;
MarchManager.Instance.OnBattleResolved -= HandleBattleResolved;
MarchManager.Instance.OnGatherStarted -= HandleGatherStarted;
MarchManager.Instance.OnScoutArrived -= HandleScoutArrived;
MarchManager.Instance.OnMarchReturned -= HandleMarchReturned;
}
void Update()
{
HandleMapTap();
SyncMarchViews();
RefreshCamps();
RefreshOpenAttackPanel();
// Воины, а не отряды: "В походе: 1" читалось как один воин
int inMarches = 0, garrison = 0;
if (GameServices.TryGet(out IArmy army)) { inMarches = army.TroopsInMarches; garrison = army.GarrisonTotal; }
string slots = army != null ? $"{army.ActiveMarches} из {army.MarchSlots}" : World.Marches.Count.ToString();
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<TMP_Text>();
if (label != null) label.text = "Армии";
var button = copy.GetComponent<Button>();
button.onClick.RemoveAllListeners();
button.onClick.AddListener(ArmiesWindow.Open);
}
// Кнопка «Разведка» в окне атаки (решения 31.10, п. 2) -- копия «Отправить». Три кнопки в ряд: Отмена, Разведка, Отправить
private Button scoutButton;
void CreateScoutButton()
{
var copy = Instantiate(sendButton.gameObject, sendButton.transform.parent);
copy.name = "ScoutButton";
scoutButton = copy.GetComponent<Button>();
scoutButton.onClick.RemoveAllListeners();
scoutButton.onClick.AddListener(OnScoutPressed);
var label = copy.GetComponentInChildren<TMP_Text>();
if (label != null) label.text = "Разведка";
Place(cancelButton, -290f);
Place(scoutButton, 0f);
Place(sendButton, 290f);
}
static void Place(Button button, float x)
{
var rt = (RectTransform)button.transform;
rt.anchoredPosition = new Vector2(x, rt.anchoredPosition.y);
rt.sizeDelta = new Vector2(Mathf.Min(rt.sizeDelta.x, 260f), rt.sizeDelta.y);
}
void OnScoutPressed()
{
if (selectedTarget == null) return;
int soldiers = (int)soldiersSlider.value;
hintText.text = MarchManager.Instance.TryStartScout(selectedTarget.id, soldiers, out string error)
? $"Разведка выступила: {soldiers} воинов идут к цели «{selectedTarget.displayName}» и вернутся без боя"
: "Разведка не начата: " + error;
CloseAttackPanel();
}
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);
garrisonLabels.Add((node, CreateLabel("", 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);
garrisonLabels.Add((node, CreateLabel("", 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<MapCameraController>();
if (mapCamera == null) mapCamera = cam.gameObject.AddComponent<MapCameraController>();
}
mapCamera.mapHalfSize = new Vector2(WorldMapLayout.HalfWidth / 1000f, WorldMapLayout.HalfHeight / 1000f);
mapCamera.GetComponent<Camera>().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<SpriteRenderer>();
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<TextMeshPro>();
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;
RefreshPoints(now);
foreach (var (node, label) in garrisonLabels)
{
string garrison = GarrisonText(node);
label.text = node.type == MapNodeType.BossCastle ? garrison : $"{node.displayName}\n{garrison}";
}
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<color=#BBBBBB>разбит, вернётся через {FormatMinutes(left)}</color>"
: $"{node.displayName}, ур. {node.level}\n{GarrisonText(node)}";
}
}
// Ресурсные точки: ромб цвета ресурса, подпись -- запас и сколько осталось жить; занятая -- «идёт сбор»
void RefreshPoints(long now)
{
foreach (var p in World.ResourcePoints)
{
if (!p.active) continue;
if (!pointViews.TryGetValue(p.id, out var view))
{
MapNode node = World.GetNode(p.id);
Vector3 pos = ToWorld(node);
var sprite = CreateSprite(p.id, PrimitiveSprites.Square, PointColors[p.kind], pos, Vector3.one * PointSize, 0);
sprite.transform.rotation = Quaternion.Euler(0, 0, 45); // ромб -- не путается с городом и лагерем
var label = CreateLabel("", pos - new Vector3(0, PointSize / 2f + 0.5f, 0), 2.2f);
view = (sprite, label);
pointViews[p.id] = view;
}
string name = World.GetNode(p.id).displayName;
view.label.text = p.occupiedBy != 0
? $"{name}\n<color=#9BE09B>идёт сбор</color>"
: IsObserved(World.GetNode(p.id))
? $"{name}\n{p.stock} {ResourceGenitive[p.kind]}, ещё {Duration(System.Math.Max(0, p.expireMs - now))}"
: $"{name}\n{UnknownText}";
}
// Исчезнувшие точки -- с карты
stalePoints.Clear();
foreach (var id in pointViews.Keys)
{
ResourcePoint p = World.GetPoint(id);
if (p == null || !p.active) stalePoints.Add(id);
}
foreach (var id in stalePoints)
{
Destroy(pointViews[id].sprite.gameObject);
Destroy(pointViews[id].label.gameObject);
pointViews.Remove(id);
}
}
// ---------------- ЗАКРЫТЫЕ СВЕДЕНИЯ (решения 31.10, п. 1) ----------------
// Объекты видны всегда как ориентиры, а их гарнизон, запас и срок -- только пока объект в зоне обзора
// (модуль Разведки). Без Разведки (старые сцены, тесты) -- всё видно, как раньше.
const string UnknownText = "<color=#9A9A9A>неизвестно: вне обзора</color>";
static bool IsObserved(MapNode node)
{
return node == null || !GameServices.TryGet(out IIntelligence intel)
|| intel.GetVisibility(node.x, node.y) == MapVisibility.Observed;
}
static string GarrisonText(MapNode node)
{
return IsObserved(node) ? $"Гарнизон: {node.garrison}" : $"Гарнизон: {UnknownText}";
}
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 (ArmiesWindow.IsOpen) 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.type == MapNodeType.ResourcePoint && World.GetPoint(node.id)?.occupiedBy != 0)
hintText.text = $"{node.displayName}: идёт сбор. Войско вернётся само, когда груз будет полон или точка истощится";
else if (node.canBeAttacked) OpenAttackPanel(node);
}
MapNode FindNodeAt(Vector3 world)
{
foreach (var node in World.Nodes)
{
if (node.type == MapNodeType.ResourcePoint && World.GetPoint(node.id)?.active != true) continue; // исчезнувшая точка
// Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем
float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize
: node.type == MapNodeType.ResourcePoint ? PointSize : 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();
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;
shownObserved = IsObserved(target);
attackInfoBase = target.type == MapNodeType.ResourcePoint ? PointInfo(target)
: (shownObserved
? $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})"
: "Гарнизон неизвестен: цель вне зоны обзора") +
(WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") +
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
RefreshAttackConditions();
soldiersSlider.minValue = available > 0 ? 1 : 0;
soldiersSlider.maxValue = available;
soldiersSlider.wholeNumbers = true;
soldiersSlider.value = available;
soldiersSlider.interactable = available > 0;
bool slotFree = !GameServices.TryGet(out IArmy slotsArmy) || slotsArmy.ActiveMarches < slotsArmy.MarchSlots;
sendButton.interactable = available > 0 && slotFree;
if (scoutButton != null) scoutButton.interactable = available > 0 && slotFree;
UpdateSoldiersToSendText();
RefreshAttackConditions(); // путь -- по составу при выбранном числе воинов
attackPanel.SetActive(true);
}
// Голод может начаться или кончиться, пока окно атаки открыто: предупреждение и прогноз
// пересчитываются, как только состояние войска изменилось (проверка -- в Update)
void RefreshAttackConditions()
{
shownConditions = MarchManager.PlayerConditions();
bool point = selectedTarget != null && selectedTarget.type == MapNodeType.ResourcePoint;
attackInfoText.text = attackInfoBase + PathText() + (point ? "" : ConditionsWarning(shownConditions)); // голод -- про бой
}
// Путь в одну сторону -- по скорости состава (регламент 31.10), реальное время; занятые слоты похода
string PathText()
{
if (selectedTarget == null) return "";
int[] units = PreviewUnits((int)soldiersSlider.value);
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
string slots = GameServices.TryGet(out IArmy army) && army.ActiveMarches >= army.MarchSlots
? $"\n<color=#E06A5A>Все слоты похода заняты ({army.ActiveMarches} из {army.MarchSlots}) -- дождитесь возвращения войска</color>"
: "";
long path = World.TravelTimeMs(home, selectedTarget, units);
// До отправки -- длительность всего задания (регламент 31.10). Для сбора она в прогнозе ниже
string total = selectedTarget.type == MapNodeType.ResourcePoint ? "" : $", туда и обратно ~{Duration(path * 2)}";
return $"\nПуть в одну сторону: {Duration(path)}{total}" + slots;
}
// 3 900 000 мс -> "1 ч 5 мин"; меньше минуты -- в секундах
static string Duration(long ms)
{
long sec = (ms + 999) / 1000;
if (sec < 60) return $"{sec} сек";
long min = (sec + 59) / 60;
return min < 60 ? $"{min} мин" : min % 60 == 0 ? $"{min / 60} ч" : $"{min / 60} ч {min % 60} мин";
}
// Кого Армия заберёт в поход: свободных воинов по составу (гарнизон остаётся)
int[] PreviewUnits(int soldiers)
{
if (soldiers <= 0 || !GameServices.TryGet(out IArmy army)) return null;
var troops = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++) troops[i] = army.GetTroops(i) - army.GetGarrison(i);
return ArmyMath.TakeProportional(troops, soldiers);
}
void RefreshOpenAttackPanel()
{
if (!attackPanel.activeSelf || selectedTarget == null) return;
if (IsObserved(selectedTarget) != shownObserved) { OpenAttackPanel(selectedTarget); return; } // цель вошла в обзор или вышла
if (MarchManager.PlayerConditions() == shownConditions) return;
RefreshAttackConditions();
UpdateSoldiersToSendText();
}
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
? "Свободных воинов нет: все в гарнизоне (окно «Войска» в городе)"
: "Дома нет солдат";
}
// Ресурсная точка: запас, срок, добыча и груз на воина (регламент 31.10)
string PointInfo(MapNode target)
{
ResourcePoint p = World.GetPoint(target.id);
if (p == null) return "Этой точки больше нет";
var r = World.PointRules;
if (!shownObserved)
return "Запас и срок неизвестны: точка вне зоны обзора\n" +
$"Воин добывает {r.MiningMilliPerGameHour[p.kind] / 1000f:0.#} в игровой час (5 мин), несёт {r.CargoPerSoldier[p.kind]}; осадные не несут";
return $"Запас: {p.stock} {ResourceGenitive[p.kind]}, точка исчезнет через {Duration(System.Math.Max(0, p.expireMs - MarchManager.NowMs))}\n" +
$"Воин добывает {r.MiningMilliPerGameHour[p.kind] / 1000f:0.#} в игровой час (5 мин), несёт {r.CargoPerSoldier[p.kind]}; осадные не несут";
}
// Прогноз сбора: сколько унесут и сколько займёт весь поход
string GatherPreview(int soldiers)
{
int[] units = PreviewUnits(soldiers);
ResourcePoint p = World.GetPoint(selectedTarget.id);
if (units == null || p == null) return "";
if (!shownObserved) return "сколько унесут -- неизвестно: точка вне обзора";
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
long path = World.TravelTimeMs(home, selectedTarget, units);
World.PreviewGather(p.id, units, MarchManager.NowMs + path, out int amount, out long gatherMs);
if (amount <= 0) return "<color=#E06A5A>унесут 0 -- точка исчезнет раньше или некому нести</color>";
return $"унесут {amount} {ResourceGenitive[p.kind]}, сбор {Duration(gatherMs)}, весь поход ~{Duration(path * 2 + gatherMs)}";
}
// Ожидаемый исход боя: тот же расчёт, что сделает модуль Боя, с составом, который заберёт Армия
string PreviewText(int soldiers)
{
if (selectedTarget == null || soldiers <= 0) return "";
if (selectedTarget.type == MapNodeType.ResourcePoint) return GatherPreview(soldiers);
int[] units = PreviewUnits(soldiers); // гарнизон в поход не идёт
if (units == null) return "";
if (!shownObserved) return "прогноз боя недоступен: гарнизон цели неизвестен";
// Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026)
BattleForecast f = GameServices.Get<IBattleSystem>().Forecast(new BattleRequest
{
AttackerUnits = units,
DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget),
Defenders = selectedTarget.garrison,
DefenderFortified = WorldMapLayout.IsFortified(selectedTarget),
AttackerConditions = MarchManager.PlayerConditions() // голод сейчас; в бою проверится ещё раз
});
string chance = f.Chance == BattleChance.Advantage ? "<color=#7CD67C>преимущество</color>"
: f.Chance == BattleChance.HighRisk ? "<color=#E06A5A>высокий риск поражения</color>"
: "<color=#E8C15A>исход неясен</color>";
// Решение №11: качественный риск и рассчитанный шанс победы
return $"сила {Range(f.AttackerPowerMin, f.AttackerPowerMax)} против {Range(f.DefenderPowerMin, f.DefenderPowerMax)} -- " +
$"{chance}, шанс победы {ChanceText(f.WinChancePermille)}, потери {Range(f.AttackerLossesMin * 100L, f.AttackerLossesMax * 100L)}";
}
// 273 десятых процента -> "27%"; около краёв -- с десятыми, чтобы 99,6% не выглядело как 100%
static string ChanceText(int permille)
{
if (permille > 0 && permille < 10) return "<1%";
if (permille > 990 && permille < 1000) return ">99%";
return (permille + 5) / 10 + "%";
}
// Диапазон "120-131" (числа x100 -> обычные); одинаковые края -- одно число
static string Range(long min, long max)
{
long a = min / 100, b = max / 100;
return a == b ? a.ToString() : $"{a}-{b}";
}
// Предупреждение в окне атаки: голод ослабляет войско (план от 27.10.2026: -50%, проверяется на момент боя)
static string ConditionsWarning(BattleCondition conditions)
{
if ((conditions & BattleCondition.Famine) == 0) return "";
int percent = GameServices.Get<IBattleSystem>() is BattleSystem b ? b.ConditionPercent(conditions) : 50;
return $"\n<color=#E06A5A>Голод: войско слабее, сила {percent}%. Накормите город до боя</color>";
}
// Случайность стороны: +23 десятых процента -> "+2,3%"
static string Luck(int permille)
{
return (permille >= 0 ? "+" : "-") + (System.Math.Abs(permille) / 10f).ToString("0.#") + "%";
}
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<IArmy>().AvailableForMarch // свободные воины всех типов: гарнизон охраняет город
: 0;
}
// ---------------- ВОЙСКА НА КАРТЕ ----------------
void SyncMarchViews()
{
long now = MarchManager.NowMs;
foreach (var march in World.Marches)
{
// Чужие армии (патрули соседей) рисует IntelligenceOverlay -- только те, что видит Разведка
if (march.ownerId != MarchManager.LocalPlayerId) continue;
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}{WoundedText(march)}, потери врага: {march.defenderLosses}\n" +
(march.attackerPower > 0 || march.defenderPower > 0
? $"Сила {march.attackerPower / 100} : {march.defenderPower / 100}, " +
$"удача {Luck(march.attackerLuck)} / {Luck(march.defenderLuck)}" +
((march.attackerConditions & (int)BattleCondition.Famine) != 0
? $", <color=#E06A5A>голод: сила {march.attackerConditionPercent}%</color>" : "") + "\n"
: "") +
$"Домой идут: {march.survivors}\n";
body += march.victory
? $"Добыча (придёт с войском): {MarchManager.FormatLoot(march.loot)}"
: "Добычи нет";
EnqueuePopup(title, body);
}
void HandleScoutArrived(MarchData march)
{
string target = World.TargetName(march);
hintText.text = march.targetEmpty
? $"Разведка: «{target}» уже нет, войско возвращается"
: $"Разведка дошла до цели «{target}» и возвращается домой";
}
// «(из них ранены 12 -- придут в госпиталь)»: раненые идут домой с войском
static string WoundedText(MarchData march)
{
int w = 0;
if (march.woundedUnits != null) foreach (var u in march.woundedUnits) w += u;
return w > 0 ? $" (ранены {w} -- придут в госпиталь)" : "";
}
void HandleGatherStarted(MarchData march)
{
string target = World.TargetName(march);
if (march.targetEmpty)
EnqueuePopup("ТОЧКИ НЕТ", $"{target} исчезла или занята\nСбора не было, домой идут все {march.soldiers} воинов");
else
hintText.text = $"Войско начало сбор: {target}, унесёт {march.cargo}";
}
void HandleMarchReturned(MarchData march)
{
if (march.scout)
{
EnqueuePopup("РАЗВЕДКА ВЕРНУЛАСЬ", $"{World.TargetName(march)}: " +
(march.targetEmpty ? "цели уже не было" : "цель разведана") +
$"\nВ город вернулись все {march.survivors} воинов, боя не было");
return;
}
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);
// Итог боя длинный (строка силы и удачи, добыча) -- текст ужимается, а не уходит под кнопку OK
popupBodyText.enableAutoSizing = true;
popupBodyText.fontSizeMin = 16;
popupBodyText.fontSizeMax = 30;
popupBodyText.overflowMode = TMPro.TextOverflowModes.Truncate;
popupBodyText.text = body;
popupPanel.SetActive(true);
}
}