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 bool _feedbackLockHeld;
private string _lastInputLockSummary;
private readonly Dictionary _catalog =
new Dictionary(StringComparer.Ordinal);
private bool _catalogLoading;
private readonly RectTransform _root;
private RectTransform _intents;
private readonly Text _topInfo;
private readonly Text _clock;
private readonly Text _status;
private readonly Button _concede;
private readonly Text _concedeLabel;
private readonly Button _reconnect;
private readonly RectTransform _result;
private RectTransform _logContent;
private Text _resultText;
private Text _feedback;
private Image _flash;
private float _feedbackTime;
private float _feedbackDuration;
private Color _feedbackColor;
private readonly Queue _feedbackQueue = new Queue();
private Button _skipFeedback;
private bool _feedbackPlaying;
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 ZoneBlockStripView _selfZones;
private ZoneBlockStripView _opponentZones;
private CardActionMenuView _cardMenu;
private RectTransform _intentPanel;
private Text _intentPanelToggleLabel;
private bool _intentPanelVisible = true;
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", Panel);
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, 88f);
UiKit.AddHorizontalLayout(top, 20, 14);
var title = UiKit.CreateText(top, "Title", "Бой", 32, Color.white, TextAnchor.MiddleLeft);
title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(title, 46f, 0f);
_topInfo = UiKit.CreateText(
top, "Match", string.Empty, 17, Muted, TextAnchor.MiddleLeft);
UiKit.MakeLiteral(_topInfo);
UiKit.SetPreferredHeight(_topInfo, 38f);
_clock = UiKit.CreateText(
top, "Clock", "—", 22, Color.white, TextAnchor.MiddleRight);
UiKit.SetPreferredHeight(_clock, 44f, 0f);
var toggle = UiKit.CreateButton(
top,
"ToggleIntentPanel",
"Скрыть панель",
ToggleIntentPanel,
16);
var toggleElement = UiKit.SetPreferredHeight(toggle, 44f, 0f);
toggleElement.preferredWidth = 168f;
_intentPanelToggleLabel = toggle.GetComponentInChildren();
UiKit.MakeLiteral(_intentPanelToggleLabel);
_concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 18);
_concedeLabel = _concede.GetComponentInChildren();
UiKit.SetPreferredHeight(_concede, 44f, 0f);
BuildActionPrompt();
var right = UiKit.CreatePanel(_root, "IntentPanel", Panel);
_intentPanel = right;
right.anchorMin = new Vector2(1f, 0f);
right.anchorMax = new Vector2(1f, 1f);
right.pivot = new Vector2(1f, 0.5f);
right.offsetMin = new Vector2(-456f, 28f);
right.offsetMax = new Vector2(-36f, -108f);
UiKit.AddVerticalLayout(right, 14, 10);
var actionsTitle = UiKit.CreateText(
right, "ActionsTitle", "Действия", 24, Color.white, TextAnchor.MiddleLeft);
actionsTitle.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(actionsTitle, 32f);
var scroll = UiKit.CreateScrollView(right, "IntentScroll", out _intents, 6f);
var scrollElement = scroll.gameObject.AddComponent();
scrollElement.flexibleHeight = 1f;
scrollElement.minHeight = 200f;
_status = UiKit.CreateText(
right, "Status", string.Empty, 16, Muted, TextAnchor.UpperLeft);
_status.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(_status, 74f);
_skipFeedback = UiKit.CreateButton(
right, "SkipFeedback", "Пропустить анимации", SkipFeedback, 15);
UiKit.SetPreferredHeight(_skipFeedback, 38f);
_skipFeedback.gameObject.SetActive(false);
var logTitle = UiKit.CreateText(
right, "LogTitle", "Журнал", 16, new Color(0.72f, 0.80f, 0.94f), TextAnchor.MiddleLeft);
logTitle.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(logTitle, 22f);
var logScroll = UiKit.CreateScrollView(right, "LogScroll", out _logContent, 3f);
var logElement = logScroll.gameObject.AddComponent();
logElement.preferredHeight = 142f;
logElement.minHeight = 80f;
_reconnect = UiKit.CreateButton(right, "Reconnect", "Переподключиться", OnReconnectClicked, 18);
UiKit.SetPreferredHeight(_reconnect, 46f);
_reconnect.gameObject.SetActive(false);
_result = BuildResultPanel(_root);
_result.gameObject.SetActive(false);
_flash = UiKit.CreatePanel(_root, "BattleFlash", Color.clear).GetComponent();
_flash.raycastTarget = false;
_flash.gameObject.SetActive(false);
_feedback = UiKit.CreateText(
_root, "BattleFeedback", string.Empty, 44,
Color.white, TextAnchor.MiddleCenter);
_feedback.fontStyle = FontStyle.Bold;
_feedback.raycastTarget = false;
var feedbackRect = _feedback.rectTransform;
feedbackRect.anchorMin = new Vector2(0.5f, 0.5f);
feedbackRect.anchorMax = new Vector2(0.5f, 0.5f);
feedbackRect.pivot = new Vector2(0.5f, 0.5f);
feedbackRect.sizeDelta = new Vector2(900f, 120f);
feedbackRect.anchoredPosition = new Vector2(0f, 130f);
_feedback.gameObject.SetActive(false);
BuildOverlaysAndZones();
BuildPhasePanel();
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);
_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;
_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 = true;
if (_intentPanel != null)
_intentPanel.gameObject.SetActive(true);
if (_intentPanelToggleLabel != null)
{
_intentPanelToggleLabel.text = "Скрыть панель";
UiKit.MakeLiteral(_intentPanelToggleLabel);
}
ApplyIntentPanelLayout();
_inputLock.ReleaseAll();
_feedbackLockHeld = false;
_logLines.Clear();
RebuildLog();
_feedbackQueue.Clear();
_feedbackPlaying = false;
_feedbackTime = 0f;
_feedback.gameObject.SetActive(false);
_flash.gameObject.SetActive(false);
_skipFeedback.gameObject.SetActive(false);
_combatStats?.gameObject.SetActive(false);
_board3D?.Clear();
_board3D?.SetVisible(true);
_overlays?.CloseAll();
HideAbilityOverlay();
HideCardChoiceOverlay();
_selfZones?.Update(null, self: true);
_opponentZones?.Update(null, self: false);
_topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId;
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;
_triggerSequence?.Cancel();
HideActionPrompt();
_root.gameObject.SetActive(false);
_result.gameObject.SetActive(false);
_feedback.gameObject.SetActive(false);
_flash.gameObject.SetActive(false);
_skipFeedback.gameObject.SetActive(false);
_overlays?.CloseAll();
HideAbilityOverlay();
HideCardChoiceOverlay();
_cardMenu?.Hide();
_combatStats?.gameObject.SetActive(false);
_board3D?.SetVisible(false);
ReleaseFeedbackLock();
_inputLock.ReleaseAll();
}
public void Tick()
{
if (_confirmConcede && Time.unscaledTime > _confirmDeadline)
{
_confirmConcede = false;
_concedeLabel.text = "Сдаться";
}
UpdateClock();
UpdateFeedback();
_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 (!_feedbackPlaying && _feedbackQueue.Count == 0)
{
_feedbackLockHeld = false;
_inputLock.Release("feedback");
}
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)
{
_autoFinishMulligan = false;
_autoFinishMulliganAt = 0f;
// After an exchange the mulligan must finish by itself;
// feedback/overlay 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 && !_feedbackPlaying)
{
_autoEndBattleArmed = false;
OnIntentClicked(endTurn);
}
}
else
{
_autoEndBattleArmed = false;
}
}
private void ShowFeedback(string text, Color color, float duration = 1.25f, bool flash = false)
{
_feedbackQueue.Enqueue(new FeedbackStep(text, color, duration, flash));
if (!_feedbackPlaying)
StartNextFeedback();
}
private void StartNextFeedback()
{
if (_feedbackQueue.Count == 0)
{
_feedbackPlaying = false;
_skipFeedback.gameObject.SetActive(false);
ReleaseFeedbackLock();
return;
}
if (!_feedbackLockHeld)
{
_feedbackLockHeld = true;
_inputLock.Acquire("feedback");
}
var step = _feedbackQueue.Dequeue();
_feedbackPlaying = true;
_feedback.text = step.Text;
_feedbackColor = step.Color;
_feedbackDuration = step.Duration;
_feedbackTime = step.Duration;
_feedback.gameObject.SetActive(true);
_feedback.rectTransform.localScale = Vector3.one * 1.12f;
_skipFeedback.gameObject.SetActive(true);
if (step.Flash)
{
_flash.gameObject.SetActive(true);
_flash.color = new Color(step.Color.r, step.Color.g, step.Color.b, 0.22f);
}
}
private void ReleaseFeedbackLock()
{
if (!_feedbackLockHeld)
return;
_feedbackLockHeld = false;
_inputLock.Release("feedback");
}
private void OnInputLockChanged(bool locked)
{
if (_view == null)
return;
RebuildIntents(_view);
RebuildPhaseControls(_view);
}
private void OnOverlayOpenChanged(bool open)
{
if (open)
_inputLock.Acquire("overlay");
else
_inputLock.Release("overlay");
}
private void UpdateFeedback()
{
if (!_feedbackPlaying)
return;
_feedbackTime -= Time.unscaledDeltaTime;
var t = Mathf.Clamp01(_feedbackTime / Mathf.Max(0.01f, _feedbackDuration));
var alpha = Mathf.Clamp01(t * 1.8f);
_feedback.color = new Color(_feedbackColor.r, _feedbackColor.g, _feedbackColor.b, alpha);
_feedback.rectTransform.localScale = Vector3.one * Mathf.Lerp(1f, 1.12f, t);
if (_flash != null && _flash.gameObject.activeSelf)
_flash.color = new Color(_feedbackColor.r, _feedbackColor.g, _feedbackColor.b, alpha * 0.22f);
if (_feedbackTime > 0f)
return;
_feedback.gameObject.SetActive(false);
if (_flash != null)
_flash.gameObject.SetActive(false);
StartNextFeedback();
}
private void SkipFeedback()
{
_feedbackQueue.Clear();
_feedbackPlaying = false;
_feedbackTime = 0f;
_feedback.gameObject.SetActive(false);
_flash.gameObject.SetActive(false);
_skipFeedback.gameObject.SetActive(false);
ReleaseFeedbackLock();
}
private readonly struct FeedbackStep
{
public FeedbackStep(string text, Color color, float duration, bool flash)
{
Text = text;
Color = color;
Duration = duration;
Flash = flash;
}
public string Text { get; }
public Color Color { get; }
public float Duration { get; }
public bool Flash { get; }
}
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;
PrepareTriggerSelection();
if (_view.Clock != null)
{
_clockServerMs = _view.Clock.ServerTimeMs;
_clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
Render();
SyncTriggerSequence();
SyncAbilityPrompt();
SyncCardChoicePrompt();
}
private bool _triggerWasPending;
private string _lastTriggerKey;
private void SyncTriggerSequence()
{
if (_triggerSequence == null || _view == null)
return;
var trigger = _view.Trigger;
if (trigger != null && _view.ActivePlayer == _view.You)
{
var key = trigger.Player + ":" + trigger.PrintingId
+ ":" + _view.EventSeq;
if (!_triggerWasPending
|| !string.Equals(_lastTriggerKey, key, StringComparison.Ordinal))
{
_triggerWasPending = true;
_lastTriggerKey = key;
_triggerSequence.ShowTrigger(
_animationConfig,
_board3D.TriggerCameraPoint(trigger.Player));
}
return;
}
if (_triggerWasPending)
{
_triggerWasPending = false;
_lastTriggerKey = null;
_triggerSequence.EndChain(_animationConfig);
}
}
private void OnEvents(ServerEvents events)
{
if (events?.Events != null)
{
foreach (var envelope in events.Events)
{
if (envelope.Event is GameFinishedView)
_finished = true;
var line = Describe(envelope.Event);
if (!string.IsNullOrWhiteSpace(line))
_logLines.Add(line);
ShowFeedbackFor(envelope.Event);
}
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 void ShowFeedbackFor(EventView view)
{
switch (view)
{
case AttackDeclaredView attack:
ShowFeedback("АТАКА " + SlotName(attack.AttackerSlot)
+ " → " + SlotName(attack.TargetSlot),
new Color(1f, 0.82f, 0.35f), _animationConfig.attackDeclare);
break;
case GuardAddedView guard:
ShowFeedback("GUARD " + CardName(guard.PrintingId)
+ " +" + guard.Shield,
new Color(0.45f, 0.82f, 1f), _animationConfig.guard);
break;
case DriveCheckedView drive:
ShowFeedback("DRIVE " + CardName(drive.PrintingId)
+ " · " + TriggerShort(drive.Trigger.ToString()),
new Color(0.72f, 0.88f, 1f), _animationConfig.driveCheck);
break;
case TriggerResolvedView trigger:
ShowFeedback("TRIGGER " + CardName(trigger.PrintingId),
new Color(0.72f, 0.88f, 1f), _animationConfig.trigger);
break;
case BattleResolvedView battle:
ShowFeedback(battle.Hit ? "HIT" : "BLOCK",
battle.Hit ? new Color(1f, 0.45f, 0.35f) : new Color(0.45f, 0.82f, 1f),
_animationConfig.battleResolved);
break;
case DamageTakenView damage:
ShowFeedback("+" + damage.Amount + " DAMAGE",
new Color(1f, 0.30f, 0.26f), _animationConfig.damage, flash: true);
break;
case HealedView:
ShowFeedback("HEAL",
new Color(0.45f, 0.95f, 0.62f), _animationConfig.heal, flash: true);
break;
case UnitRetiredView:
ShowFeedback("RETIRE",
new Color(1f, 0.55f, 0.30f), _animationConfig.retireDuration);
break;
case InterceptedView intercept:
ShowFeedback("INTERCEPT " + CardName(intercept.PrintingId),
new Color(0.55f, 0.82f, 1f), _animationConfig.intercept);
break;
case PerfectGuardView perfect:
ShowFeedback("PERFECT GUARD " + CardName(perfect.SentinelPrintingId),
new Color(0.60f, 0.82f, 1f), _animationConfig.perfectGuard);
break;
}
}
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)
{
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 a feedback/overlay lock is active.
_mulliganUsed = true;
_autoFinishMulligan = true;
_autoFinishMulliganAt = Time.unscaledTime + 0.3f;
if (_view != null)
RebuildPhaseControls(_view);
}
SetStatus(result.Duplicate ? "Повтор уже применён." : "Принято.", Muted);
await RequestSnapshotAsync();
}
}
catch (OperationCanceledException)
{
// экран закрыли
}
catch (Exception exception)
{
Debug.LogException(exception);
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