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-11-04 14:55:41 +01:00
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 ;
2026-10-12 23:54:03 +03:00
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
// Лагеря меняют вид: разбит -- серый, с таймером до появления
2026-11-04 14:55:41 +01:00
private readonly List <( MapNode node , Renderer sprite , TextMeshPro label )> campViews = new List <( MapNode , Renderer , TextMeshPro )>();
2026-10-31 16:28:50 +01:00
// Ресурсные точки появляются и исчезают -- виды создаются и убираются по ходу игры
2026-11-04 14:55:41 +01:00
private readonly Dictionary < string , ( Renderer sprite , TextMeshPro label )> pointViews = new Dictionary < string , ( Renderer , TextMeshPro )>();
2026-10-31 16:28:50 +01:00
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-11-04 15:21:54 +01:00
private ReconReport ? shownReport ; // цель вне обзора, но есть отчёт разведки -- гарнизон и прогноз по нему (на момент отчёта)
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 ;
2026-11-04 14:55:41 +01:00
Renderer ground = BuildMap ();
IntelligenceOverlay . Create ( mapRoot , World , ground ); // туман на земле и чужие армии (модуль Разведки)
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 )
2026-11-05 17:31:25 +01:00
hintText . text = "Запусти игру из сцены города (City3D), иначе войско неоткуда брать" ;
2026-10-12 23:54:03 +03:00
}
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-11-04 15:21:54 +01:00
MarchManager . Instance . OnReconCompleted += HandleReconCompleted ;
2026-11-06 17:15:22 +01:00
MarchManager . Instance . OnCovertDetected += HandleCovertDetected ;
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-11-04 15:21:54 +01:00
MarchManager . Instance . OnReconCompleted -= HandleReconCompleted ;
2026-11-06 17:15:22 +01:00
MarchManager . Instance . OnCovertDetected -= HandleCovertDetected ;
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-04 15:21:54 +01:00
// «Отчёты» -- под «Армиями»: последние отчёты разведки (Разведка М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 );
2026-10-31 16:38:41 +01:00
}
2026-11-04 15:21:54 +01:00
// Кнопки разведки в окне атаки -- копии «Отправить». Четыре в ряд: Отмена, Разведка II, Разведка III, Отправить.
// Разведка II / III (Разведка М2): наблюдать цель и принести отчёт; у ресурсной точки -- просто «Разведка» (дойти и вернуться)
private Button scoutButton , scout3Button ;
private TMP_Text scoutLabel ;
2026-11-01 13:11:55 +01:00
void CreateScoutButton ()
{
2026-11-04 15:21:54 +01:00
scoutButton = CopyOfSend ( "ScoutButton" , "Разведка II" , () => OnScoutPressed ( 2 ));
scoutLabel = scoutButton . GetComponentInChildren < TMP_Text >();
scout3Button = CopyOfSend ( "Scout3Button" , "Разведка III" , () => OnScoutPressed ( 3 ));
2026-11-01 13:11:55 +01:00
2026-11-04 15:21:54 +01:00
Place ( cancelButton , - 330f );
Place ( scoutButton , - 110f );
Place ( scout3Button , 110f );
Place ( sendButton , 330f );
2026-11-06 17:15:22 +01:00
// «Тайно» (Разведка М3) -- над кнопками разведки: группа до 10 пехоты / лучников, может быть обнаружена
covertButton = CopyOfSend ( "CovertButton" , CovertLabel , () =>
{
ReconReportText . CovertMode = ! ReconReportText . CovertMode ;
RefreshCovertButton ();
});
var crt = ( RectTransform ) covertButton . transform ;
var srt = ( RectTransform ) scoutButton . transform ;
crt . anchoredPosition = srt . anchoredPosition + new Vector2 ( 110f , srt . rect . height + 12f );
crt . sizeDelta = new Vector2 ( 420f , srt . sizeDelta . y );
RefreshCovertButton ();
}
private Button covertButton ;
static string CovertLabel => ReconReportText . CovertMode ? "Тайно: ВКЛ (до 10 пехоты и лучников)" : "Тайно: выкл" ;
void RefreshCovertButton ()
{
if ( covertButton == null ) return ;
bool can = GameServices . TryGet ( out IIntelligence intel ) && intel . CovertEnabled ;
covertButton . gameObject . SetActive ( can && selectedTarget != null && selectedTarget . type != MapNodeType . ResourcePoint );
var label = covertButton . GetComponentInChildren < TMP_Text >();
if ( label != null ) label . text = CovertLabel ;
2026-11-04 15:21:54 +01:00
}
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 ;
2026-11-01 13:11:55 +01:00
}
static void Place ( Button button , float x )
{
var rt = ( RectTransform ) button . transform ;
rt . anchoredPosition = new Vector2 ( x , rt . anchoredPosition . y );
2026-11-04 15:21:54 +01:00
rt . sizeDelta = new Vector2 ( Mathf . Min ( rt . sizeDelta . x , 205f ), rt . sizeDelta . y );
2026-11-01 13:11:55 +01:00
}
2026-11-04 15:21:54 +01:00
void OnScoutPressed ( int level )
2026-11-01 13:11:55 +01:00
{
if ( selectedTarget == null ) return ;
int soldiers = ( int ) soldiersSlider . value ;
2026-11-04 15:21:54 +01:00
bool point = selectedTarget . type == MapNodeType . ResourcePoint ;
2026-11-06 17:15:22 +01:00
// Тайно (М3) -- не больше 10: лишних с ползунка не берём
if (! point && ReconReportText . CovertMode && GameServices . TryGet ( out IIntelligence spyIntel ) && spyIntel . CovertEnabled )
soldiers = Mathf . Min ( soldiers , spyIntel . CovertMaxSoldiers );
2026-11-04 15:21:54 +01:00
string error ;
bool ok = point
? MarchManager . Instance . TryStartScout ( selectedTarget . id , soldiers , out error )
2026-11-06 17:15:22 +01:00
: MarchManager . Instance . TryStartRecon ( selectedTarget . id , soldiers , level , ReconReportText . CovertMode , out error );
2026-11-04 15:21:54 +01:00
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)}, потом отчёт и домой"
2026-11-01 13:11:55 +01:00
: "Разведка не начата: " + 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}» пока в разработке" );
}
// ---------------- КАРТА ----------------
2026-11-04 15:03:16 +01:00
// Карта лежит на земле (2.5D): замок -- стены с башнями, свой город и соседи -- домики на площадке,
// лагеря -- шатры с костром, ресурсные точки -- кристаллы цвета ресурса. Подписи висят над объектами.
2026-11-04 14:55:41 +01:00
Renderer BuildMap ()
2026-10-12 23:54:03 +03:00
{
float width = WorldMapLayout . HalfWidth / 1000f * 2f ;
float height = WorldMapLayout . HalfHeight / 1000f * 2f ;
2026-11-04 14:55:41 +01:00
Renderer ground = CreateGround ( width , height );
2026-10-12 23:54:03 +03:00
foreach ( var node in World . Nodes )
{
Vector3 pos = ToWorld ( node );
2026-11-04 15:03:16 +01:00
int seed = node . id . GetHashCode () & 0x7fff ;
2026-10-12 23:54:03 +03:00
switch ( node . type )
{
case MapNodeType . BossCastle :
2026-11-04 15:03:16 +01:00
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 )));
2026-10-12 23:54:03 +03:00
break ;
case MapNodeType . PlayerCity :
2026-11-04 15:03:16 +01:00
// Белая площадка и синие крыши -- чтобы свой город сразу бросался в глаза
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 );
2026-10-12 23:54:03 +03:00
break ;
case MapNodeType . NeighborCity :
2026-11-04 15:03:16 +01:00
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 )));
2026-10-12 23:54:03 +03:00
break ;
2026-10-27 16:46:54 +01:00
case MapNodeType . Camp :
2026-11-04 15:03:16 +01:00
// Шатры разбойников с костром; цвет главного шатра -- разбит лагерь или нет
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 );
2026-10-27 16:46:54 +01:00
campViews . Add (( node , sprite , label ));
break ;
2026-10-12 23:54:03 +03:00
}
}
2026-11-04 15:03:16 +01:00
BuildScenery ( width , height );
2026-11-04 14:55:41 +01:00
return ground ;
}
2026-11-04 15:03:16 +01:00
// Замок Змея Горыныча: квадрат стен, четыре угловые башни с красными крышами, высокая главная башня
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 ;
}
2026-11-04 14:55:41 +01:00
// Земля -- плоскость с текстурой: её раскрашивает туман (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 ();
2026-11-04 15:07:28 +01:00
// Земля за краем карты -- тёмная, как неисследованное: камера под наклоном не видит пустого неба
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 );
2026-11-04 14:55:41 +01:00
var go = new GameObject ( "Ground" );
go . transform . SetParent ( mapRoot , false );
go . AddComponent < MeshFilter >(). sharedMesh = mesh ;
var r = go . AddComponent < MeshRenderer >();
2026-11-04 15:03:16 +01:00
r . sharedMaterial = Map3D . TexturedMat ( Texture2D . whiteTexture ); // текстуру с туманом даст IntelligenceOverlay
2026-11-04 14:55:41 +01:00
return r ;
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 );
2026-11-04 14:55:41 +01:00
mapCamera . distance = StartDistance ;
2026-11-04 15:03:16 +01:00
Map3D . SetUpLook ( mapCamera . GetComponent < Camera >(), 22f , 60f ); // солнце, небо, дымка вдали (в 2D-сцене света не было)
2026-10-12 23:54:03 +03:00
CenterOnMyCity ();
}
void CenterOnMyCity ()
{
MapNode home = World . GetNode ( WorldMapLayout . PlayerCityId );
if ( home != null ) mapCamera . CenterOn ( ToWorld ( home ));
}
2026-10-27 16:46:54 +01:00
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 );
2026-11-04 15:03:16 +01:00
sprite . sharedMaterial = Map3D . Mat ( left > 0 ? new Color ( 0.4f , 0.4f , 0.4f ) : 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 );
2026-11-04 15:03:16 +01:00
// Кристалл цвета ресурса на камне -- не путается с городом и лагерем
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 );
2026-11-04 14:55:41 +01:00
var label = Map3D . Label ( mapRoot , "" , pos + Vector3 . up * ( PointHeight + 0.75f ), 2.2f );
2026-10-31 16:28:50 +01:00
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
}
2026-11-04 14:55:41 +01:00
// Координаты карты (x, y) -> мир на земле (x, 0, y). 1000 милли-единиц = 1 клетка
2026-10-12 23:54:03 +03:00
public static Vector3 ToWorld ( MapNode node )
{
2026-11-04 14:55:41 +01:00
return new Vector3 ( node . x / 1000f , 0f , node . y / 1000f );
2026-10-12 23:54:03 +03:00
}
// ---------------- ВЫБОР ЦЕЛИ ----------------
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 ;
2026-11-06 16:55:59 +01:00
// Видимая чужая армия под пальцем -- окно разведки армии (Разведка М2)
if ( FindArmyAt ( world , out KnownContact army )) { ReconReportText . OpenArmyWindow ( army , s => hintText . text = s ); return ; }
2026-10-12 23:54:03 +03:00
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 );
}
2026-11-06 16:55:59 +01:00
// Чужая армия в обзоре рядом с точкой нажатия (метка-ромб размером ~0,55 клетки; зона чуть больше)
static bool FindArmyAt ( Vector3 world , out KnownContact found )
{
found = default ;
if (! GameServices . TryGet ( out IIntelligence intel )) return false ;
float best = 0.7f ;
bool any = false ;
foreach ( var c in intel . Contacts )
{
if (! c . Observed ) continue ;
float d = Vector2 . Distance ( new Vector2 ( world . x , world . z ), new Vector2 ( c . X / 1000f , c . Y / 1000f ));
if ( d <= best ) { best = d ; found = c ; any = true ; }
}
return any ;
}
2026-10-12 23:54:03 +03:00
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 );
2026-11-04 14:55:41 +01:00
if ( Mathf . Abs ( world . x - pos . x ) <= halfSize && Mathf . Abs ( world . z - pos . z ) <= halfSize ) return node ;
2026-10-12 23:54:03 +03:00
}
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-11-04 15:21:54 +01:00
shownReport = ! shownObserved && GameServices . TryGet ( out IIntelligence intelReports ) && intelReports . TryGetLatestReport ( target . id , out var report )
? report : ( ReconReport ?) null ;
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-11-04 15:21:54 +01:00
: shownReport . HasValue
? $"Гарнизон по отчёту разведки {Duration(MarchManager.NowMs - shownReport.Value.TimeMs)} назад: {Sum(shownReport.Value.Units)} ({UnitsText(shownReport.Value.Units)})"
: "Гарнизон неизвестен: цель вне зоны обзора. Отправьте разведку" ) +
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-04 15:21:54 +01:00
bool pointTarget = target . type == MapNodeType . ResourcePoint ;
2026-11-01 13:11:55 +01:00
if ( scoutButton != null ) scoutButton . interactable = available > 0 && slotFree ;
2026-11-04 15:21:54 +01:00
if ( scoutLabel != null ) scoutLabel . text = pointTarget ? "Разведка" : "Разведка II" ;
if ( scout3Button != null )
{
scout3Button . gameObject . SetActive (! pointTarget ); // у точки отчёта нет -- только дойти и вернуться
scout3Button . interactable = available > 0 && slotFree ;
}
2026-11-06 17:15:22 +01:00
RefreshCovertButton ();
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-04 15:21:54 +01:00
if (! shownObserved && ! shownReport . HasValue ) return "прогноз боя недоступен: гарнизон цели неизвестен" ;
int [] defenders = shownObserved ? WorldMapLayout . GarrisonUnits ( selectedTarget ) : shownReport . Value . Units ; // по отчёту -- на момент отчёта
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 ,
2026-11-04 15:21:54 +01:00
DefenderUnits = defenders ,
Defenders = Sum ( defenders ),
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)}" ;
}
2026-11-04 15:21:54 +01:00
static int Sum ( int [] units )
{
int n = 0 ;
if ( units != null ) foreach ( var u in units ) n += u ;
return n ;
}
2026-10-30 17:02:05 +01:00
// 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-06 17:15:22 +01:00
// Разведка М3: тайный отряд заметили -- миссия прервана, отряд идёт домой (без отчёта, без боя)
void HandleCovertDetected ( MarchData march )
{
EnqueuePopup ( "РАЗВЕДЧИКОВ ЗАМЕТИЛИ" ,
$"Тайный отряд ({march.soldiers} воинов) заметили у цели «{World.TargetName(march)}». Миссия прервана: разведчики возвращаются домой, отчёта не будет." );
}
2026-11-04 15:21:54 +01:00
void HandleReconCompleted ( MarchData march )
{
2026-11-06 16:55:59 +01:00
if ( GameServices . TryGet ( out IIntelligence intel ) && intel . TryGetLatestReport ( MarchManager . ReconTargetId ( march ), out var report ))
2026-11-04 15:21:54 +01:00
EnqueuePopup ( "ОТЧЁТ РАЗВЕДКИ" , ReconReportText . Format ( report , MarchManager . NowMs ) + "\nРазведчики возвращаются домой" );
}
2026-11-01 13:11:55 +01:00
void HandleScoutArrived ( MarchData march )
{
2026-11-04 15:21:54 +01:00
if ( march . reconLevel > 0 && ! march . targetEmpty )
{
hintText . text = $"Разведка у цели «{World.TargetName(march)}»: наблюдает {Duration(march.gatherEndMs - march.arriveMs)}" ;
return ;
}
2026-11-01 13:11:55 +01:00
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 )
{
2026-11-04 15:16:43 +01:00
int w = 0 , missing = 0 ;
2026-11-01 13:32:25 +01:00
if ( march . woundedUnits != null ) foreach ( var u in march . woundedUnits ) w += u ;
2026-11-04 15:16:43 +01:00
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})" : "" ;
2026-11-01 13:32:25 +01:00
}
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 );
}
}