using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using Vanguard.Api;
using Vanguard.Api.Contracts;
using Vanguard.Battle;
using Vanguard.Board3D;
using Vanguard.Meta;
using Vanguard.Shared.Protocol;
using Vanguard.UI.Overlays;
namespace Vanguard.UI
{
///
/// Серый боевой экран: доска, рука, часы и кнопки всех легальных интентов.
/// Красоту и анимации добавим поверх этой рабочей логики.
///
public sealed class BattleView
{
private static readonly Color Background = new Color(0.035f, 0.045f, 0.065f, 1f);
private static readonly Color Panel = new Color(0.07f, 0.085f, 0.115f, 1f);
private static readonly Color Card = new Color(0.10f, 0.13f, 0.18f, 1f);
private static readonly Color Muted = new Color(0.62f, 0.68f, 0.82f);
private static readonly Color Accent = new Color(0.20f, 0.42f, 0.78f, 1f);
private const float IntentPanelShift = 456f;
private readonly MetaService _meta;
private readonly Action _onExit;
private readonly AnimationConfig _animationConfig;
private readonly InputLockService _inputLock;
private TriggerCheckSequence _triggerSequence;
private string _lastInputLockSummary;
private readonly Dictionary _catalog =
new Dictionary(StringComparer.Ordinal);
private bool _catalogLoading;
private readonly RectTransform _root;
private RectTransform _intents;
private Text _topInfo;
private Text _clock;
private Text _bank;
private Text _status;
private float _statusHideAt;
private Button _concede;
private Text _concedeLabel;
private Button _reconnect;
private readonly RectTransform _result;
private RectTransform _logContent;
private Text _resultText;
private readonly List _logLines = new List();
private GameConnection _connection;
private CancellationTokenSource _cts;
private CancellationTokenSource _reconnectCts;
private bool _reconnecting;
private int _reconnectAttempt;
private RectTransform _reconnectOverlay;
private Text _reconnectOverlayText;
private Text _reconnectOverlayHint;
private RectTransform _abilityOverlay;
private Text _abilityOverlayName;
private RectTransform _abilityOverlayButtons;
private int _abilityPromptInstanceId;
private RectTransform _cardChoiceOverlay;
private Text _cardChoiceTitle;
private RectTransform _cardChoiceContent;
private string _cardChoiceKey;
private MatchConnectionInfo _info;
private MatchView _view;
private bool _connecting;
private bool _sending;
private bool _snapshotPending;
private bool _finished;
private bool _confirmConcede;
private bool _showAllIntents;
private float _confirmDeadline;
private long _clockServerMs;
private long _clockReceivedMs;
private readonly List _pendingIntents = new List();
private readonly List _discardAbilityChoices =
new List();
private readonly List _discardGuardChoices = new List();
private bool _discardChoiceActive;
private string _discardPrompt;
private Action _discardCancelAction;
private int _pendingBoosterSlot = -1;
private int _pendingBoostAttackerSlot = -2;
private readonly Dictionary _guardSelected =
new Dictionary();
private int _guardSelectedInterceptSlot = -1;
private int _guardDiscardInstanceId;
private string _guardDiscardPrintingId;
private ActivateAbilityIntent _pendingAbilityCost;
private int _abilityCostDiscardInstanceId;
private int? _triggerPowerSlot;
private readonly HashSet _mulliganSelected = new HashSet();
private bool _mulliganUsed;
private int _abilityDamageIndex = -1;
private readonly HashSet _abilityDamageIndexes = new HashSet();
private readonly HashSet _highlightSlots = new HashSet();
private GameBoard3D _board3D;
private OverlayNavigationService _overlays;
private PlayerStatsPanelView _selfZones;
private PlayerStatsPanelView _opponentZones;
private CardActionMenuView _cardMenu;
private RectTransform _intentPanel;
private Text _intentPanelToggleLabel;
private bool _intentPanelVisible;
private RectTransform _confirmationBar;
private RectTransform _phasePanel;
private RectTransform _combatStats;
private Text _combatStatsText;
private RectTransform _actionPrompt;
private Text _actionPromptTitle;
private Text _actionPromptHint;
private string _actionPromptKey;
private bool _autoFinishMulligan;
private float _autoFinishMulliganAt;
private bool _autoEndBattleArmed;
private float _autoEndBattleAt;
public BattleView(Transform parent, MetaService meta, Action onExit)
{
_meta = meta;
_onExit = onExit;
_animationConfig = AnimationConfig.LoadOrDefault();
_inputLock = new InputLockService();
_inputLock.Changed += OnInputLockChanged;
_root = UiKit.CreatePanel(parent, "BattleScreen", Background);
var rootImage = _root.GetComponent();
if (rootImage != null)
{
rootImage.color = Color.clear;
rootImage.raycastTarget = false;
}
var top = UiKit.CreatePanel(_root, "TopBar", new Color(0.02f, 0.035f, 0.055f, 0.92f));
top.anchorMin = new Vector2(0f, 1f);
top.anchorMax = new Vector2(1f, 1f);
top.pivot = new Vector2(0.5f, 1f);
top.anchoredPosition = Vector2.zero;
top.sizeDelta = new Vector2(0f, 76f);
UiKit.AddHorizontalLayout(top, 16, 12);
var matchInfo = UiKit.CreatePanel(top, "MatchInfo", Color.clear);
UiKit.AddVerticalLayout(matchInfo, 0, 0);
var infoElement = matchInfo.gameObject.AddComponent();
infoElement.preferredWidth = 460f;
infoElement.flexibleWidth = 1f;
infoElement.minWidth = 260f;
_topInfo = UiKit.CreateText(
matchInfo, "TurnPhase", "Turn 1", 19, Color.white, TextAnchor.MiddleLeft);
_topInfo.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(_topInfo, 26f);
UiKit.MakeLiteral(_topInfo);
var clockPanel = UiKit.CreatePanel(top, "ClockPanel", Color.clear);
UiKit.AddVerticalLayout(clockPanel, 0, 0);
var clockElement = clockPanel.gameObject.AddComponent();
clockElement.preferredWidth = 140f;
clockElement.minWidth = 120f;
_clock = UiKit.CreateText(
clockPanel, "Clock", "—", 21, Color.white, TextAnchor.MiddleCenter);
UiKit.SetPreferredHeight(_clock, 28f);
_bank = UiKit.CreateText(
clockPanel, "Bank", "Банк —", 12, Muted, TextAnchor.MiddleCenter);
UiKit.SetPreferredHeight(_bank, 16f);
var toggle = UiKit.CreateButton(
top,
"ToggleIntentPanel",
"Показать панель",
ToggleIntentPanel,
13);
var toggleElement = UiKit.SetPreferredHeight(toggle, 40f, 0f);
toggleElement.preferredWidth = 164f;
_intentPanelToggleLabel = toggle.GetComponentInChildren();
UiKit.MakeLiteral(_intentPanelToggleLabel);
_concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 15);
_concedeLabel = _concede.GetComponentInChildren();
UiKit.SetPreferredHeight(_concede, 40f, 0f);
var concedeElement = _concede.GetComponent();
if (concedeElement != null)
concedeElement.preferredWidth = 108f;
_reconnect = UiKit.CreateButton(
top, "Reconnect", "Переподключиться", OnReconnectClicked, 13);
UiKit.SetPreferredHeight(_reconnect, 40f, 0f);
_reconnect.gameObject.SetActive(false);
BuildActionPrompt();
// Компактная панель подтверждений: без заголовка и фона, растёт
// снизу вверх по количеству доступных кнопок.
_confirmationBar = UiKit.CreatePanel(
_root,
"ConfirmationBar",
Color.clear);
_confirmationBar.anchorMin = new Vector2(1f, 0f);
_confirmationBar.anchorMax = new Vector2(1f, 0f);
_confirmationBar.pivot = new Vector2(1f, 0f);
_confirmationBar.anchoredPosition = new Vector2(-28f, 28f);
_confirmationBar.sizeDelta = new Vector2(300f, 0f);
var barImage = _confirmationBar.GetComponent();
if (barImage != null)
barImage.raycastTarget = false;
UiKit.AddVerticalLayout(_confirmationBar, 0, 6);
var barFitter = _confirmationBar.gameObject.AddComponent();
barFitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
barFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
_intents = _confirmationBar;
_status = UiKit.CreateText(
_root, "StatusToast", string.Empty, 15, Color.white, TextAnchor.MiddleLeft);
var statusRect = _status.rectTransform;
statusRect.anchorMin = new Vector2(0f, 1f);
statusRect.anchorMax = new Vector2(0f, 1f);
statusRect.pivot = new Vector2(0f, 1f);
statusRect.anchoredPosition = new Vector2(24f, -86f);
statusRect.sizeDelta = new Vector2(720f, 44f);
_status.horizontalOverflow = HorizontalWrapMode.Wrap;
_status.raycastTarget = false;
_status.gameObject.SetActive(false);
_intentPanel = UiKit.CreatePanel(
_root, "DebugPanel", new Color(0.02f, 0.035f, 0.055f, 0.94f));
_intentPanel.anchorMin = new Vector2(1f, 0f);
_intentPanel.anchorMax = new Vector2(1f, 1f);
_intentPanel.pivot = new Vector2(1f, 0.5f);
_intentPanel.offsetMin = new Vector2(-456f, 28f);
_intentPanel.offsetMax = new Vector2(-28f, -96f);
UiKit.AddVerticalLayout(_intentPanel, 14, 8);
var actionsTitle = UiKit.CreateText(
_intentPanel, "ActionsTitle", "DEBUG", 20, Color.white, TextAnchor.MiddleLeft);
actionsTitle.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(actionsTitle, 28f);
var logTitle = UiKit.CreateText(
_intentPanel, "LogTitle", "Журнал", 16, new Color(0.72f, 0.80f, 0.94f), TextAnchor.MiddleLeft);
logTitle.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(logTitle, 22f);
var logScroll = UiKit.CreateScrollView(_intentPanel, "LogScroll", out _logContent, 3f);
var logElement = logScroll.gameObject.AddComponent();
logElement.flexibleHeight = 1f;
logElement.minHeight = 160f;
_intentPanelVisible = false;
_intentPanel.gameObject.SetActive(false);
_result = BuildResultPanel(_root);
_result.gameObject.SetActive(false);
BuildOverlaysAndZones();
BuildCombatStats();
BuildAbilityOverlay();
BuildCardChoiceOverlay();
BuildReconnectOverlay();
_cardMenu = new CardActionMenuView();
_cardMenu.Build(_root);
_board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default);
_board3D.ConfigureRuntime(_animationConfig, _inputLock);
_triggerSequence = new TriggerCheckSequence(
_board3D.CameraDirector,
_inputLock);
_triggerSequence.FocusChanged += OnTriggerFocusChanged;
_triggerSequence.ZoomCompleted += OnTriggerZoomCompleted;
_triggerSequence.ResolveReady += OnTriggerResolveReady;
_board3D.ArtProvider = ResolveBoardArt;
_board3D.CardClicked += OnBoard3DCardClicked;
_board3D.CardRightClicked += OnBoard3DCardRightClicked;
_board3D.SlotClicked += OnBoard3DSlotClicked;
_board3D.CardDropped += OnBoard3DCardDropped;
_board3D.PileClicked += OnBoard3DPileClicked;
_board3D.PileRightClicked += OnBoard3DPileRightClicked;
_board3D.SetVisible(false);
_root.gameObject.SetActive(false);
}
public void Show(MatchConnectionInfo info)
{
_info = info;
CancelReconnect();
_view = null;
_finished = false;
_triggerWasPending = false;
_lastTriggerKey = null;
_triggerZonePlayer = -1;
_triggerZoneNoTrigger = false;
_triggerZoomStartedByEvent = false;
_suppressNextDamageZoom = false;
_vanguardRevealActive = false;
_introActive = false;
_introStage = 0;
_introDeadline = 0f;
_triggerSequence?.Cancel();
_confirmConcede = false;
_concedeLabel.text = "Сдаться";
_concede.interactable = true;
_reconnect.gameObject.SetActive(false);
_result.gameObject.SetActive(false);
_pendingIntents.Clear();
HideActionPrompt();
_discardChoiceActive = false;
_discardAbilityChoices.Clear();
_discardGuardChoices.Clear();
_discardCancelAction = null;
_discardPrompt = null;
_highlightSlots.Clear();
_mulliganSelected.Clear();
_mulliganUsed = false;
_abilityDamageIndex = -1;
_abilityDamageIndexes.Clear();
_pendingBoosterSlot = -1;
_pendingBoostAttackerSlot = -2;
ClearGuardSelection();
ClearAbilityCostSelection();
_triggerPowerSlot = null;
_showAllIntents = false;
_autoFinishMulligan = false;
_autoFinishMulliganAt = 0f;
_autoEndBattleArmed = false;
_autoEndBattleAt = 0f;
_intentPanelVisible = false;
if (_intentPanel != null)
_intentPanel.gameObject.SetActive(false);
if (_intentPanelToggleLabel != null)
{
_intentPanelToggleLabel.text = "Показать панель";
UiKit.MakeLiteral(_intentPanelToggleLabel);
}
ApplyIntentPanelLayout();
_inputLock.ReleaseAll();
_logLines.Clear();
RebuildLog();
_confirmationBar?.gameObject.SetActive(false);
_combatStats?.gameObject.SetActive(false);
_board3D?.Clear();
_board3D?.SetVisible(true);
StartBattleIntro();
_overlays?.CloseAll();
HideAbilityOverlay();
HideCardChoiceOverlay();
_selfZones?.Update(null, true);
_opponentZones?.Update(null, false);
_topInfo.text = "Turn 1";
UiKit.MakeLiteral(_topInfo);
SetStatus("Подключаемся к матчу…", Muted);
_root.gameObject.SetActive(true);
_ = LoadCatalogAsync();
_ = ConnectAsync();
}
public void Hide()
{
CancelReconnect();
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
if (_connection != null)
{
var connection = _connection;
_connection = null;
DetachConnection(connection);
_ = connection.DisposeAsync().AsTask();
}
_view = null;
_triggerWasPending = false;
_lastTriggerKey = null;
_triggerZonePlayer = -1;
_triggerZoneNoTrigger = false;
_triggerZoomStartedByEvent = false;
_suppressNextDamageZoom = false;
_vanguardRevealActive = false;
_introActive = false;
_introStage = 0;
_introDeadline = 0f;
_triggerSequence?.Cancel();
HideActionPrompt();
_root.gameObject.SetActive(false);
_result.gameObject.SetActive(false);
_confirmationBar?.gameObject.SetActive(false);
_overlays?.CloseAll();
HideAbilityOverlay();
HideCardChoiceOverlay();
_cardMenu?.Hide();
_combatStats?.gameObject.SetActive(false);
_board3D?.SetVisible(false);
_inputLock.ReleaseAll();
}
public void Tick()
{
if (_confirmConcede && Time.unscaledTime > _confirmDeadline)
{
_confirmConcede = false;
_concedeLabel.text = "Сдаться";
}
if (_vanguardRevealActive
&& Time.unscaledTime >= _vanguardRevealUntil)
{
_vanguardRevealActive = false;
_inputLock.Release("vanguard-reveal");
}
TickIntro();
UpdateClock();
if (_status != null
&& _status.gameObject.activeSelf
&& Time.unscaledTime > _statusHideAt)
{
_status.gameObject.SetActive(false);
}
_triggerSequence?.Tick(_animationConfig);
RefreshActionPrompt();
SanitizeInputLocks();
SyncAbilityPrompt();
SyncCardChoicePrompt();
UpdateAutoPhases();
}
private void SanitizeInputLocks()
{
if (!_inputLock.IsLocked)
{
_lastInputLockSummary = null;
return;
}
// Locks must never outlive the UI/gameplay state that owns them.
// This also recovers from a missed close callback after reconnects,
// overlays or card menus.
if (!_reconnecting)
_inputLock.Release("reconnect");
if (_overlays == null || !_overlays.IsOpen)
_inputLock.Release("overlay");
if (_cardMenu == null || !_cardMenu.IsVisible)
_inputLock.Release("card-menu");
if (_abilityOverlay == null || !_abilityOverlay.gameObject.activeSelf)
_inputLock.Release("ability");
if (_cardChoiceOverlay == null || !_cardChoiceOverlay.gameObject.activeSelf)
_inputLock.Release("card-choice");
if (!_vanguardRevealActive)
_inputLock.Release("vanguard-reveal");
if (!_introActive)
_inputLock.Release("battle-intro");
var summary = _inputLock.Describe();
if (!_inputLock.IsLocked)
{
_lastInputLockSummary = null;
return;
}
if (!string.Equals(summary, _lastInputLockSummary, StringComparison.Ordinal))
{
_lastInputLockSummary = summary;
Debug.LogWarning("Battle input lock held: " + summary);
}
}
private void UpdateAutoPhases()
{
if (_view == null || _sending || _reconnecting)
return;
var legal = _view.LegalIntents ?? new List();
if (_autoFinishMulligan)
{
if (_view.Phase != GamePhase.Mulligan)
{
_autoFinishMulligan = false;
_autoFinishMulliganAt = 0f;
}
else if (_view.ActivePlayer == _view.You)
{
var finish = legal.OfType().FirstOrDefault();
if (finish != null)
{
if (_autoFinishMulliganAt <= 0f)
{
_autoFinishMulliganAt = Time.unscaledTime + 0.6f;
}
else if (Time.unscaledTime >= _autoFinishMulliganAt
&& (_board3D == null
|| _board3D.MoveAnimator == null
|| !_board3D.MoveAnimator.IsBusy)
&& (_board3D == null
|| _board3D.ReturningCardCount == 0)
&& (_board3D == null || !_board3D.IsHandDrawActive))
{
_autoFinishMulligan = false;
_autoFinishMulliganAt = 0f;
// After an exchange the mulligan must finish by itself;
// other locks must not block that transition.
OnIntentClicked(finish, ignoreInputLock: true);
}
}
}
}
if (_inputLock.IsLocked)
return;
var battle = _view.Phase == GamePhase.Battle
&& _view.ActivePlayer == _view.You
&& !_view.IsFinished;
var actionable = legal.Any(intent =>
intent is AttackIntent
or ActivateAbilityIntent
or ResolveAbilityIntent
or ResolveTriggerIntent
or SelectTriggerIntent
or ChooseIntent);
var endTurn = legal.OfType().FirstOrDefault();
if (battle && !actionable && endTurn != null && _pendingIntents.Count == 0)
{
if (!_autoEndBattleArmed)
{
_autoEndBattleArmed = true;
_autoEndBattleAt = Time.unscaledTime + 1.0f;
}
else if (Time.unscaledTime >= _autoEndBattleAt)
{
_autoEndBattleArmed = false;
OnIntentClicked(endTurn);
}
}
else
{
_autoEndBattleArmed = false;
}
}
private void OnInputLockChanged(bool locked)
{
if (_view == null)
return;
RebuildIntents(_view);
}
private void OnOverlayOpenChanged(bool open)
{
if (open)
_inputLock.Acquire("overlay");
else
_inputLock.Release("overlay");
}
private RectTransform BuildResultPanel(Transform parent)
{
var overlay = UiKit.CreatePanel(parent, "BattleResult", new Color(0f, 0f, 0f, 0.75f));
var card = UiKit.CreateCenteredCard(
overlay, "ResultCard", new Vector2(640f, 330f),
new Color(0.08f, 0.10f, 0.14f, 1f));
UiKit.AddVerticalLayout(card, 32, 14);
var title = UiKit.CreateText(card, "Title", "Матч завершён", 32, Color.white);
title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(title, 48f);
_resultText = UiKit.CreateText(card, "Text", string.Empty, 22, Muted);
UiKit.SetPreferredHeight(_resultText, 90f);
var exit = UiKit.CreateButton(card, "Exit", "В меню", OnExitClicked, 21);
UiKit.SetPreferredHeight(exit, 52f);
return overlay;
}
private async Task LoadCatalogAsync()
{
if (_catalogLoading || _catalog.Count > 0)
return;
_catalogLoading = true;
try
{
var items = await _meta.GetCatalogAsync();
foreach (var item in items)
_catalog[item.PrintingId] = item;
if (_root.gameObject.activeSelf && _view != null)
Render(animateMoves: false);
}
catch (Exception exception)
{
Debug.LogWarning("Battle catalog load failed: " + exception.Message);
}
finally
{
_catalogLoading = false;
}
}
private async Task ConnectAsync(CancellationToken cancellationToken = default)
{
if (_connecting || _info == null)
return false;
_connecting = true;
_reconnect.gameObject.SetActive(false);
_cts?.Cancel();
_cts?.Dispose();
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
try
{
if (_connection != null)
{
var previous = _connection;
_connection = null;
DetachConnection(previous);
await previous.DisposeAsync();
}
var connection = new GameConnection();
_connection = connection;
connection.HelloReceived += OnHello;
connection.SnapshotReceived += OnSnapshot;
connection.EventsReceived += OnEvents;
connection.ClockReceived += OnClock;
connection.ErrorReceived += OnError;
connection.Closed += OnClosed;
await connection.ConnectAsync(
_info.WsUrl,
_info.Ticket,
Application.version,
_view != null ? _view.EventSeq : 0,
_cts.Token);
SetStatus("Соединение открыто. Ожидаем состояние…", Muted);
return true;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception exception)
{
Debug.LogException(exception);
SetStatus("Не удалось подключиться: " + exception.Message, new Color(1f, 0.55f, 0.45f));
_reconnect.gameObject.SetActive(false);
if (!_reconnecting)
BeginReconnect("Не удалось подключиться: " + exception.Message);
return false;
}
finally
{
_connecting = false;
}
}
private void DetachConnection(GameConnection connection)
{
if (connection == null)
return;
connection.HelloReceived -= OnHello;
connection.SnapshotReceived -= OnSnapshot;
connection.EventsReceived -= OnEvents;
connection.ClockReceived -= OnClock;
connection.ErrorReceived -= OnError;
connection.Closed -= OnClosed;
}
private void OnHello(ServerHello hello)
{
if (_info != null)
_info.Seat = hello.PlayerIndex;
if (_reconnecting)
FinishReconnect();
}
private void OnSnapshot(ServerSnapshot snapshot)
{
if (!_root.gameObject.activeSelf || snapshot?.View == null)
return;
if (_reconnecting)
FinishReconnect();
_view = snapshot.View;
_finished = _view.IsFinished;
_pendingIntents.Clear();
if (_mulliganUsed
&& _view.Phase == GamePhase.Mulligan
&& _view.ActivePlayer == _view.You)
{
// Recovery path: if the automatic finish was interrupted by a
// reconnect, re-arm it from the authoritative snapshot.
_autoFinishMulligan = true;
_autoFinishMulliganAt = 0f;
}
FinishDiscardSelection();
PruneGuardSelection();
PruneAbilityCostSelection();
_triggerPowerSlot = null;
_triggerSelectionReady = false;
if (_triggerSequence == null || !_triggerSequence.IsBusy)
PrepareTriggerSelection();
if (_view.Clock != null)
{
_clockServerMs = _view.Clock.ServerTimeMs;
_clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
Render();
SyncTriggerSequence();
SyncAbilityPrompt();
SyncCardChoicePrompt();
}
private bool _triggerWasPending;
private string _lastTriggerKey;
private int _triggerZonePlayer = -1;
private bool _triggerZoneNoTrigger;
private bool _triggerZoomStartedByEvent;
private bool _triggerSelectionReady;
private bool _suppressNextDamageZoom;
private bool _triggerCameraFocused;
private bool _vanguardRevealActive;
private float _vanguardRevealUntil;
private bool _introActive;
private int _introStage;
private float _introDeadline;
private void OnTriggerFocusChanged(bool focused)
{
_triggerCameraFocused = focused;
_board3D?.SetHandVisible(!focused);
if (focused)
return;
if (_triggerZonePlayer >= 0)
{
_board3D?.SetTriggerZoneMessage(_triggerZonePlayer, string.Empty);
_triggerZonePlayer = -1;
}
_triggerZoneNoTrigger = false;
_triggerZoomStartedByEvent = false;
}
private void OnTriggerZoomCompleted()
{
if (_triggerZonePlayer < 0 || _board3D == null)
return;
_board3D.SetTriggerZoneVisible(_triggerZonePlayer, true);
_board3D.SetTriggerZoneMessage(
_triggerZonePlayer,
_triggerZoneNoTrigger ? "БЕЗ ТРИГГЕРА" : string.Empty);
if (!_triggerZoneNoTrigger)
{
_board3D.RevealTriggerCard(
_triggerZonePlayer,
_animationConfig != null
? _animationConfig.triggerRevealDuration
: 0.35f);
}
}
private void OnTriggerResolveReady()
{
if (_triggerZoneNoTrigger
|| _triggerZonePlayer < 0
|| _view?.Trigger == null
|| _view.Trigger.Player != _triggerZonePlayer
|| _view.ActivePlayer != _view.You)
{
return;
}
PrepareTriggerSelection();
if (_triggerSelectionReady)
{
RebuildIntents(_view);
RefreshActionPrompt();
}
}
private void StartBattleIntro()
{
if (_board3D == null || _animationConfig == null)
return;
_introActive = true;
_introStage = 0;
_introDeadline = Time.unscaledTime + _animationConfig.introOverheadHold;
_board3D.SetHandVisible(false);
_board3D.SetOpponentHandVisible(false);
_board3D.SetCameraIntro();
_inputLock.Acquire("battle-intro");
}
private void TickIntro()
{
if (!_introActive || _board3D == null || _animationConfig == null)
return;
var now = Time.unscaledTime;
if (_introStage == 0 && now >= _introDeadline)
{
_introStage = 1;
_board3D.MoveCameraToMain(_animationConfig.introCameraMoveDuration);
_introDeadline = now + _animationConfig.introCameraMoveDuration;
return;
}
if (_introStage == 1 && now >= _introDeadline)
{
_introStage = 2;
_introDeadline = now + _animationConfig.introMainHold;
return;
}
if (_introStage == 2 && now >= _introDeadline)
{
if (_view == null)
{
_introDeadline = now + 0.2f;
return;
}
_introStage = 3;
_board3D.AnimateHandDraw(_animationConfig.introDrawDuration);
_introDeadline = now + 0.1f;
return;
}
if (_introStage == 3
&& now >= _introDeadline
&& !_board3D.IsHandDrawActive
&& (_board3D.MoveAnimator == null
|| !_board3D.MoveAnimator.IsBusy))
{
_introActive = false;
_inputLock.Release("battle-intro");
if (_view != null)
{
RebuildIntents(_view);
RefreshActionPrompt();
}
}
}
private void BeginVanguardReveal()
{
if (_vanguardRevealActive)
return;
_vanguardRevealActive = true;
_vanguardRevealUntil = Time.unscaledTime
+ (_animationConfig != null
? _animationConfig.vanguardRevealDuration
: 0.8f)
+ 0.1f;
_inputLock.Acquire("vanguard-reveal");
}
private void BeginTriggerZoneZoom(int player, bool hasTrigger, bool fromEvent)
{
if (_triggerSequence == null || _board3D == null)
return;
if (_triggerSequence.IsBusy && _triggerZonePlayer == player)
{
if (hasTrigger && _triggerZoneNoTrigger)
{
_triggerZoneNoTrigger = false;
_triggerWasPending = true;
}
return;
}
_triggerZonePlayer = player;
_triggerZoneNoTrigger = !hasTrigger;
_triggerWasPending = hasTrigger;
_triggerSelectionReady = false;
_lastTriggerKey = null;
_triggerZoomStartedByEvent = fromEvent;
_board3D.SetTriggerZoneVisible(player, false);
_board3D.SetTriggerZoneMessage(player, string.Empty);
_triggerSequence.ShowTrigger(
_animationConfig,
_board3D.TriggerCameraPoint(player));
}
private void SyncTriggerSequence()
{
if (_triggerSequence == null || _view == null)
return;
var trigger = _view.Trigger;
if (trigger != null)
{
var key = trigger.Player + ":" + trigger.PrintingId
+ ":" + _view.EventSeq;
if (_triggerZoomStartedByEvent)
{
_triggerZoomStartedByEvent = false;
_triggerWasPending = true;
_lastTriggerKey = key;
return;
}
if (_triggerWasPending
&& string.Equals(_lastTriggerKey, key, StringComparison.Ordinal))
{
return;
}
_triggerWasPending = true;
_lastTriggerKey = key;
BeginTriggerZoneZoom(trigger.Player, hasTrigger: true, fromEvent: false);
return;
}
if (_triggerZoomStartedByEvent)
_triggerZoomStartedByEvent = false;
if (_triggerWasPending)
{
_triggerWasPending = false;
_lastTriggerKey = null;
_triggerSequence.EndChain(_animationConfig);
}
}
private void OnEvents(ServerEvents events)
{
if (events?.Events != null)
{
var batchHasDriveCheck = events.Events
.Any(envelope => envelope.Event is DriveCheckedView);
var damageZoomStarted = false;
foreach (var envelope in events.Events)
{
if (envelope.Event is GameFinishedView)
_finished = true;
if (envelope.Event is MulliganView mulligan)
{
_board3D?.QueueHandReturnToDeck(
mulligan.Player,
mulligan.Count);
}
else if (envelope.Event is CardReturnedToDeckView returned)
{
_board3D?.QueueHandReturnToDeck(
returned.Player,
1,
returned.PrintingId);
}
if (_view?.Phase == GamePhase.Mulligan
&& envelope.Event is PhaseChangedView phase
&& phase.Phase != GamePhase.Mulligan)
{
BeginVanguardReveal();
}
if (envelope.Event is DriveCheckedView drive)
{
BeginTriggerZoneZoom(
drive.Player,
drive.Trigger != TriggerKind.None,
fromEvent: true);
}
else if (envelope.Event is TriggerResolvedView trigger
&& !_triggerWasPending)
{
BeginTriggerZoneZoom(trigger.Player, hasTrigger: true, fromEvent: true);
if (!batchHasDriveCheck)
_suppressNextDamageZoom = true;
}
else if (envelope.Event is DamageTakenView damage
&& !_suppressNextDamageZoom
&& !damageZoomStarted)
{
BeginTriggerZoneZoom(
damage.Player,
hasTrigger: false,
fromEvent: true);
damageZoomStarted = true;
}
else if (envelope.Event is DamageTakenView)
{
_suppressNextDamageZoom = false;
}
var line = Describe(envelope.Event);
if (!string.IsNullOrWhiteSpace(line))
_logLines.Add(line);
}
while (_logLines.Count > 40)
_logLines.RemoveAt(0);
RebuildLog();
}
_ = RequestSnapshotAsync();
}
private void RebuildLog()
{
if (_logContent == null)
return;
ClearChildren(_logContent);
var start = Mathf.Max(0, _logLines.Count - 8);
for (var i = start; i < _logLines.Count; i++)
{
var line = UiKit.CreateText(
_logContent, "LogLine", _logLines[i], 13,
i == _logLines.Count - 1
? new Color(0.92f, 0.94f, 1f)
: new Color(0.60f, 0.68f, 0.82f),
TextAnchor.MiddleLeft);
line.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(line, 30f);
}
}
private string Describe(EventView view)
{
if (view == null)
return null;
return view switch
{
UnitRodeView ride => "Ride: " + CardName(ride.PrintingId),
UnitCalledView call => "Call: " + CardName(call.PrintingId) + " > " + SlotName(call.Slot),
UnitMovedView move => "Move: " + SlotName(move.FromSlot) + " > " + SlotName(move.ToSlot),
AttackDeclaredView attack => "Атака: " + SlotName(attack.AttackerSlot)
+ " > " + SlotName(attack.TargetSlot),
GuardAddedView guard => "Guard: " + CardName(guard.PrintingId)
+ " (+" + guard.Shield + ")",
PerfectGuardView perfect => "Perfect Guard: " + CardName(perfect.SentinelPrintingId),
DriveCheckedView drive => "Drive: " + CardName(drive.PrintingId)
+ " · " + drive.Trigger,
TriggerResolvedView trigger => "Триггер: " + CardName(trigger.PrintingId),
BattleResolvedView battle => battle.Hit ? "Атака прошла." : "Атака отбита.",
DamageTakenView damage => "Урон: " + PlayerName(damage.Player)
+ " +" + damage.Amount + " (всего " + damage.Total + ")",
HealedView heal => "Heal: " + PlayerName(heal.Player),
UnitRetiredView retire => "Retire: " + SlotName(retire.Slot),
InterceptedView intercept => "Intercept: " + CardName(intercept.PrintingId),
CardDrawnView draw => "Карта: " + PlayerName(draw.Player),
PhaseChangedView phase => "Фаза: " + phase.Phase,
GameFinishedView finish => "Партия завершена.",
_ => null,
};
}
private string PlayerName(int player) =>
_view == null ? "P" + player : player == _view.You ? "Вы" : "Противник";
private void OnClock(ServerClock clock)
{
if (!_root.gameObject.activeSelf || clock?.Clock == null)
return;
_clockServerMs = clock.Clock.ServerTimeMs;
_clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (_view != null)
_view.Clock = clock.Clock;
UpdateClock();
}
private void OnError(ServerError error)
{
SetStatus(error.Code + ": " + error.Message, new Color(1f, 0.55f, 0.45f));
}
private void OnClosed(string reason)
{
if (!_root.gameObject.activeSelf || _finished || _view?.IsFinished == true)
return;
BeginReconnect(reason);
}
private void BeginReconnect(string reason)
{
if (_info == null
|| !_root.gameObject.activeSelf
|| _finished
|| _view?.IsFinished == true)
return;
if (_reconnecting)
{
SetReconnectHint("Соединение потеряно. Переподключаемся…");
SetReconnectReason(reason);
return;
}
_reconnecting = true;
_reconnectAttempt = 0;
_reconnectOverlay?.SetAsLastSibling();
_reconnectOverlay?.gameObject.SetActive(true);
SetReconnectHint("Переподключаемся…");
SetReconnectReason(reason);
_reconnect.gameObject.SetActive(false);
_inputLock.Acquire("reconnect");
_reconnectCts?.Cancel();
_reconnectCts?.Dispose();
_reconnectCts = new CancellationTokenSource();
_ = ReconnectLoopAsync(_reconnectCts.Token);
}
private async Task ReconnectLoopAsync(CancellationToken token)
{
var delays = new[] { 0f, 2f, 10f, 30f };
var attempt = 0;
while (!token.IsCancellationRequested
&& _root.gameObject.activeSelf
&& !_finished)
{
var delay = attempt < delays.Length ? delays[attempt] : 30f;
if (delay > 0f)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(delay), token);
}
catch (OperationCanceledException)
{
return;
}
}
if (token.IsCancellationRequested)
return;
attempt++;
_reconnectAttempt = attempt;
SetReconnectHint("Переподключение, попытка " + attempt + "…");
try
{
var ticket = await _meta.CreateTicketAsync(_info.MatchId);
_info.Ticket = ticket.Ticket;
_info.WsUrl = ticket.WsUrl;
if (await ConnectAsync(token))
{
SetReconnectHint("Соединение восстановлено. Загружаем поле…");
return;
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception exception)
{
Debug.LogException(exception);
SetReconnectHint("Попытка " + attempt + " не удалась. Повторяем…");
}
}
}
private void FinishReconnect()
{
if (!_reconnecting)
return;
_reconnecting = false;
_reconnectAttempt = 0;
if (_reconnectOverlay != null)
_reconnectOverlay.gameObject.SetActive(false);
_inputLock.Release("reconnect");
SetStatus("Соединение восстановлено.", Muted);
}
private void SetReconnectHint(string text)
{
if (_reconnectOverlayHint != null)
_reconnectOverlayHint.text = text ?? string.Empty;
}
private void SetReconnectReason(string reason)
{
if (_reconnectOverlayText == null)
return;
var value = reason;
const string prefix = "Соединение потеряно: ";
while (!string.IsNullOrEmpty(value)
&& value.StartsWith(prefix, StringComparison.Ordinal))
{
value = value.Substring(prefix.Length);
}
_reconnectOverlayText.text = string.IsNullOrWhiteSpace(value)
? "Соединение с сервером прервано. Переподключаемся автоматически."
: value;
}
private void CancelReconnect()
{
_reconnecting = false;
_reconnectAttempt = 0;
_reconnectCts?.Cancel();
_reconnectCts?.Dispose();
_reconnectCts = null;
if (_reconnectOverlay != null)
_reconnectOverlay.gameObject.SetActive(false);
_inputLock.Release("reconnect");
}
private async Task RequestSnapshotAsync()
{
if (_snapshotPending || _connection == null || !_connection.IsConnected)
return;
_snapshotPending = true;
try
{
await _connection.RequestSnapshotAsync(_cts != null ? _cts.Token : CancellationToken.None);
}
catch (OperationCanceledException)
{
// экран закрыли
}
catch (Exception exception)
{
Debug.LogException(exception);
SetStatus("Не удалось обновить состояние.", new Color(1f, 0.60f, 0.45f));
}
finally
{
_snapshotPending = false;
}
}
private void OnIntentClicked(IntentPayload intent) =>
OnIntentClicked(intent, ignoreInputLock: false);
private async void OnIntentClicked(IntentPayload intent, bool ignoreInputLock)
{
if (_sending || _connection == null || _cts == null || _view == null)
return;
if (!ignoreInputLock && _inputLock.IsLocked && intent is not ConcedeIntent)
return;
if (_discardChoiceActive)
return;
if (_pendingAbilityCost != null
&& intent is not ActivateAbilityIntent
&& intent is not ConcedeIntent)
{
SetStatus(
"Сначала подтвердите оплату способности.",
new Color(1f, 0.70f, 0.35f));
return;
}
if (_abilityDamageIndex >= 0)
{
if (intent is ActivateAbilityIntent activate)
activate.DamageIndex = _abilityDamageIndex;
else if (intent is ResolveAbilityIntent resolve)
resolve.DamageIndex = _abilityDamageIndex;
}
_abilityDamageIndex = -1;
_abilityDamageIndexes.Clear();
_pendingIntents.Clear();
_highlightSlots.Clear();
_pendingBoosterSlot = -1;
_pendingBoostAttackerSlot = -2;
_triggerPowerSlot = null;
if (intent is FinishMulliganIntent or SkipRideIntent
or EnterBattleIntent or EndTurnIntent)
{
_autoFinishMulligan = false;
_autoFinishMulliganAt = 0f;
_autoEndBattleArmed = false;
}
_sending = true;
SetStatus("Отправляем: " + Label(intent), Muted);
try
{
var result = await _connection.SubmitAsync(intent, _cts.Token);
if (!result.Accepted)
{
_board3D?.ReturnDroppedCard();
if (intent is MulliganIntent || intent is FinishMulliganIntent)
{
_mulliganUsed = false;
_autoFinishMulligan = false;
_autoFinishMulliganAt = 0f;
}
SetStatus(
"Отклонено (" + result.Code + "): " + (result.Reason ?? "без причины"),
new Color(1f, 0.55f, 0.45f));
if (_view != null)
Render(animateMoves: false);
}
else
{
if (intent is MulliganIntent)
{
// The exchange itself completes the mulligan; finish it
// automatically even if an overlay lock is active.
_mulliganUsed = true;
_autoFinishMulligan = true;
_autoFinishMulliganAt = Time.unscaledTime + 0.3f;
if (_view != null)
RebuildIntents(_view);
}
SetStatus(result.Duplicate ? "Повтор уже применён." : "Принято.", Muted);
await RequestSnapshotAsync();
}
}
catch (OperationCanceledException)
{
// экран закрыли
}
catch (Exception exception)
{
Debug.LogException(exception);
_board3D?.ReturnDroppedCard();
SetStatus("Ошибка отправки интента: " + exception.Message, new Color(1f, 0.55f, 0.45f));
}
finally
{
_sending = false;
}
}
private void ToggleIntentPanel()
{
_intentPanelVisible = !_intentPanelVisible;
if (_intentPanel != null)
_intentPanel.gameObject.SetActive(_intentPanelVisible);
if (_intentPanelToggleLabel != null)
{
_intentPanelToggleLabel.text = _intentPanelVisible
? "Показать панель"
: "Скрыть панель";
UiKit.MakeLiteral(_intentPanelToggleLabel);
}
ApplyIntentPanelLayout();
}
private void BuildAbilityOverlay()
{
_abilityOverlay = UiKit.CreatePanel(
_root,
"AbilityOverlay",
new Color(0f, 0f, 0f, 0.72f));
var card = UiKit.CreateCenteredCard(
_abilityOverlay,
"AbilityCard",
new Vector2(640f, 300f),
Panel);
UiKit.AddVerticalLayout(card, 22, 10);
var title = UiKit.CreateText(
card,
"AbilityTitle",
"Способность",
22,
new Color(1f, 0.78f, 0.42f),
TextAnchor.MiddleCenter);
title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(title, 34f);
_abilityOverlayName = UiKit.CreateText(
card,
"AbilityName",
string.Empty,
26,
Color.white,
TextAnchor.MiddleCenter);
_abilityOverlayName.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(_abilityOverlayName, 46f);
var hint = UiKit.CreateText(
card,
"AbilityHint",
"Активировать способность?",
17,
new Color(0.78f, 0.84f, 0.96f),
TextAnchor.MiddleCenter);
UiKit.SetPreferredHeight(hint, 32f);
_abilityOverlayButtons = UiKit.CreatePanel(
card, "AbilityButtons", Color.clear);
UiKit.AddHorizontalLayout(_abilityOverlayButtons, 0, 10);
UiKit.SetPreferredHeight(_abilityOverlayButtons, 48f);
_abilityOverlay.gameObject.SetActive(false);
}
private void SyncAbilityPrompt()
{
if (_abilityOverlay == null || _sending)
return;
var ability = _view?.Ability;
if (ability == null
|| _view == null
|| _view.ActivePlayer != _view.You)
{
HideAbilityOverlay();
return;
}
if (_abilityOverlay.gameObject.activeSelf
&& _abilityPromptInstanceId == ability.InstanceId)
return;
var intents = (_view.LegalIntents ?? new List())
.OfType()
.ToList();
var accept = intents.FirstOrDefault(intent => intent.Accept);
var skip = intents.FirstOrDefault(intent => !intent.Accept);
if (accept == null && skip == null)
return;
_abilityPromptInstanceId = ability.InstanceId;
_abilityOverlayName.text = CardName(ability.PrintingId);
UiKit.MakeLiteral(_abilityOverlayName);
ClearChildren(_abilityOverlayButtons);
if (accept != null)
{
AddAbilityOverlayButton(
"Активировать",
accept,
new Color(0.20f, 0.42f, 0.78f));
}
if (skip != null)
{
AddAbilityOverlayButton(
"Пропустить",
skip,
new Color(0.24f, 0.28f, 0.36f));
}
_abilityOverlay.SetAsLastSibling();
_abilityOverlay.gameObject.SetActive(true);
_inputLock.Acquire("ability");
}
private void AddAbilityOverlayButton(
string label,
ResolveAbilityIntent intent,
Color color)
{
var captured = intent;
var button = UiKit.CreateButton(
_abilityOverlayButtons,
"Ability_" + label,
label,
() =>
{
HideAbilityOverlay();
OnIntentClicked(captured, ignoreInputLock: true);
},
18);
button.image.color = color;
UiKit.SetPreferredHeight(button, 46f);
}
private void HideAbilityOverlay()
{
if (_abilityOverlay == null)
return;
_abilityPromptInstanceId = 0;
_abilityOverlay.gameObject.SetActive(false);
_inputLock.Release("ability");
}
private bool IsRearGuardChoice()
{
var prompt = _view?.Choice?.Prompt;
return !string.IsNullOrEmpty(prompt)
&& (prompt.IndexOf("круг", StringComparison.OrdinalIgnoreCase) >= 0
|| prompt.IndexOf("rear", StringComparison.OrdinalIgnoreCase) >= 0);
}
private void BuildCardChoiceOverlay()
{
_cardChoiceOverlay = UiKit.CreatePanel(
_root,
"CardChoiceOverlay",
new Color(0f, 0f, 0f, 0.78f));
var card = UiKit.CreateCenteredCard(
_cardChoiceOverlay,
"CardChoiceCard",
new Vector2(1120f, 620f),
Panel);
UiKit.AddVerticalLayout(card, 20, 10);
var title = UiKit.CreateText(
card,
"CardChoiceTitle",
"ВЫБОР КАРТЫ",
22,
new Color(1f, 0.80f, 0.42f),
TextAnchor.MiddleCenter);
title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(title, 34f);
_cardChoiceTitle = UiKit.CreateText(
card,
"CardChoicePrompt",
string.Empty,
17,
new Color(0.82f, 0.88f, 0.98f),
TextAnchor.MiddleCenter);
UiKit.SetPreferredHeight(_cardChoiceTitle, 42f);
var scroll = UiKit.CreateGridScrollView(
card,
"CardChoiceScroll",
new Vector2(160f, 230f),
new Vector2(12f, 12f),
out _cardChoiceContent);
var element = scroll.gameObject.AddComponent();
element.minHeight = 400f;
element.flexibleHeight = 1f;
_cardChoiceOverlay.gameObject.SetActive(false);
}
private static bool TryGetChoicePrintingId(
ChoiceOptionView option,
out string printingId)
{
printingId = null;
var label = option?.Label ?? string.Empty;
var open = label.LastIndexOf('(');
var close = label.LastIndexOf(')');
if (open < 0 || close <= open + 1)
return false;
printingId = label.Substring(open + 1, close - open - 1).Trim();
return printingId.Length > 0;
}
private bool IsCardChoice()
{
var options = _view?.Choice?.Options;
return options is { Count: > 0 }
&& options.All(option => TryGetChoicePrintingId(option, out _));
}
private bool IsHandChoice()
{
var choice = _view?.Choice;
if (choice == null
|| _view == null
|| _view.ActivePlayer != _view.You
|| choice.Options == null
|| choice.Options.Count == 0)
return false;
var prompt = choice.Prompt ?? string.Empty;
if (prompt.IndexOf("рук", StringComparison.OrdinalIgnoreCase) < 0
&& prompt.IndexOf("сброс", StringComparison.OrdinalIgnoreCase) < 0)
return false;
var hand = _view.Players
.FirstOrDefault(player => player.Index == _view.You)
?.Hand;
return hand != null
&& choice.Options.All(option => option.Slot >= 0 && option.Slot < hand.Count);
}
private void HandleHandChoiceCard(BoardCardRef card)
{
if (_view?.Choice == null || card.Zone != BoardZone.Hand || card.Player != _view.You)
{
SetStatus("Выберите карту в руке.", new Color(0.98f, 0.82f, 0.42f));
return;
}
var handIndex = HandIndexOf(card.InstanceId);
var optionIndex = _view.Choice.Options
.ToList()
.FindIndex(option => option.Slot == handIndex);
if (optionIndex < 0)
{
SetStatus("Эта карта недоступна для выбора.", new Color(1f, 0.55f, 0.45f));
return;
}
OnIntentClicked(
new ChooseIntent { Index = optionIndex },
ignoreInputLock: true);
}
private void SyncCardChoicePrompt()
{
if (_cardChoiceOverlay == null || _sending)
return;
var choice = _view?.Choice;
if (choice == null
|| _view == null
|| _view.ActivePlayer != _view.You
|| !IsCardChoice())
{
HideCardChoiceOverlay();
return;
}
var key = (choice.Prompt ?? string.Empty) + "|" +
string.Join(",", choice.Options.Select(option => option.Label));
if (_cardChoiceOverlay.gameObject.activeSelf
&& string.Equals(_cardChoiceKey, key, StringComparison.Ordinal))
return;
_cardChoiceKey = key;
_cardChoiceTitle.text = choice.Prompt ?? "Выберите карту";
UiKit.MakeLiteral(_cardChoiceTitle);
ClearChildren(_cardChoiceContent);
for (var i = 0; i < choice.Options.Count; i++)
{
var index = i;
var option = choice.Options[i];
if (!TryGetChoicePrintingId(option, out var printingId))
continue;
var tile = UiKit.CreatePanel(
_cardChoiceContent,
"CardChoice_" + index,
new Color(0.05f, 0.075f, 0.11f, 0.98f));
var tileElement = tile.gameObject.AddComponent();
tileElement.preferredWidth = 160f;
tileElement.preferredHeight = 230f;
tileElement.flexibleWidth = 0f;
tileElement.flexibleHeight = 0f;
var button = tile.gameObject.AddComponent