2026-10-12 23:54:03 +03:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-10-27 16:46:54 +01:00
|
|
|
|
const string DefaultHint = "Нажми на замок, соседний город или лагерь разбойников, чтобы напасть. Карту можно двигать и приближать";
|
2026-10-12 23:54:03 +03:00
|
|
|
|
const float CastleSize = 2.2f;
|
|
|
|
|
|
const float CitySize = 1.1f;
|
2026-10-27 16:46:54 +01:00
|
|
|
|
const float CampSize = 0.8f;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
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);
|
2026-10-27 16:46:54 +01:00
|
|
|
|
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);
|
2026-10-12 23:54:03 +03:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-11-01 13:01:21 +01:00
|
|
|
|
// Подписи замка и соседей: гарнизон виден только в зоне обзора (решения 31.10, п. 1)
|
|
|
|
|
|
private readonly List<(MapNode node, TextMeshPro label)> garrisonLabels = new List<(MapNode, TextMeshPro)>();
|
|
|
|
|
|
|
2026-10-27 16:46:54 +01:00
|
|
|
|
// Лагеря меняют вид: разбит -- серый, с таймером до появления
|
|
|
|
|
|
private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>();
|
2026-10-31 16:28:50 +01:00
|
|
|
|
|
|
|
|
|
|
// Ресурсные точки появляются и исчезают -- виды создаются и убираются по ходу игры
|
|
|
|
|
|
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 = { "дерева", "камня", "металла", "золота" };
|
2026-10-27 16:46:54 +01:00
|
|
|
|
private float nextCampRefresh;
|
|
|
|
|
|
|
2026-10-28 19:20:12 +01:00
|
|
|
|
// Окно атаки: постоянная часть текста и состояние войска, с которым оно показано
|
|
|
|
|
|
private string attackInfoBase;
|
|
|
|
|
|
private BattleCondition shownConditions;
|
2026-11-01 13:01:21 +01:00
|
|
|
|
private bool shownObserved; // цель была в зоне обзора, когда окно атаки открылось
|
2026-10-28 19:20:12 +01:00
|
|
|
|
|
2026-10-12 23:54:03 +03:00
|
|
|
|
WorldSimulation World => MarchManager.Instance.World;
|
|
|
|
|
|
|
|
|
|
|
|
void Start()
|
|
|
|
|
|
{
|
|
|
|
|
|
mapRoot = new GameObject("Map").transform;
|
|
|
|
|
|
BuildMap();
|
2026-10-31 17:05:17 +01:00
|
|
|
|
IntelligenceOverlay.Create(mapRoot, World); // туман и чужие армии (модуль Разведки)
|
2026-10-12 23:54:03 +03:00
|
|
|
|
SetUpCamera();
|
|
|
|
|
|
|
|
|
|
|
|
backButton.onClick.AddListener(SceneNavigator.ReturnToCity);
|
|
|
|
|
|
if (myCityButton != null) myCityButton.onClick.AddListener(CenterOnMyCity);
|
|
|
|
|
|
if (ratingButton != null) ratingButton.onClick.AddListener(ratingPanel.Open);
|
2026-10-31 16:38:41 +01:00
|
|
|
|
CreateArmiesButton();
|
2026-10-12 23:54:03 +03:00
|
|
|
|
HookUpComingSoon(worldMapNavButton, "Карта мира");
|
|
|
|
|
|
HookUpComingSoon(eventsNavButton, "События");
|
2026-10-19 19:07:45 +02:00
|
|
|
|
if (circleNavButton != null) circleNavButton.onClick.AddListener(CircleWindow.Open); // модуль Круга
|
2026-10-12 23:54:03 +03:00
|
|
|
|
HookUpComingSoon(settlementNavButton, "Поселение");
|
|
|
|
|
|
sendButton.onClick.AddListener(OnSendPressed);
|
2026-11-01 13:11:55 +01:00
|
|
|
|
CreateScoutButton();
|
2026-10-12 23:54:03 +03:00
|
|
|
|
cancelButton.onClick.AddListener(CloseAttackPanel);
|
|
|
|
|
|
popupOkButton.onClick.AddListener(ShowNextPopup);
|
2026-10-31 16:05:55 +01:00
|
|
|
|
soldiersSlider.onValueChanged.AddListener(_ => { UpdateSoldiersToSendText(); if (attackPanel.activeSelf) RefreshAttackConditions(); });
|
2026-10-12 23:54:03 +03:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
MarchManager.Instance.OnGatherStarted += HandleGatherStarted;
|
2026-11-01 13:11:55 +01:00
|
|
|
|
MarchManager.Instance.OnScoutArrived += HandleScoutArrived;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
MarchManager.Instance.OnMarchReturned += HandleMarchReturned;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void OnDisable()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (MarchManager.Instance == null) return;
|
|
|
|
|
|
MarchManager.Instance.OnBattleResolved -= HandleBattleResolved;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
MarchManager.Instance.OnGatherStarted -= HandleGatherStarted;
|
2026-11-01 13:11:55 +01:00
|
|
|
|
MarchManager.Instance.OnScoutArrived -= HandleScoutArrived;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
MarchManager.Instance.OnMarchReturned -= HandleMarchReturned;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void Update()
|
|
|
|
|
|
{
|
|
|
|
|
|
HandleMapTap();
|
|
|
|
|
|
SyncMarchViews();
|
2026-10-27 16:46:54 +01:00
|
|
|
|
RefreshCamps();
|
2026-10-28 19:20:12 +01:00
|
|
|
|
RefreshOpenAttackPanel();
|
2026-10-12 23:54:03 +03:00
|
|
|
|
|
2026-10-27 16:34:58 +01:00
|
|
|
|
// Воины, а не отряды: "В походе: 1" читалось как один воин
|
2026-10-27 16:54:49 +01:00
|
|
|
|
int inMarches = 0, garrison = 0;
|
|
|
|
|
|
if (GameServices.TryGet(out IArmy army)) { inMarches = army.TroopsInMarches; garrison = army.GarrisonTotal; }
|
2026-10-31 16:05:55 +01:00
|
|
|
|
string slots = army != null ? $"{army.ActiveMarches} из {army.MarchSlots}" : World.Marches.Count.ToString();
|
|
|
|
|
|
soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {slots})";
|
2026-10-12 23:54:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-31 16:38:41 +01:00
|
|
|
|
// Кнопка «Армии» -- копия кнопки «Рейтинг» прямо под ней: тот же вид, без правки сцены
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-01 13:11:55 +01:00
|
|
|
|
// Кнопка «Разведка» в окне атаки (решения 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 23:54:03 +03:00
|
|
|
|
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);
|
2026-11-01 13:01:21 +01:00
|
|
|
|
garrisonLabels.Add((node, CreateLabel("", pos - new Vector3(0, CastleSize / 2f + 0.35f, 0), 3f)));
|
2026-10-12 23:54:03 +03:00
|
|
|
|
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);
|
2026-11-01 13:01:21 +01:00
|
|
|
|
garrisonLabels.Add((node, CreateLabel("", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f)));
|
2026-10-12 23:54:03 +03:00
|
|
|
|
break;
|
2026-10-27 16:46:54 +01:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:46:54 +01:00
|
|
|
|
TextMeshPro CreateLabel(string text, Vector3 position, float fontSize)
|
2026-10-12 23:54:03 +03:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
2026-10-27 16:46:54 +01:00
|
|
|
|
return tmp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void RefreshCamps()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (Time.unscaledTime < nextCampRefresh) return;
|
|
|
|
|
|
nextCampRefresh = Time.unscaledTime + 1f;
|
|
|
|
|
|
|
|
|
|
|
|
long now = MarchManager.NowMs;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
RefreshPoints(now);
|
2026-11-01 13:01:21 +01:00
|
|
|
|
foreach (var (node, label) in garrisonLabels)
|
|
|
|
|
|
{
|
|
|
|
|
|
string garrison = GarrisonText(node);
|
|
|
|
|
|
label.text = node.type == MapNodeType.BossCastle ? garrison : $"{node.displayName}\n{garrison}";
|
|
|
|
|
|
}
|
2026-10-27 16:46:54 +01:00
|
|
|
|
foreach (var (node, sprite, label) in campViews)
|
|
|
|
|
|
{
|
|
|
|
|
|
long left = World.CampRespawnInMs(node.id, now);
|
|
|
|
|
|
sprite.color = left > 0 ? CampClearedColor : CampColor;
|
2026-11-01 13:01:21 +01:00
|
|
|
|
label.text = left > 0 // разбил его сам игрок -- это он знает
|
2026-10-27 16:46:54 +01:00
|
|
|
|
? $"{node.displayName}\n<color=#BBBBBB>разбит, вернётся через {FormatMinutes(left)}</color>"
|
2026-11-01 13:01:21 +01:00
|
|
|
|
: $"{node.displayName}, ур. {node.level}\n{GarrisonText(node)}";
|
2026-10-27 16:46:54 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-31 16:28:50 +01:00
|
|
|
|
// Ресурсные точки: ромб цвета ресурса, подпись -- запас и сколько осталось жить; занятая -- «идёт сбор»
|
|
|
|
|
|
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>"
|
2026-11-01 13:01:21 +01:00
|
|
|
|
: IsObserved(World.GetNode(p.id))
|
|
|
|
|
|
? $"{name}\n{p.stock} {ResourceGenitive[p.kind]}, ещё {Duration(System.Math.Max(0, p.expireMs - now))}"
|
|
|
|
|
|
: $"{name}\n{UnknownText}";
|
2026-10-31 16:28:50 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Исчезнувшие точки -- с карты
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-01 13:01:21 +01:00
|
|
|
|
// ---------------- ЗАКРЫТЫЕ СВЕДЕНИЯ (решения 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}";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:46:54 +01:00
|
|
|
|
static string FormatMinutes(long ms)
|
|
|
|
|
|
{
|
|
|
|
|
|
long sec = (ms + 999) / 1000;
|
|
|
|
|
|
return sec >= 60 ? $"{(sec + 59) / 60} мин" : $"{sec} сек";
|
2026-10-12 23:54:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-10-31 16:38:41 +01:00
|
|
|
|
if (ArmiesWindow.IsOpen) return;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
if (!mapCamera.TryGetTap(out Vector3 world)) return;
|
|
|
|
|
|
|
|
|
|
|
|
MapNode node = FindNodeAt(world);
|
|
|
|
|
|
if (node == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (node.type == MapNodeType.PlayerCity) hintText.text = "Это твой город. Нажми НАЗАД, чтобы вернуться в него";
|
2026-10-27 16:46:54 +01:00
|
|
|
|
else if (node.type == MapNodeType.Camp && World.IsCampCleared(node.id, MarchManager.NowMs))
|
|
|
|
|
|
hintText.text = $"{node.displayName} уже разбит. Появится снова через {FormatMinutes(World.CampRespawnInMs(node.id, MarchManager.NowMs))}";
|
2026-10-31 16:28:50 +01:00
|
|
|
|
else if (node.type == MapNodeType.ResourcePoint && World.GetPoint(node.id)?.occupiedBy != 0)
|
|
|
|
|
|
hintText.text = $"{node.displayName}: идёт сбор. Войско вернётся само, когда груз будет полон или точка истощится";
|
2026-10-12 23:54:03 +03:00
|
|
|
|
else if (node.canBeAttacked) OpenAttackPanel(node);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
MapNode FindNodeAt(Vector3 world)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var node in World.Nodes)
|
|
|
|
|
|
{
|
2026-10-31 16:28:50 +01:00
|
|
|
|
if (node.type == MapNodeType.ResourcePoint && World.GetPoint(node.id)?.active != true) continue; // исчезнувшая точка
|
2026-10-12 23:54:03 +03:00
|
|
|
|
// Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем
|
2026-10-31 16:28:50 +01:00
|
|
|
|
float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize
|
|
|
|
|
|
: node.type == MapNodeType.ResourcePoint ? PointSize : CitySize;
|
2026-10-27 16:46:54 +01:00
|
|
|
|
float halfSize = size / 2f + 0.2f;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
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();
|
|
|
|
|
|
|
2026-10-27 16:46:54 +01:00
|
|
|
|
attackTitleText.text = target.type == MapNodeType.NeighborCity ? $"Город игрока {target.displayName}"
|
|
|
|
|
|
: target.type == MapNodeType.Camp ? $"{target.displayName}, ур. {target.level}"
|
2026-10-12 23:54:03 +03:00
|
|
|
|
: target.displayName;
|
2026-10-27 16:34:58 +01:00
|
|
|
|
attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает
|
|
|
|
|
|
attackInfoText.fontSizeMin = 18;
|
|
|
|
|
|
attackInfoText.fontSizeMax = 32;
|
2026-11-01 13:01:21 +01:00
|
|
|
|
shownObserved = IsObserved(target);
|
2026-10-31 16:28:50 +01:00
|
|
|
|
attackInfoBase = target.type == MapNodeType.ResourcePoint ? PointInfo(target)
|
2026-11-01 13:01:21 +01:00
|
|
|
|
: (shownObserved
|
|
|
|
|
|
? $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})"
|
|
|
|
|
|
: "Гарнизон неизвестен: цель вне зоны обзора") +
|
2026-10-31 16:28:50 +01:00
|
|
|
|
(WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") +
|
|
|
|
|
|
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
|
2026-10-28 19:20:12 +01:00
|
|
|
|
RefreshAttackConditions();
|
2026-10-12 23:54:03 +03:00
|
|
|
|
|
|
|
|
|
|
soldiersSlider.minValue = available > 0 ? 1 : 0;
|
|
|
|
|
|
soldiersSlider.maxValue = available;
|
|
|
|
|
|
soldiersSlider.wholeNumbers = true;
|
|
|
|
|
|
soldiersSlider.value = available;
|
|
|
|
|
|
soldiersSlider.interactable = available > 0;
|
2026-10-31 16:05:55 +01:00
|
|
|
|
bool slotFree = !GameServices.TryGet(out IArmy slotsArmy) || slotsArmy.ActiveMarches < slotsArmy.MarchSlots;
|
|
|
|
|
|
sendButton.interactable = available > 0 && slotFree;
|
2026-11-01 13:11:55 +01:00
|
|
|
|
if (scoutButton != null) scoutButton.interactable = available > 0 && slotFree;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
UpdateSoldiersToSendText();
|
2026-10-31 16:05:55 +01:00
|
|
|
|
RefreshAttackConditions(); // путь -- по составу при выбранном числе воинов
|
2026-10-12 23:54:03 +03:00
|
|
|
|
|
|
|
|
|
|
attackPanel.SetActive(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 19:20:12 +01:00
|
|
|
|
// Голод может начаться или кончиться, пока окно атаки открыто: предупреждение и прогноз
|
|
|
|
|
|
// пересчитываются, как только состояние войска изменилось (проверка -- в Update)
|
|
|
|
|
|
void RefreshAttackConditions()
|
|
|
|
|
|
{
|
|
|
|
|
|
shownConditions = MarchManager.PlayerConditions();
|
2026-10-31 16:28:50 +01:00
|
|
|
|
bool point = selectedTarget != null && selectedTarget.type == MapNodeType.ResourcePoint;
|
|
|
|
|
|
attackInfoText.text = attackInfoBase + PathText() + (point ? "" : ConditionsWarning(shownConditions)); // голод -- про бой
|
2026-10-31 16:05:55 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Путь в одну сторону -- по скорости состава (регламент 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>"
|
|
|
|
|
|
: "";
|
2026-10-31 16:38:41 +01:00
|
|
|
|
long path = World.TravelTimeMs(home, selectedTarget, units);
|
|
|
|
|
|
// До отправки -- длительность всего задания (регламент 31.10). Для сбора она в прогнозе ниже
|
|
|
|
|
|
string total = selectedTarget.type == MapNodeType.ResourcePoint ? "" : $", туда и обратно ~{Duration(path * 2)}";
|
|
|
|
|
|
return $"\nПуть в одну сторону: {Duration(path)}{total}" + slots;
|
2026-10-31 16:05:55 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-10-28 19:20:12 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void RefreshOpenAttackPanel()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!attackPanel.activeSelf || selectedTarget == null) return;
|
2026-11-01 13:01:21 +01:00
|
|
|
|
if (IsObserved(selectedTarget) != shownObserved) { OpenAttackPanel(selectedTarget); return; } // цель вошла в обзор или вышла
|
2026-10-28 19:20:12 +01:00
|
|
|
|
if (MarchManager.PlayerConditions() == shownConditions) return;
|
|
|
|
|
|
RefreshAttackConditions();
|
|
|
|
|
|
UpdateSoldiersToSendText();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 23:54:03 +03:00
|
|
|
|
void UpdateSoldiersToSendText()
|
|
|
|
|
|
{
|
|
|
|
|
|
int available = GetSoldiersAtHome();
|
2026-10-27 16:34:58 +01:00
|
|
|
|
// Две строки: сколько отправить и ожидаемый исход -- в одну строку оценка не влезала и обрезалась
|
|
|
|
|
|
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);
|
2026-10-12 23:54:03 +03:00
|
|
|
|
soldiersToSendText.text = available > 0
|
2026-10-27 16:34:58 +01:00
|
|
|
|
? $"Отправить: {(int)soldiersSlider.value} из {available}\n{PreviewText((int)soldiersSlider.value)}"
|
2026-10-27 16:54:49 +01:00
|
|
|
|
: GameServices.TryGet(out IArmy army) && army.GarrisonTotal > 0
|
|
|
|
|
|
? "Свободных воинов нет: все в гарнизоне (окно «Войска» в городе)"
|
|
|
|
|
|
: "Дома нет солдат";
|
2026-10-12 23:54:03 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-31 16:28:50 +01:00
|
|
|
|
// Ресурсная точка: запас, срок, добыча и груз на воина (регламент 31.10)
|
|
|
|
|
|
string PointInfo(MapNode target)
|
|
|
|
|
|
{
|
|
|
|
|
|
ResourcePoint p = World.GetPoint(target.id);
|
|
|
|
|
|
if (p == null) return "Этой точки больше нет";
|
|
|
|
|
|
var r = World.PointRules;
|
2026-11-01 13:01:21 +01:00
|
|
|
|
if (!shownObserved)
|
|
|
|
|
|
return "Запас и срок неизвестны: точка вне зоны обзора\n" +
|
|
|
|
|
|
$"Воин добывает {r.MiningMilliPerGameHour[p.kind] / 1000f:0.#} в игровой час (5 мин), несёт {r.CargoPerSoldier[p.kind]}; осадные не несут";
|
2026-10-31 16:28:50 +01:00
|
|
|
|
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 "";
|
2026-11-01 13:01:21 +01:00
|
|
|
|
if (!shownObserved) return "сколько унесут -- неизвестно: точка вне обзора";
|
2026-10-31 16:28:50 +01:00
|
|
|
|
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)}";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:34:58 +01:00
|
|
|
|
// Ожидаемый исход боя: тот же расчёт, что сделает модуль Боя, с составом, который заберёт Армия
|
|
|
|
|
|
string PreviewText(int soldiers)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (selectedTarget == null || soldiers <= 0) return "";
|
2026-10-31 16:28:50 +01:00
|
|
|
|
if (selectedTarget.type == MapNodeType.ResourcePoint) return GatherPreview(soldiers);
|
2026-10-31 16:05:55 +01:00
|
|
|
|
int[] units = PreviewUnits(soldiers); // гарнизон в поход не идёт
|
2026-10-27 16:34:58 +01:00
|
|
|
|
if (units == null) return "";
|
2026-11-01 13:01:21 +01:00
|
|
|
|
if (!shownObserved) return "прогноз боя недоступен: гарнизон цели неизвестен";
|
2026-10-27 16:34:58 +01:00
|
|
|
|
|
2026-10-28 19:03:14 +01:00
|
|
|
|
// Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026)
|
|
|
|
|
|
BattleForecast f = GameServices.Get<IBattleSystem>().Forecast(new BattleRequest
|
2026-10-27 16:34:58 +01:00
|
|
|
|
{
|
|
|
|
|
|
AttackerUnits = units,
|
|
|
|
|
|
DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget),
|
|
|
|
|
|
Defenders = selectedTarget.garrison,
|
2026-10-28 19:16:25 +01:00
|
|
|
|
DefenderFortified = WorldMapLayout.IsFortified(selectedTarget),
|
|
|
|
|
|
AttackerConditions = MarchManager.PlayerConditions() // голод сейчас; в бою проверится ещё раз
|
2026-10-27 16:34:58 +01:00
|
|
|
|
});
|
2026-10-28 19:03:14 +01:00
|
|
|
|
string chance = f.Chance == BattleChance.Advantage ? "<color=#7CD67C>преимущество</color>"
|
|
|
|
|
|
: f.Chance == BattleChance.HighRisk ? "<color=#E06A5A>высокий риск поражения</color>"
|
|
|
|
|
|
: "<color=#E8C15A>исход неясен</color>";
|
2026-10-30 17:02:05 +01:00
|
|
|
|
// Решение №11: качественный риск и рассчитанный шанс победы
|
2026-10-28 19:03:14 +01:00
|
|
|
|
return $"сила {Range(f.AttackerPowerMin, f.AttackerPowerMax)} против {Range(f.DefenderPowerMin, f.DefenderPowerMax)} -- " +
|
2026-10-30 17:02:05 +01:00
|
|
|
|
$"{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 + "%";
|
2026-10-28 19:03:14 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Диапазон "120-131" (числа x100 -> обычные); одинаковые края -- одно число
|
|
|
|
|
|
static string Range(long min, long max)
|
|
|
|
|
|
{
|
|
|
|
|
|
long a = min / 100, b = max / 100;
|
|
|
|
|
|
return a == b ? a.ToString() : $"{a}-{b}";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 19:16:25 +01:00
|
|
|
|
// Предупреждение в окне атаки: голод ослабляет войско (план от 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>";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 19:03:14 +01:00
|
|
|
|
// Случайность стороны: +23 десятых процента -> "+2,3%"
|
|
|
|
|
|
static string Luck(int permille)
|
|
|
|
|
|
{
|
|
|
|
|
|
return (permille >= 0 ? "+" : "-") + (System.Math.Abs(permille) / 10f).ToString("0.#") + "%";
|
2026-10-27 16:34:58 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static string UnitsText(int[] units)
|
|
|
|
|
|
{
|
|
|
|
|
|
return $"пех. {units[TroopType.Infantry]}, луч. {units[TroopType.Archers]}, " +
|
|
|
|
|
|
$"кав. {units[TroopType.Cavalry]}, осад. {units[TroopType.Siege]}";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 23:54:03 +03:00
|
|
|
|
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
|
2026-10-27 16:54:49 +01:00
|
|
|
|
? GameServices.Get<IArmy>().AvailableForMarch // свободные воины всех типов: гарнизон охраняет город
|
2026-10-12 23:54:03 +03:00
|
|
|
|
: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------- ВОЙСКА НА КАРТЕ ----------------
|
|
|
|
|
|
|
|
|
|
|
|
void SyncMarchViews()
|
|
|
|
|
|
{
|
|
|
|
|
|
long now = MarchManager.NowMs;
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var march in World.Marches)
|
|
|
|
|
|
{
|
2026-10-31 17:05:17 +01:00
|
|
|
|
// Чужие армии (патрули соседей) рисует IntelligenceOverlay -- только те, что видит Разведка
|
|
|
|
|
|
if (march.ownerId != MarchManager.LocalPlayerId) continue;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
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);
|
2026-10-27 16:46:54 +01:00
|
|
|
|
if (march.targetEmpty)
|
|
|
|
|
|
{
|
|
|
|
|
|
EnqueuePopup("ЛАГЕРЬ ПУСТ", $"{target.displayName} уже разбит\nБоя не было, домой идут все {march.survivors} воинов");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-12 23:54:03 +03:00
|
|
|
|
string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ";
|
|
|
|
|
|
string body = $"{target.displayName}\n" +
|
|
|
|
|
|
$"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" +
|
2026-11-01 13:32:25 +01:00
|
|
|
|
$"Твои потери: {march.soldiers - march.survivors}{WoundedText(march)}, потери врага: {march.defenderLosses}\n" +
|
2026-10-28 19:03:14 +01:00
|
|
|
|
(march.attackerPower > 0 || march.defenderPower > 0
|
2026-10-28 19:10:27 +01:00
|
|
|
|
? $"Сила {march.attackerPower / 100} : {march.defenderPower / 100}, " +
|
2026-10-28 19:16:25 +01:00
|
|
|
|
$"удача {Luck(march.attackerLuck)} / {Luck(march.defenderLuck)}" +
|
|
|
|
|
|
((march.attackerConditions & (int)BattleCondition.Famine) != 0
|
|
|
|
|
|
? $", <color=#E06A5A>голод: сила {march.attackerConditionPercent}%</color>" : "") + "\n"
|
2026-10-28 19:03:14 +01:00
|
|
|
|
: "") +
|
2026-10-12 23:54:03 +03:00
|
|
|
|
$"Домой идут: {march.survivors}\n";
|
|
|
|
|
|
body += march.victory
|
|
|
|
|
|
? $"Добыча (придёт с войском): {MarchManager.FormatLoot(march.loot)}"
|
|
|
|
|
|
: "Добычи нет";
|
|
|
|
|
|
EnqueuePopup(title, body);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-01 13:11:55 +01:00
|
|
|
|
void HandleScoutArrived(MarchData march)
|
|
|
|
|
|
{
|
|
|
|
|
|
string target = World.TargetName(march);
|
|
|
|
|
|
hintText.text = march.targetEmpty
|
|
|
|
|
|
? $"Разведка: «{target}» уже нет, войско возвращается"
|
|
|
|
|
|
: $"Разведка дошла до цели «{target}» и возвращается домой";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-01 13:32:25 +01:00
|
|
|
|
// «(из них ранены 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} -- придут в госпиталь)" : "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-31 16:28:50 +01:00
|
|
|
|
void HandleGatherStarted(MarchData march)
|
|
|
|
|
|
{
|
2026-10-31 16:31:12 +01:00
|
|
|
|
string target = World.TargetName(march);
|
2026-10-31 16:28:50 +01:00
|
|
|
|
if (march.targetEmpty)
|
2026-10-31 16:31:12 +01:00
|
|
|
|
EnqueuePopup("ТОЧКИ НЕТ", $"{target} исчезла или занята\nСбора не было, домой идут все {march.soldiers} воинов");
|
2026-10-31 16:28:50 +01:00
|
|
|
|
else
|
2026-10-31 16:31:12 +01:00
|
|
|
|
hintText.text = $"Войско начало сбор: {target}, унесёт {march.cargo}";
|
2026-10-31 16:28:50 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 23:54:03 +03:00
|
|
|
|
void HandleMarchReturned(MarchData march)
|
|
|
|
|
|
{
|
2026-11-01 13:19:34 +01:00
|
|
|
|
if (march.scout)
|
|
|
|
|
|
{
|
|
|
|
|
|
EnqueuePopup("РАЗВЕДКА ВЕРНУЛАСЬ", $"{World.TargetName(march)}: " +
|
|
|
|
|
|
(march.targetEmpty ? "цели уже не было" : "цель разведана") +
|
|
|
|
|
|
$"\nВ город вернулись все {march.survivors} воинов, боя не было");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-12 23:54:03 +03:00
|
|
|
|
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);
|
2026-10-28 19:10:27 +01:00
|
|
|
|
// Итог боя длинный (строка силы и удачи, добыча) -- текст ужимается, а не уходит под кнопку OK
|
|
|
|
|
|
popupBodyText.enableAutoSizing = true;
|
|
|
|
|
|
popupBodyText.fontSizeMin = 16;
|
|
|
|
|
|
popupBodyText.fontSizeMax = 30;
|
|
|
|
|
|
popupBodyText.overflowMode = TMPro.TextOverflowModes.Truncate;
|
2026-10-12 23:54:03 +03:00
|
|
|
|
popupBodyText.text = body;
|
|
|
|
|
|
popupPanel.SetActive(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|