Files
MVP1/Assets/Scripts/WorldMap/WorldMapController.cs
T
AlexanderandClaude Opus 5.5 4865b14ff2 Гарнизон города
Модуль Армии (Bogatyri.Army, без Unity):
- гарнизон — часть воинов в городе: защищает город и в походы не уходит, всего воинов столько же
- IArmy: GarrisonTotal, GetGarrison(type), AvailableForMarch, SetGarrison(count)
- гарнизон набирается по составу армии, не больше воинов в городе; после потерь обрезается по типам
- в поход уходят только свободные воины (TakeForMarch)
- сохранение: строка garrison= в секции army (старые сохранения — без гарнизона); сброс очищает
- TroopsChangedEvent.Garrison

Интерфейс:
- окно «Войска» в городе: карточка «Гарнизон города» (0, -10, -1, +1, +10, «Все»)
- карта мира: «Свободно / Гарнизон / В походе», ползунок похода и прогноз — только по свободным воинам
- понятная ошибка, если воины есть, но все в гарнизоне

Тест «один солдат в одном месте»: 30 воинов, 10 в гарнизон → 20 свободны, всего 30 (решение команды от 19.10.2026).
Документы Army, Core, World, README обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:54:49 +01:00

466 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using Bogatyri.World;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// Сцена карты мира: рисует замок и города, открывает окно атаки, показывает войска в походе
// и итоги боёв. Правил тут нет -- они в WorldSimulation, походы ведёт MarchManager.
public class WorldMapController : MonoBehaviour
{
[Header("Камера")]
public MapCameraController mapCamera;
[Header("Верхняя панель")]
public TMP_Text soldiersText;
public Button backButton;
public Button myCityButton;
[Header("Подсказка")]
public TMP_Text hintText;
[Header("Рейтинг")]
public Button ratingButton;
public RatingPanel ratingPanel;
[Header("Нижняя панель (пока без функционала)")]
public Button worldMapNavButton;
public Button eventsNavButton;
public Button circleNavButton;
public Button settlementNavButton;
[Header("Окно атаки")]
public GameObject attackPanel;
public TMP_Text attackTitleText;
public TMP_Text attackInfoText;
public Slider soldiersSlider;
public TMP_Text soldiersToSendText;
public Button sendButton;
public Button cancelButton;
[Header("Уведомление (итог боя / возвращение)")]
public GameObject popupPanel;
public TMP_Text popupTitleText;
public TMP_Text popupBodyText;
public Button popupOkButton;
const string DefaultHint = "Нажми на замок, соседний город или лагерь разбойников, чтобы напасть. Карту можно двигать и приближать";
const float CastleSize = 2.2f;
const float CitySize = 1.1f;
const float CampSize = 0.8f;
const float StartZoom = 8f;
static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f);
static readonly Color CastleColor = new Color(0.55f, 0.15f, 0.15f);
static readonly Color PlayerCityColor = new Color(0.25f, 0.5f, 0.95f);
static readonly Color NeighborCityColor = new Color(0.75f, 0.55f, 0.3f);
static readonly Color CampColor = new Color(0.45f, 0.2f, 0.45f);
static readonly Color CampClearedColor = new Color(0.35f, 0.35f, 0.35f, 0.6f);
private Transform mapRoot;
private readonly Dictionary<int, MarchView> marchViews = new Dictionary<int, MarchView>();
private readonly Queue<(string title, string body)> popupQueue = new Queue<(string, string)>();
private readonly List<int> staleMarchIds = new List<int>();
private MapNode selectedTarget;
// Лагеря меняют вид: разбит -- серый, с таймером до появления
private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>();
private float nextCampRefresh;
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, "События");
if (circleNavButton != null) circleNavButton.onClick.AddListener(CircleWindow.Open); // модуль Круга
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();
RefreshCamps();
// Воины, а не отряды: "В походе: 1" читалось как один воин
int inMarches = 0, garrison = 0;
if (GameServices.TryGet(out IArmy army)) { inMarches = army.TroopsInMarches; garrison = army.GarrisonTotal; }
soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {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;
case MapNodeType.Camp:
// Круг, а не квадрат: лагерь не путается с городом
var sprite = CreateSprite(node.id, PrimitiveSprites.Circle, CampColor, pos, Vector3.one * CampSize, 0);
var label = CreateLabel("", pos - new Vector3(0, CampSize / 2f + 0.5f, 0), 2.3f);
campViews.Add((node, sprite, label));
break;
}
}
}
void SetUpCamera()
{
// Сцена собрана старой версией сборщика -- добавляем контроллер камеры сами
if (mapCamera == null)
{
Camera cam = Camera.main;
mapCamera = cam.GetComponent<MapCameraController>();
if (mapCamera == null) mapCamera = cam.gameObject.AddComponent<MapCameraController>();
}
mapCamera.mapHalfSize = new Vector2(WorldMapLayout.HalfWidth / 1000f, WorldMapLayout.HalfHeight / 1000f);
mapCamera.GetComponent<Camera>().orthographicSize = StartZoom;
CenterOnMyCity();
}
void CenterOnMyCity()
{
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
if (home != null) mapCamera.CenterOn(ToWorld(home));
}
SpriteRenderer CreateSprite(string name, Sprite sprite, Color color, Vector3 position, Vector3 scale, int order)
{
var go = new GameObject(name);
go.transform.SetParent(mapRoot, false);
go.transform.position = position;
go.transform.localScale = scale;
var sr = go.AddComponent<SpriteRenderer>();
sr.sprite = sprite;
sr.color = color;
sr.sortingOrder = order;
return sr;
}
TextMeshPro CreateLabel(string text, Vector3 position, float fontSize)
{
var go = new GameObject("Label");
go.transform.SetParent(mapRoot, false);
go.transform.position = position;
var tmp = go.AddComponent<TextMeshPro>();
tmp.text = text;
tmp.fontSize = fontSize;
tmp.alignment = TextAlignmentOptions.Center;
tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
tmp.sortingOrder = 10;
return tmp;
}
void RefreshCamps()
{
if (Time.unscaledTime < nextCampRefresh) return;
nextCampRefresh = Time.unscaledTime + 1f;
long now = MarchManager.NowMs;
foreach (var (node, sprite, label) in campViews)
{
long left = World.CampRespawnInMs(node.id, now);
sprite.color = left > 0 ? CampClearedColor : CampColor;
label.text = left > 0
? $"{node.displayName}\n<color=#BBBBBB>разбит, вернётся через {FormatMinutes(left)}</color>"
: $"{node.displayName}, ур. {node.level}\nГарнизон: {node.garrison}";
}
}
static string FormatMinutes(long ms)
{
long sec = (ms + 999) / 1000;
return sec >= 60 ? $"{(sec + 59) / 60} мин" : $"{sec} сек";
}
public static Vector3 ToWorld(MapNode node)
{
return new Vector3(node.x / 1000f, node.y / 1000f, 0f);
}
// ---------------- ВЫБОР ЦЕЛИ ----------------
void HandleMapTap()
{
if (attackPanel.activeSelf || popupPanel.activeSelf) return;
if (ratingPanel != null && ratingPanel.gameObject.activeSelf) return;
if (!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.canBeAttacked) OpenAttackPanel(node);
}
MapNode FindNodeAt(Vector3 world)
{
foreach (var node in World.Nodes)
{
// Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем
float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize : CitySize;
float halfSize = size / 2f + 0.2f;
Vector3 pos = ToWorld(node);
if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node;
}
return null;
}
// ---------------- ОКНО АТАКИ ----------------
void OpenAttackPanel(MapNode target)
{
selectedTarget = target;
int available = GetSoldiersAtHome();
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f);
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;
attackInfoText.text = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})\n" +
$"Путь в одну сторону: {travelSeconds} сек" +
(WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\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.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
? "Свободных воинов нет: все в гарнизоне (окно «Войска» в городе)"
: "Дома нет солдат";
}
// Ожидаемый исход боя: тот же расчёт, что сделает модуль Боя, с составом, который заберёт Армия
string PreviewText(int soldiers)
{
if (selectedTarget == null || soldiers <= 0) return "";
IArmy army = GameServices.Get<IArmy>();
var troops = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++) troops[i] = army.GetTroops(i) - army.GetGarrison(i); // гарнизон в поход не идёт
int[] units = ArmyMath.TakeProportional(troops, soldiers);
if (units == null) return "";
BattleOutcome o = GameServices.Get<IBattleSystem>().Resolve(new BattleRequest
{
AttackerUnits = units,
DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget),
Defenders = selectedTarget.garrison,
DefenderFortified = WorldMapLayout.IsFortified(selectedTarget)
});
string result = o.Victory ? "<color=#7CD67C>скорее победа</color>" : "<color=#E06A5A>скорее поражение</color>";
return $"сила {o.AttackerPower / 100} против {o.DefenderPower / 100} -- {result}, потери ~{o.AttackerLosses}";
}
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)
{
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}, потери врага: {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);
}
}