Files
MVP1/Assets/Scripts/WorldMap/WorldMapController.cs
T
AlexanderandClaude Opus 5.5 e8b17a7efa Скорости маршей по типам войск и слоты похода (регламент 31.10)
Мир (Bogatyri.World.Simulation):
- MarchSpeedRules: скорость по типам — пехота 8, лучники 9, кавалерия 12, осадные 4 клетки
  за реальный час (ТЕСТ); марш идёт в реальном времени
- смешанное войско — по самому медленному типу (настройка mixedUsesSlowest: средняя по числу воинов);
  правило для одной армии в регламенте не задано — вопрос команде
- WorldSimulation.TravelTimeMs, AssignUnits: время прихода пересчитывается по составу похода;
  обратный путь — по составу выживших
- MarchRules.GetTravelTimeMs — для похода без состава (как пехота)

Армия (Bogatyri.Army):
- слоты похода: IArmy.MarchSlots (база 2, ТЕСТ), ActiveMarches; TakeForMarch отказывает при занятых слотах;
  походы из старых сохранений сверх лимита дойдут, новых не будет

Таблица MarchConfig (Assets/Resources/MarchConfig.asset): скорости, режим смешанного войска, база слотов.
Карта мира: путь в окне атаки — по составу при выбранном числе воинов, «Все слоты похода заняты»,
верхняя строка «отрядов: 1 из 2».

Тесты: скорости по типам, смешанное войско, обратный путь, слоты; старые тесты мира переведены
на часовые марши. Все 49 тестов ModuleTests проходят вне Unity. Код Валдемара не менялся.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:05:55 +01:00

561 lines
26 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;
// Окно атаки: постоянная часть текста и состояние войска, с которым оно показано
private string attackInfoBase;
private BattleCondition shownConditions;
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(); 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.OnMarchReturned += HandleMarchReturned;
}
void OnDisable()
{
if (MarchManager.Instance == null) return;
MarchManager.Instance.OnBattleResolved -= HandleBattleResolved;
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 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();
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;
attackInfoBase = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})" +
(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;
UpdateSoldiersToSendText();
RefreshAttackConditions(); // путь -- по составу при выбранном числе воинов
attackPanel.SetActive(true);
}
// Голод может начаться или кончиться, пока окно атаки открыто: предупреждение и прогноз
// пересчитываются, как только состояние войска изменилось (проверка -- в Update)
void RefreshAttackConditions()
{
shownConditions = MarchManager.PlayerConditions();
attackInfoText.text = attackInfoBase + PathText() + 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>"
: "";
return $"\nПуть в одну сторону: {Duration(World.TravelTimeMs(home, selectedTarget, units))}" + 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 (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
? "Свободных воинов нет: все в гарнизоне (окно «Войска» в городе)"
: "Дома нет солдат";
}
// Ожидаемый исход боя: тот же расчёт, что сделает модуль Боя, с составом, который заберёт Армия
string PreviewText(int soldiers)
{
if (selectedTarget == null || soldiers <= 0) return "";
int[] units = PreviewUnits(soldiers); // гарнизон в поход не идёт
if (units == null) return "";
// Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026)
BattleForecast f = GameServices.Get<IBattleSystem>().Forecast(new BattleRequest
{
AttackerUnits = units,
DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget),
Defenders = selectedTarget.garrison,
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)}";
}
// 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)
{
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.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 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);
// Итог боя длинный (строка силы и удачи, добыча) -- текст ужимается, а не уходит под кнопку OK
popupBodyText.enableAutoSizing = true;
popupBodyText.fontSizeMin = 16;
popupBodyText.fontSizeMax = 30;
popupBodyText.overflowMode = TMPro.TextOverflowModes.Truncate;
popupBodyText.text = body;
popupPanel.SetActive(true);
}
}