Files
MVP1/Assets/Scripts/WorldMap/WorldMapController.cs
T
AlexanderandClaude Opus 5.5 61ba46947c Разведка М2: целенаправленная разведка поселений, замка и лагерей с отчётами II и III
Отряд идёт маршем, наблюдает 2/4 (поселение, лагерь) или 3/6 (замок) игровых часов, отчёт на конец наблюдения,
потом домой. Уровень II -- точные войска, герой, стены; III -- плюс защита и ресурсы ±15%. Один отчёт на операцию,
сохранение. Карта: кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту вне обзора.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:21:54 +01:00

921 lines
49 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 StartDistance = 18f; // камера 2.5D: расстояние до земли
// Высота объектов над землёй (2.5D на примитивах, 04.11.2026)
const float CastleHeight = 1.4f, CityHeight = 0.9f, CampHeight = 0.5f, PointHeight = 0.45f;
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, Renderer sprite, TextMeshPro label)> campViews = new List<(MapNode, Renderer, TextMeshPro)>();
// Ресурсные точки появляются и исчезают -- виды создаются и убираются по ходу игры
private readonly Dictionary<string, (Renderer sprite, TextMeshPro label)> pointViews = new Dictionary<string, (Renderer, 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; // цель была в зоне обзора, когда окно атаки открылось
private ReconReport? shownReport; // цель вне обзора, но есть отчёт разведки -- гарнизон и прогноз по нему (на момент отчёта)
WorldSimulation World => MarchManager.Instance.World;
void Start()
{
mapRoot = new GameObject("Map").transform;
Renderer ground = BuildMap();
IntelligenceOverlay.Create(mapRoot, World, ground); // туман на земле и чужие армии (модуль Разведки)
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.OnReconCompleted += HandleReconCompleted;
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.OnReconCompleted -= HandleReconCompleted;
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);
// «Отчёты» -- под «Армиями»: последние отчёты разведки (Разведка М2)
var reports = Instantiate(ratingButton.gameObject, ratingButton.transform.parent);
reports.name = "ReportsButton";
var rrt = (RectTransform)reports.transform;
rrt.anchoredPosition = src.anchoredPosition - new Vector2(0, (src.rect.height + 12) * 2);
var rl = reports.GetComponentInChildren<TMP_Text>();
if (rl != null) rl.text = "Отчёты";
var rb = reports.GetComponent<Button>();
rb.onClick.RemoveAllListeners();
rb.onClick.AddListener(ReconReportText.OpenWindow);
}
// Кнопки разведки в окне атаки -- копии «Отправить». Четыре в ряд: Отмена, Разведка II, Разведка III, Отправить.
// Разведка II / III (Разведка М2): наблюдать цель и принести отчёт; у ресурсной точки -- просто «Разведка» (дойти и вернуться)
private Button scoutButton, scout3Button;
private TMP_Text scoutLabel;
void CreateScoutButton()
{
scoutButton = CopyOfSend("ScoutButton", "Разведка II", () => OnScoutPressed(2));
scoutLabel = scoutButton.GetComponentInChildren<TMP_Text>();
scout3Button = CopyOfSend("Scout3Button", "Разведка III", () => OnScoutPressed(3));
Place(cancelButton, -330f);
Place(scoutButton, -110f);
Place(scout3Button, 110f);
Place(sendButton, 330f);
}
Button CopyOfSend(string name, string text, UnityEngine.Events.UnityAction onClick)
{
var copy = Instantiate(sendButton.gameObject, sendButton.transform.parent);
copy.name = name;
var b = copy.GetComponent<Button>();
b.onClick.RemoveAllListeners();
b.onClick.AddListener(onClick);
var label = copy.GetComponentInChildren<TMP_Text>();
if (label != null) label.text = text;
return b;
}
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, 205f), rt.sizeDelta.y);
}
void OnScoutPressed(int level)
{
if (selectedTarget == null) return;
int soldiers = (int)soldiersSlider.value;
bool point = selectedTarget.type == MapNodeType.ResourcePoint;
string error;
bool ok = point
? MarchManager.Instance.TryStartScout(selectedTarget.id, soldiers, out error)
: MarchManager.Instance.TryStartRecon(selectedTarget.id, soldiers, level, out error);
long hold = !point && GameServices.TryGet(out IIntelligence intel) ? intel.ReconDurationMs(selectedTarget.id, level) : 0;
hintText.text = ok
? point ? $"Разведка выступила: {soldiers} воинов идут к цели «{selectedTarget.displayName}» и вернутся без боя"
: $"Разведка {(level == 3 ? "III" : "II")} выступила к «{selectedTarget.displayName}»: наблюдать {Duration(hold)}, потом отчёт и домой"
: "Разведка не начата: " + error;
CloseAttackPanel();
}
void HookUpComingSoon(Button button, string sectionName)
{
if (button == null) return;
button.onClick.AddListener(() => hintText.text = $"Раздел «{sectionName}» пока в разработке");
}
// ---------------- КАРТА ----------------
// Карта лежит на земле (2.5D): замок -- стены с башнями, свой город и соседи -- домики на площадке,
// лагеря -- шатры с костром, ресурсные точки -- кристаллы цвета ресурса. Подписи висят над объектами.
Renderer BuildMap()
{
float width = WorldMapLayout.HalfWidth / 1000f * 2f;
float height = WorldMapLayout.HalfHeight / 1000f * 2f;
Renderer ground = CreateGround(width, height);
foreach (var node in World.Nodes)
{
Vector3 pos = ToWorld(node);
int seed = node.id.GetHashCode() & 0x7fff;
switch (node.type)
{
case MapNodeType.BossCastle:
BuildCastle(node, pos);
Map3D.Label(mapRoot, node.displayName, pos + Vector3.up * (CastleHeight * 1.8f + 1.4f), 4f);
garrisonLabels.Add((node, Map3D.Label(mapRoot, "", pos + Vector3.up * (CastleHeight * 1.8f + 0.75f), 3f)));
break;
case MapNodeType.PlayerCity:
// Белая площадка и синие крыши -- чтобы свой город сразу бросался в глаза
Map3D.Block(mapRoot, node.id + "_Frame", PrimitiveType.Cube, Color.white, pos, Vector2.one * (CitySize + 0.45f), 0.08f);
BuildTown(node.id, pos + Vector3.up * 0.08f, new Color(0.92f, 0.88f, 0.78f), PlayerCityColor, seed, 4);
Map3D.Flag(mapRoot, pos + new Vector3(0.55f, 0.08f, 0.55f), 1.5f, new Color(0.95f, 0.8f, 0.2f));
Map3D.Label(mapRoot, node.displayName, pos + Vector3.up * (CityHeight + 1.0f), 3.2f);
break;
case MapNodeType.NeighborCity:
Map3D.Block(mapRoot, node.id + "_Yard", PrimitiveType.Cube, new Color(0.62f, 0.55f, 0.42f), pos, Vector2.one * (CitySize + 0.25f), 0.05f);
BuildTown(node.id, pos + Vector3.up * 0.05f, new Color(0.85f, 0.75f, 0.58f), new Color(0.62f, 0.25f, 0.18f), seed, 3);
garrisonLabels.Add((node, Map3D.Label(mapRoot, "", pos + Vector3.up * (CityHeight + 1.05f), 2.6f)));
break;
case MapNodeType.Camp:
// Шатры разбойников с костром; цвет главного шатра -- разбит лагерь или нет
var sprite = Map3D.Shape(mapRoot, node.id, Map3D.Cone, CampColor, pos, Vector2.one * CampSize, CampHeight * 1.6f);
Map3D.Shape(mapRoot, node.id + "_Tent2", Map3D.Cone, new Color(0.55f, 0.42f, 0.3f), pos + new Vector3(0.45f, 0, -0.25f), Vector2.one * CampSize * 0.6f, CampHeight * 1.1f);
Map3D.Block(mapRoot, node.id + "_Fire", PrimitiveType.Cube, new Color(1f, 0.55f, 0.15f), pos + new Vector3(-0.35f, 0, -0.35f), Vector2.one * 0.14f, 0.12f);
var label = Map3D.Label(mapRoot, "", pos + Vector3.up * (CampHeight * 1.6f + 0.85f), 2.3f);
campViews.Add((node, sprite, label));
break;
}
}
BuildScenery(width, height);
return ground;
}
// Замок Змея Горыныча: квадрат стен, четыре угловые башни с красными крышами, высокая главная башня
void BuildCastle(MapNode node, Vector3 pos)
{
Color stone = new Color(0.5f, 0.45f, 0.42f), dark = new Color(0.32f, 0.28f, 0.27f), roofRed = new Color(0.6f, 0.12f, 0.1f);
float half = CastleSize / 2f, wall = 0.18f, wallH = CastleHeight * 0.55f;
Map3D.Block(mapRoot, node.id + "_WallN", PrimitiveType.Cube, stone, pos + new Vector3(0, 0, half - wall / 2), new Vector2(CastleSize, wall), wallH);
Map3D.Block(mapRoot, node.id + "_WallS", PrimitiveType.Cube, stone, pos + new Vector3(0, 0, -half + wall / 2), new Vector2(CastleSize, wall), wallH);
Map3D.Block(mapRoot, node.id + "_WallE", PrimitiveType.Cube, stone, pos + new Vector3(half - wall / 2, 0, 0), new Vector2(wall, CastleSize), wallH);
Map3D.Block(mapRoot, node.id + "_WallW", PrimitiveType.Cube, stone, pos + new Vector3(-half + wall / 2, 0, 0), new Vector2(wall, CastleSize), wallH);
foreach (var corner in new[] { new Vector3(1, 0, 1), new Vector3(-1, 0, 1), new Vector3(1, 0, -1), new Vector3(-1, 0, -1) })
{
Vector3 c = pos + corner * (half - 0.12f);
Map3D.Block(mapRoot, node.id + "_Tower", PrimitiveType.Cylinder, stone, c, Vector2.one * 0.42f, CastleHeight);
Map3D.Shape(mapRoot, node.id + "_TowerRoof", Map3D.Cone, roofRed, c + Vector3.up * CastleHeight, Vector2.one * 0.55f, 0.5f);
}
Map3D.Block(mapRoot, node.id + "_Keep", PrimitiveType.Cube, dark, pos, Vector2.one * CastleSize * 0.42f, CastleHeight * 1.8f);
Map3D.Shape(mapRoot, node.id + "_KeepRoof", Map3D.Pyramid, roofRed, pos + Vector3.up * CastleHeight * 1.8f, Vector2.one * CastleSize * 0.5f, 0.7f, 45f);
Map3D.Flag(mapRoot, pos + Vector3.up * (CastleHeight * 1.8f + 0.6f), 0.6f, new Color(0.15f, 0.55f, 0.2f));
}
// Городок: несколько домиков разного размера на площадке города
void BuildTown(string id, Vector3 pos, Color walls, Color roofColor, int seed, int houses)
{
var rng = new System.Random(seed);
Vector3[] spots = { new Vector3(-0.28f, 0, -0.22f), new Vector3(0.28f, 0, -0.2f), new Vector3(-0.2f, 0, 0.3f), new Vector3(0.25f, 0, 0.28f) };
for (int i = 0; i < houses && i < spots.Length; i++)
{
float w = 0.38f + (float)rng.NextDouble() * 0.12f, h = 0.3f + (float)rng.NextDouble() * 0.25f;
Map3D.House(mapRoot, id + "_House" + i, pos + spots[i], w, w * 0.8f, h, walls, roofColor, rng.Next(2) * 90f);
}
}
// Рощи и камни между объектами -- только украшение (не мешают тапам и походам)
void BuildScenery(float width, float height)
{
var rng = new System.Random(WorldMapLayout.DefaultSeed);
int trees = 0;
for (int attempt = 0; attempt < 3000 && trees < 170; attempt++)
{
float x = ((float)rng.NextDouble() - 0.5f) * (width - 1f), z = ((float)rng.NextDouble() - 0.5f) * (height - 1f);
if (!FarFromNodes(x, z, 1.7f)) continue;
// рощами: рядом с деревом -- ещё 1-2
int group = 1 + rng.Next(3);
for (int g = 0; g < group; g++)
{
float gx = x + ((float)rng.NextDouble() - 0.5f) * 1.2f, gz = z + ((float)rng.NextDouble() - 0.5f) * 1.2f;
if (!FarFromNodes(gx, gz, 1.4f)) continue;
Map3D.Tree(mapRoot, "Tree", new Vector3(gx, 0, gz), 0.55f + (float)rng.NextDouble() * 0.35f, rng.Next(6));
trees++;
}
}
for (int i = 0, rocks = 0; i < 600 && rocks < 30; i++)
{
float x = ((float)rng.NextDouble() - 0.5f) * (width - 1f), z = ((float)rng.NextDouble() - 0.5f) * (height - 1f);
if (!FarFromNodes(x, z, 1.5f)) continue;
Map3D.Rock(mapRoot, "Rock", new Vector3(x, 0, z), 0.3f + (float)rng.NextDouble() * 0.35f, rng.Next(9));
rocks++;
}
}
bool FarFromNodes(float x, float z, float distance)
{
foreach (var n in World.Nodes)
{
float dx = n.x / 1000f - x, dz = n.y / 1000f - z;
if (dx * dx + dz * dz < distance * distance) return false;
}
return true;
}
// Земля -- плоскость с текстурой: её раскрашивает туман (IntelligenceOverlay). UV (0,0) -- левый нижний угол карты
Renderer CreateGround(float width, float height)
{
var mesh = new Mesh { name = "WorldMap_Ground" };
float hx = width / 2f, hz = height / 2f;
mesh.vertices = new[] { new Vector3(-hx, 0, -hz), new Vector3(-hx, 0, hz), new Vector3(hx, 0, hz), new Vector3(hx, 0, -hz) };
mesh.uv = new[] { new Vector2(0, 0), new Vector2(0, 1), new Vector2(1, 1), new Vector2(1, 0) };
mesh.triangles = new[] { 0, 1, 2, 0, 2, 3 };
mesh.RecalculateNormals();
// Земля за краем карты -- тёмная, как неисследованное: камера под наклоном не видит пустого неба
Map3D.Block(mapRoot, "OuterLand", PrimitiveType.Cube, new Color(0.12f, 0.16f, 0.13f), new Vector3(0, -0.08f, 0),
new Vector2(width + 160f, height + 160f), 0.06f);
var go = new GameObject("Ground");
go.transform.SetParent(mapRoot, false);
go.AddComponent<MeshFilter>().sharedMesh = mesh;
var r = go.AddComponent<MeshRenderer>();
r.sharedMaterial = Map3D.TexturedMat(Texture2D.whiteTexture); // текстуру с туманом даст IntelligenceOverlay
return r;
}
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.distance = StartDistance;
Map3D.SetUpLook(mapCamera.GetComponent<Camera>(), 22f, 60f); // солнце, небо, дымка вдали (в 2D-сцене света не было)
CenterOnMyCity();
}
void CenterOnMyCity()
{
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
if (home != null) mapCamera.CenterOn(ToWorld(home));
}
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.sharedMaterial = Map3D.Mat(left > 0 ? new Color(0.4f, 0.4f, 0.4f) : 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 holder = new GameObject(p.id).transform; // исчезнет точка -- уберём всё вместе
holder.SetParent(mapRoot, false);
Map3D.Rock(holder, "Base", pos, PointSize * 0.9f, p.kind + 3);
var sprite = Map3D.Shape(holder, "Crystal", Map3D.Pyramid, PointColors[p.kind], pos + Vector3.up * 0.1f, Vector2.one * PointSize * 0.75f, PointHeight * 1.8f, 45f);
var label = Map3D.Label(mapRoot, "", pos + Vector3.up * (PointHeight + 0.75f), 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} сек";
}
// Координаты карты (x, y) -> мир на земле (x, 0, y). 1000 милли-единиц = 1 клетка
public static Vector3 ToWorld(MapNode node)
{
return new Vector3(node.x / 1000f, 0f, node.y / 1000f);
}
// ---------------- ВЫБОР ЦЕЛИ ----------------
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.z - pos.z) <= 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);
shownReport = !shownObserved && GameServices.TryGet(out IIntelligence intelReports) && intelReports.TryGetLatestReport(target.id, out var report)
? report : (ReconReport?)null;
attackInfoBase = target.type == MapNodeType.ResourcePoint ? PointInfo(target)
: (shownObserved
? $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})"
: shownReport.HasValue
? $"Гарнизон по отчёту разведки {Duration(MarchManager.NowMs - shownReport.Value.TimeMs)} назад: {Sum(shownReport.Value.Units)} ({UnitsText(shownReport.Value.Units)})"
: "Гарнизон неизвестен: цель вне зоны обзора. Отправьте разведку") +
(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;
bool pointTarget = target.type == MapNodeType.ResourcePoint;
if (scoutButton != null) scoutButton.interactable = available > 0 && slotFree;
if (scoutLabel != null) scoutLabel.text = pointTarget ? "Разведка" : "Разведка II";
if (scout3Button != null)
{
scout3Button.gameObject.SetActive(!pointTarget); // у точки отчёта нет -- только дойти и вернуться
scout3Button.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 && !shownReport.HasValue) return "прогноз боя недоступен: гарнизон цели неизвестен";
int[] defenders = shownObserved ? WorldMapLayout.GarrisonUnits(selectedTarget) : shownReport.Value.Units; // по отчёту -- на момент отчёта
// Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026)
BattleForecast f = GameServices.Get<IBattleSystem>().Forecast(new BattleRequest
{
AttackerUnits = units,
DefenderUnits = defenders,
Defenders = Sum(defenders),
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)}";
}
static int Sum(int[] units)
{
int n = 0;
if (units != null) foreach (var u in units) n += u;
return n;
}
// 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 HandleReconCompleted(MarchData march)
{
if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(march.targetNodeId, out var report))
EnqueuePopup("ОТЧЁТ РАЗВЕДКИ", ReconReportText.Format(report, MarchManager.NowMs) + "\nРазведчики возвращаются домой");
}
void HandleScoutArrived(MarchData march)
{
if (march.reconLevel > 0 && !march.targetEmpty)
{
hintText.text = $"Разведка у цели «{World.TargetName(march)}»: наблюдает {Duration(march.gatherEndMs - march.arriveMs)}";
return;
}
string target = World.TargetName(march);
hintText.text = march.targetEmpty
? $"Разведка: «{target}» уже нет, войско возвращается"
: $"Разведка дошла до цели «{target}» и возвращается домой";
}
// «(из них ранены 12 -- придут в госпиталь)»: раненые идут домой с войском
static string WoundedText(MarchData march)
{
int w = 0, missing = 0;
if (march.woundedUnits != null) foreach (var u in march.woundedUnits) w += u;
if (march.missingUnits != null) foreach (var u in march.missingUnits) missing += u;
string text = w > 0 ? $"ранены {w} -- придут в госпиталь" : "";
if (missing > 0) text += (text.Length > 0 ? ", " : "") + $"пропали {missing}";
return text.Length > 0 ? $" ({text})" : "";
}
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);
}
}