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;
|
|
|
|
|
|
|
|
|
|
|
|
const string DefaultHint = "Нажми на замок или соседний город, чтобы напасть. Карту можно двигать и приближать";
|
|
|
|
|
|
const float CastleSize = 2.2f;
|
|
|
|
|
|
const float CitySize = 1.1f;
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
|
|
WorldSimulation World => MarchManager.Instance.World;
|
|
|
|
|
|
|
|
|
|
|
|
void Start()
|
|
|
|
|
|
{
|
|
|
|
|
|
mapRoot = new GameObject("Map").transform;
|
|
|
|
|
|
BuildMap();
|
|
|
|
|
|
SetUpCamera();
|
|
|
|
|
|
|
|
|
|
|
|
backButton.onClick.AddListener(SceneNavigator.ReturnToCity);
|
|
|
|
|
|
if (myCityButton != null) myCityButton.onClick.AddListener(CenterOnMyCity);
|
|
|
|
|
|
if (ratingButton != null) ratingButton.onClick.AddListener(ratingPanel.Open);
|
|
|
|
|
|
HookUpComingSoon(worldMapNavButton, "Карта мира");
|
|
|
|
|
|
HookUpComingSoon(eventsNavButton, "События");
|
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);
|
|
|
|
|
|
cancelButton.onClick.AddListener(CloseAttackPanel);
|
|
|
|
|
|
popupOkButton.onClick.AddListener(ShowNextPopup);
|
|
|
|
|
|
soldiersSlider.onValueChanged.AddListener(_ => UpdateSoldiersToSendText());
|
|
|
|
|
|
|
|
|
|
|
|
attackPanel.SetActive(false);
|
|
|
|
|
|
popupPanel.SetActive(false);
|
|
|
|
|
|
if (ratingPanel != null) ratingPanel.Close();
|
|
|
|
|
|
hintText.text = DefaultHint;
|
|
|
|
|
|
|
|
|
|
|
|
if (ResourceManager.Instance == null)
|
|
|
|
|
|
hintText.text = "Запусти игру из сцены города (MVP1), иначе войско неоткуда брать";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void OnEnable()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (MarchManager.Instance == null) return;
|
|
|
|
|
|
MarchManager.Instance.OnBattleResolved += HandleBattleResolved;
|
|
|
|
|
|
MarchManager.Instance.OnMarchReturned += HandleMarchReturned;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void OnDisable()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (MarchManager.Instance == null) return;
|
|
|
|
|
|
MarchManager.Instance.OnBattleResolved -= HandleBattleResolved;
|
|
|
|
|
|
MarchManager.Instance.OnMarchReturned -= HandleMarchReturned;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void Update()
|
|
|
|
|
|
{
|
|
|
|
|
|
HandleMapTap();
|
|
|
|
|
|
SyncMarchViews();
|
|
|
|
|
|
|
|
|
|
|
|
soldiersText.text = $"Войска дома: {GetSoldiersAtHome()} В походе: {World.Marches.Count}";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void HookUpComingSoon(Button button, string sectionName)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (button == null) return;
|
|
|
|
|
|
button.onClick.AddListener(() => hintText.text = $"Раздел «{sectionName}» пока в разработке");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------- КАРТА ----------------
|
|
|
|
|
|
|
|
|
|
|
|
void BuildMap()
|
|
|
|
|
|
{
|
|
|
|
|
|
float width = WorldMapLayout.HalfWidth / 1000f * 2f;
|
|
|
|
|
|
float height = WorldMapLayout.HalfHeight / 1000f * 2f;
|
|
|
|
|
|
CreateSprite("Ground", PrimitiveSprites.Square, GroundColor, Vector3.zero, new Vector3(width, height, 1f), -10);
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var node in World.Nodes)
|
|
|
|
|
|
{
|
|
|
|
|
|
Vector3 pos = ToWorld(node);
|
|
|
|
|
|
|
|
|
|
|
|
switch (node.type)
|
|
|
|
|
|
{
|
|
|
|
|
|
case MapNodeType.BossCastle:
|
|
|
|
|
|
CreateSprite(node.id, PrimitiveSprites.Square, CastleColor, pos, Vector3.one * CastleSize, 0);
|
|
|
|
|
|
CreateSprite(node.id + "_Keep", PrimitiveSprites.Square, new Color(0.3f, 0.08f, 0.08f), pos, Vector3.one * CastleSize * 0.5f, 1);
|
|
|
|
|
|
CreateLabel(node.displayName, pos + new Vector3(0, CastleSize / 2f + 0.4f, 0), 4f);
|
|
|
|
|
|
CreateLabel($"Гарнизон: {node.garrison}", pos - new Vector3(0, CastleSize / 2f + 0.35f, 0), 3f);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case MapNodeType.PlayerCity:
|
|
|
|
|
|
// Белая рамка -- чтобы свой город сразу бросался в глаза
|
|
|
|
|
|
CreateSprite(node.id + "_Frame", PrimitiveSprites.Square, Color.white, pos, Vector3.one * (CitySize + 0.25f), 0);
|
|
|
|
|
|
CreateSprite(node.id, PrimitiveSprites.Square, PlayerCityColor, pos, Vector3.one * CitySize, 1);
|
|
|
|
|
|
CreateLabel(node.displayName, pos - new Vector3(0, CitySize / 2f + 0.35f, 0), 3.2f);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case MapNodeType.NeighborCity:
|
|
|
|
|
|
CreateSprite(node.id, PrimitiveSprites.Square, NeighborCityColor, pos, Vector3.one * CitySize, 0);
|
|
|
|
|
|
CreateLabel($"{node.displayName}\nГарнизон: {node.garrison}", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CreateLabel(string text, Vector3 position, float fontSize)
|
|
|
|
|
|
{
|
|
|
|
|
|
var go = new GameObject("Label");
|
|
|
|
|
|
go.transform.SetParent(mapRoot, false);
|
|
|
|
|
|
go.transform.position = position;
|
|
|
|
|
|
var tmp = go.AddComponent<TextMeshPro>();
|
|
|
|
|
|
tmp.text = text;
|
|
|
|
|
|
tmp.fontSize = fontSize;
|
|
|
|
|
|
tmp.alignment = TextAlignmentOptions.Center;
|
|
|
|
|
|
tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
|
|
|
|
|
|
tmp.sortingOrder = 10;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static Vector3 ToWorld(MapNode node)
|
|
|
|
|
|
{
|
|
|
|
|
|
return new Vector3(node.x / 1000f, node.y / 1000f, 0f);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------- ВЫБОР ЦЕЛИ ----------------
|
|
|
|
|
|
|
|
|
|
|
|
void HandleMapTap()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (attackPanel.activeSelf || popupPanel.activeSelf) return;
|
|
|
|
|
|
if (ratingPanel != null && ratingPanel.gameObject.activeSelf) return;
|
|
|
|
|
|
if (!mapCamera.TryGetTap(out Vector3 world)) return;
|
|
|
|
|
|
|
|
|
|
|
|
MapNode node = FindNodeAt(world);
|
|
|
|
|
|
if (node == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (node.type == MapNodeType.PlayerCity) hintText.text = "Это твой город. Нажми НАЗАД, чтобы вернуться в него";
|
|
|
|
|
|
else if (node.canBeAttacked) OpenAttackPanel(node);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
MapNode FindNodeAt(Vector3 world)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var node in World.Nodes)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем
|
|
|
|
|
|
float halfSize = (node.type == MapNodeType.BossCastle ? CastleSize : CitySize) / 2f + 0.2f;
|
|
|
|
|
|
Vector3 pos = ToWorld(node);
|
|
|
|
|
|
if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------- ОКНО АТАКИ ----------------
|
|
|
|
|
|
|
|
|
|
|
|
void OpenAttackPanel(MapNode target)
|
|
|
|
|
|
{
|
|
|
|
|
|
selectedTarget = target;
|
|
|
|
|
|
int available = GetSoldiersAtHome();
|
|
|
|
|
|
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
|
|
|
|
|
|
int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f);
|
|
|
|
|
|
|
|
|
|
|
|
attackTitleText.text = target.type == MapNodeType.NeighborCity
|
|
|
|
|
|
? $"Город игрока {target.displayName}"
|
|
|
|
|
|
: target.displayName;
|
|
|
|
|
|
attackInfoText.text = $"Гарнизон: {target.garrison} солдат\n" +
|
|
|
|
|
|
$"Путь в одну сторону: {travelSeconds} сек\n" +
|
|
|
|
|
|
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
|
|
|
|
|
|
|
|
|
|
|
|
soldiersSlider.minValue = available > 0 ? 1 : 0;
|
|
|
|
|
|
soldiersSlider.maxValue = available;
|
|
|
|
|
|
soldiersSlider.wholeNumbers = true;
|
|
|
|
|
|
soldiersSlider.value = available;
|
|
|
|
|
|
soldiersSlider.interactable = available > 0;
|
|
|
|
|
|
sendButton.interactable = available > 0;
|
|
|
|
|
|
UpdateSoldiersToSendText();
|
|
|
|
|
|
|
|
|
|
|
|
attackPanel.SetActive(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void UpdateSoldiersToSendText()
|
|
|
|
|
|
{
|
|
|
|
|
|
int available = GetSoldiersAtHome();
|
|
|
|
|
|
soldiersToSendText.text = available > 0
|
|
|
|
|
|
? $"Отправить: {(int)soldiersSlider.value} из {available}"
|
|
|
|
|
|
: "Дома нет солдат";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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-23 18:33:04 +02:00
|
|
|
|
? GameServices.Get<IArmy>().SoldiersAtHome // [объединение] наши воины всех типов
|
2026-10-12 23:54:03 +03:00
|
|
|
|
: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------- ВОЙСКА НА КАРТЕ ----------------
|
|
|
|
|
|
|
|
|
|
|
|
void SyncMarchViews()
|
|
|
|
|
|
{
|
|
|
|
|
|
long now = MarchManager.NowMs;
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var march in World.Marches)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!marchViews.TryGetValue(march.id, out MarchView view))
|
|
|
|
|
|
{
|
|
|
|
|
|
view = MarchView.Create(mapRoot, march);
|
|
|
|
|
|
marchViews[march.id] = view;
|
|
|
|
|
|
}
|
|
|
|
|
|
view.Refresh(World, march, now);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Убираем войска, которые уже вернулись
|
|
|
|
|
|
staleMarchIds.Clear();
|
|
|
|
|
|
foreach (var id in marchViews.Keys)
|
|
|
|
|
|
{
|
|
|
|
|
|
bool stillActive = false;
|
|
|
|
|
|
foreach (var march in World.Marches)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (march.id == id) { stillActive = true; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!stillActive) staleMarchIds.Add(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach (var id in staleMarchIds)
|
|
|
|
|
|
{
|
|
|
|
|
|
Destroy(marchViews[id].gameObject);
|
|
|
|
|
|
marchViews.Remove(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------- УВЕДОМЛЕНИЯ ----------------
|
|
|
|
|
|
|
|
|
|
|
|
void HandleBattleResolved(MarchData march)
|
|
|
|
|
|
{
|
|
|
|
|
|
MapNode target = World.GetNode(march.targetNodeId);
|
|
|
|
|
|
string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ";
|
|
|
|
|
|
string body = $"{target.displayName}\n" +
|
|
|
|
|
|
$"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" +
|
|
|
|
|
|
$"Твои потери: {march.soldiers - march.survivors}, потери врага: {march.defenderLosses}\n" +
|
|
|
|
|
|
$"Домой идут: {march.survivors}\n";
|
|
|
|
|
|
body += march.victory
|
|
|
|
|
|
? $"Добыча (придёт с войском): {MarchManager.FormatLoot(march.loot)}"
|
|
|
|
|
|
: "Добычи нет";
|
|
|
|
|
|
EnqueuePopup(title, body);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void HandleMarchReturned(MarchData march)
|
|
|
|
|
|
{
|
|
|
|
|
|
string body = $"В город вернулись {march.survivors} солдат\n" +
|
|
|
|
|
|
$"Добыча: {MarchManager.FormatLoot(march.loot)}";
|
|
|
|
|
|
EnqueuePopup("ВОЙСКО ДОМА", body);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void EnqueuePopup(string title, string body)
|
|
|
|
|
|
{
|
|
|
|
|
|
popupQueue.Enqueue((title, body));
|
|
|
|
|
|
if (!popupPanel.activeSelf) ShowNextPopup();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void ShowNextPopup()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (popupQueue.Count == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
popupPanel.SetActive(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var (title, body) = popupQueue.Dequeue();
|
|
|
|
|
|
popupTitleText.text = title;
|
|
|
|
|
|
popupTitleText.color = title == "ПОРАЖЕНИЕ" ? new Color(0.9f, 0.35f, 0.3f) : new Color(1f, 0.85f, 0.3f);
|
|
|
|
|
|
popupBodyText.text = body;
|
|
|
|
|
|
popupPanel.SetActive(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|