using System; using System.Threading; using System.Threading.Tasks; using UnityEngine; using UnityEngine.UI; using Vanguard.Api; using Vanguard.Api.Contracts; using Vanguard.Auth; using Vanguard.Battle; using Vanguard.Meta; namespace Vanguard.UI { /// Очередь classic-ranked и переход к матчу по тикету. public sealed class QueueView { private readonly MetaService _meta; private readonly AuthService _auth; private readonly Action _onBack; private readonly Action _onMatched; private readonly RectTransform _root; private readonly Text _deckName; private readonly Text _status; private readonly Text _details; private readonly Button _cancel; private readonly Button _retry; private DeckDto _deck; private CancellationTokenSource _cts; private Guid? _queueId; private bool _matched; private bool _busy; public QueueView( Transform parent, MetaService meta, AuthService auth, Action onBack, Action onMatched) { _meta = meta; _auth = auth; _onBack = onBack; _onMatched = onMatched; _root = UiKit.CreatePanel(parent, "QueueScreen", new Color(0.035f, 0.045f, 0.065f, 1f)); var card = UiKit.CreateCenteredCard( _root, "QueueCard", new Vector2(780f, 520f), new Color(0.07f, 0.085f, 0.115f, 1f)); UiKit.AddVerticalLayout(card, 36, 14); var title = UiKit.CreateText(card, "Title", "Поиск противника", 42, Color.white); title.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(title, 58f); _deckName = UiKit.CreateText( card, "Deck", "Колода", 22, new Color(0.72f, 0.78f, 0.92f)); UiKit.SetPreferredHeight(_deckName, 34f); var mode = UiKit.CreateText( card, "Mode", "Classic Ranked · скрытый MMR", 18, new Color(0.58f, 0.65f, 0.78f)); UiKit.SetPreferredHeight(mode, 26f); _status = UiKit.CreateText( card, "Status", "Готовим очередь…", 26, new Color(0.86f, 0.90f, 1f)); UiKit.SetPreferredHeight(_status, 74f); _details = UiKit.CreateText( card, "Details", string.Empty, 18, new Color(0.62f, 0.68f, 0.82f)); UiKit.SetPreferredHeight(_details, 38f); _cancel = UiKit.CreateButton(card, "Cancel", "Отменить поиск", OnCancelClicked, 21); UiKit.SetPreferredHeight(_cancel, 52f); _retry = UiKit.CreateButton(card, "Retry", "Повторить поиск", OnRetryClicked, 19); UiKit.SetPreferredHeight(_retry, 44f); _retry.gameObject.SetActive(false); _root.gameObject.SetActive(false); } public void Show(DeckDto deck) { _deck = deck; UiKit.SetActive(_root, true); _deckName.text = "Колода: " + (deck != null ? deck.Name : "—"); _retry.gameObject.SetActive(false); _ = StartAsync(); } public void Hide() { CancelPolling(); if (_queueId.HasValue && !_matched) { var queueId = _queueId.Value; _queueId = null; _ = CancelServerQueueAsync(queueId); } _matched = false; UiKit.SetActive(_root, false); } private async Task StartAsync() { if (_busy || _deck == null) return; CancelPolling(); _busy = true; _matched = false; _queueId = null; _retry.gameObject.SetActive(false); _cancel.interactable = false; SetStatus("Готовим очередь…", new Color(0.72f, 0.78f, 0.92f)); _cts = new CancellationTokenSource(); var token = _cts.Token; try { await _auth.EnsureAccessAsync(); var accepted = await _meta.EnqueueAsync(_deck.Id, "classic-ranked"); _queueId = accepted.QueueId; SetStatus("Ищем соперника…", new Color(0.86f, 0.90f, 1f)); _cancel.interactable = true; while (!token.IsCancellationRequested) { var status = await _meta.GetQueueStatusAsync(accepted.QueueId); if (token.IsCancellationRequested) return; if (status.Status == "matched") { _matched = true; await EnterMatchAsync(status); return; } if (status.Status is "failed" or "expired" or "cancelled") { _queueId = null; SetStatus( status.Status == "failed" ? "Не удалось создать матч." : "Очередь закрыта: " + status.Status + ".", new Color(1f, 0.60f, 0.45f)); _details.text = status.Reason ?? string.Empty; _retry.gameObject.SetActive(true); return; } _details.text = "Ожидание: " + status.WaitedSeconds + " с · окно MMR: ±" + status.Window; _cancel.interactable = true; await Task.Delay(1000, token); } } catch (OperationCanceledException) { // пользователь отменил } catch (ApiException exception) { SetStatus(exception.Message, new Color(1f, 0.55f, 0.45f)); _retry.gameObject.SetActive(true); } catch (Exception exception) { Debug.LogException(exception); SetStatus("Очередь недоступна. Проверьте сервер.", new Color(1f, 0.55f, 0.45f)); _retry.gameObject.SetActive(true); } finally { _busy = false; _cancel.interactable = true; } } private async Task EnterMatchAsync(QueueStatusResponse status) { SetStatus("Матч найден! Подключаемся…", new Color(0.45f, 0.85f, 0.55f)); _cancel.interactable = false; string ticket = status.Ticket; string wsUrl = status.WsUrl; if (string.IsNullOrEmpty(ticket) && !string.IsNullOrEmpty(status.MatchId)) { var issued = await _meta.CreateTicketAsync(status.MatchId); ticket = issued.Ticket; wsUrl = issued.WsUrl; } if (string.IsNullOrEmpty(ticket)) throw new ApiException(500, "ticket.missing", "Сервер не выдал тикет."); if (string.IsNullOrEmpty(wsUrl)) wsUrl = _meta.BaseUrl.Replace("https://", "wss://").Replace("http://", "ws://") .TrimEnd('/') + "/game"; _onMatched(new MatchConnectionInfo { MatchId = status.MatchId, Seat = status.Seat, Ticket = ticket, WsUrl = wsUrl, DeckName = _deck != null ? _deck.Name : string.Empty, }); } private async void OnCancelClicked() { await CancelAndBackAsync(); } private async void OnRetryClicked() { await StartAsync(); } private async Task CancelAndBackAsync() { CancelPolling(); if (_queueId.HasValue && !_matched) { var queueId = _queueId.Value; _queueId = null; await CancelServerQueueAsync(queueId); } _matched = false; _onBack(); } private async Task CancelServerQueueAsync(Guid queueId) { try { await _meta.CancelQueueAsync(queueId); } catch (Exception exception) { Debug.LogException(exception); } } private void CancelPolling() { if (_cts == null) return; try { _cts.Cancel(); } catch (Exception) { // ignore } _cts.Dispose(); _cts = null; } private void SetStatus(string text, Color color) { _status.text = text ?? string.Empty; _status.color = color; } } }