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