diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index ce534e9..796e9ea 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -231,10 +231,11 @@ - [x] Сцена `Boot` + runtime bootstrap; `Meta`/`Battle` появятся дальше. - [x] Composition root + DI (VContainer), без синглтон-свалок. - [x] `ApiClient` (HTTP/JSON), `PlayerState`, таймауты и разбор `ApiError`. -- [ ] `GameConnection` (WebSocket + MessagePack), очередь событий, - `ClientGameState`. +- [x] `GameConnection` (WebSocket + MessagePack), snapshot/events, + `ClientGameState` в виде `CurrentView` + `BattleView`, очередь и тикет. - [x] Экраны: логин/регистрация, главное меню, онбординг, коллекция, список колод и автосборка стартовой; магазин/полный декбилдер — дальше. +- [x] Очередь: classic-ranked, опрос, отмена, тикет, переход в матч. - [ ] Декбилдер: фильтры (клан, грейд, редкость, тип, арт), счётчики, валидация в реальном времени. - [x] Базовая коллекция: список, балансы, разбор в пыль с подтверждением; @@ -249,16 +250,20 @@ ## 8. Клиент: боевая сцена `[20–30 дней]` -- [ ] Поле: Vanguard, 2 колонки × 2 ряда, Guardian, рука, Damage, Soul, Drop, Trigger. -- [ ] Старт: выбор grade 0, маллиган. -- [ ] Ввод: клик/тап → контекстное меню → подсветка валидных целей. -- [ ] Отображение силы/крита с модификаторами и подсветкой баффов. +**Первый gray-box есть:** доска, рука, сила/крит, часы, все легальные +интенты кнопками, сдача и результат. Арты и анимации — следующие. + +- [x] Поле: Vanguard, 5 кругов Арьергарда, рука, damage/soul/drop-счётчики + (без артов и анимаций). +- [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»). +- [x] Ввод: кнопки легальных интентов; подсветка целей — дальше. +- [x] Отображение силы/крита из `MatchView`; модификаторы уже посчитаны сервером. - [ ] Очередь анимаций строго по событиям сервера: ride, call, attack, drive, damage, retire, boost, guard. - [ ] Скип/ускорение анимаций. +- [x] Таймер хода, индикатор реконнекта, кнопка сдачи (базово). +- [x] Экран результата: победа/поражение; Gold/XP и «ещё матч» — дальше. +- [x] Догон состояния по `resync`/snapshot после реконнекта (без переигрывания анимаций). - [ ] Экран противника: таймлайн и текстовый лог боя. -- [ ] Таймер хода, индикатор реконнекта, кнопка сдачи. -- [ ] Экран результата: победа/поражение, Gold/XP, кнопка «ещё матч». -- [ ] Догон состояния после реконнекта (resync + переигрывание очереди). **Готово, когда:** полный матч играется мышью без консоли и без рассинхрона с сервером. diff --git a/README.md b/README.md index a1f1650..b291278 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ | CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` | | EF-миграции | готово (`InitialCreate`) | | Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` | -| Клиент | каркас: Unity 6.6, логин, онбординг, коллекция, колоды | +| Клиент | Unity 6.6: логин, онбординг, коллекция, колоды, очередь, gray-box бой | | Лаунчер | не начато | ## Требования diff --git a/client/Assets/Vanguard/Runtime/App/VanguardApp.cs b/client/Assets/Vanguard/Runtime/App/VanguardApp.cs index 35968c9..32f40e9 100644 --- a/client/Assets/Vanguard/Runtime/App/VanguardApp.cs +++ b/client/Assets/Vanguard/Runtime/App/VanguardApp.cs @@ -73,6 +73,11 @@ namespace Vanguard.App _root.ShowLogin("Введите данные игрока."); } + private void Update() + { + _root?.Tick(); + } + private void OnDestroy() { if (_container is IDisposable disposable) diff --git a/client/Assets/Vanguard/Runtime/Auth/AuthService.cs b/client/Assets/Vanguard/Runtime/Auth/AuthService.cs index b588051..00de060 100644 --- a/client/Assets/Vanguard/Runtime/Auth/AuthService.cs +++ b/client/Assets/Vanguard/Runtime/Auth/AuthService.cs @@ -25,6 +25,12 @@ namespace Vanguard.Auth public bool IsAuthenticated => _tokens.HasRefreshToken; + public async Task EnsureAccessAsync() + { + if (_tokens.IsAccessExpired && _tokens.HasRefreshToken) + await RefreshAsync(); + } + public async Task LoginAsync(string userName, string password) { var response = await _api.PostAsync( diff --git a/client/Assets/Vanguard/Runtime/Battle.meta b/client/Assets/Vanguard/Runtime/Battle.meta new file mode 100644 index 0000000..73b7f15 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6b4976ff16294e6dba7e193dc27cefcb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/Battle/GameConnection.cs b/client/Assets/Vanguard/Runtime/Battle/GameConnection.cs new file mode 100644 index 0000000..50c510c --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/GameConnection.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Battle +{ + /// + /// WebSocket-клиент матча. Кадры — MessagePack из Vanguard.Shared. + /// Чтение идёт в async-цикле на главном Unity-потоке, поэтому события + /// можно безопасно обрабатывать в UI. + /// + public sealed class GameConnection : IAsyncDisposable + { + private readonly ClientWebSocket _socket = new ClientWebSocket(); + private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); + private readonly ConcurrentDictionary> _pending = + new ConcurrentDictionary>(); + + private CancellationTokenSource _cts; + private Task _readTask; + private Task _pingTask; + private int _closed; + + public event Action HelloReceived; + public event Action SnapshotReceived; + public event Action EventsReceived; + public event Action IntentResultReceived; + public event Action ClockReceived; + public event Action ErrorReceived; + public event Action Closed; + + public int Seat { get; private set; } = -1; + public long LastSeq { get; private set; } + public MatchView CurrentView { get; private set; } + public bool IsConnected => _socket.State == WebSocketState.Open; + + public async Task ConnectAsync( + string wsUrl, + string ticket, + string clientBuild, + long resumeFromSeq, + CancellationToken cancellationToken) + { + if (_socket.State != WebSocketState.None) + throw new InvalidOperationException("Соединение уже открыто или закрыто."); + + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + await _socket.ConnectAsync(new Uri(wsUrl), cancellationToken); + + await SendAsync(new ClientHello + { + ProtocolVersion = ProtocolConstants.Version, + Ticket = ticket, + ClientBuild = clientBuild, + ResumeFromSeq = resumeFromSeq, + }, cancellationToken); + + _readTask = ReadLoopAsync(_cts.Token); + _pingTask = PingLoopAsync(_cts.Token); + } + + public async Task SubmitAsync( + IntentPayload intent, + CancellationToken cancellationToken) + { + var intentId = Guid.NewGuid().ToString("N"); + var completion = new TaskCompletionSource(); + _pending[intentId] = completion; + + try + { + await SendAsync(new ClientIntent + { + IntentId = intentId, + BasedOnSeq = LastSeq, + Intent = intent, + }, cancellationToken); + + using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + timeout.CancelAfter(TimeSpan.FromSeconds(15)); + using (timeout.Token.Register(() => + completion.TrySetException(new TimeoutException( + "Сервер не ответил на интент.")))) + { + return await completion.Task; + } + } + } + finally + { + _pending.TryRemove(intentId, out _); + } + } + + public Task RequestSnapshotAsync(CancellationToken cancellationToken) => + SendAsync(new ClientResync + { + FromSeq = LastSeq + 1, + FullSnapshot = true, + }, cancellationToken); + + public Task LeaveAsync(string reason, CancellationToken cancellationToken) => + SendAsync(new ClientLeave { Reason = reason }, cancellationToken); + + public async Task SendAsync(ClientFrame frame, CancellationToken cancellationToken) + { + var bytes = ProtocolSerializer.Encode(frame); + + await _sendLock.WaitAsync(cancellationToken); + try + { + await _socket.SendAsync( + new ArraySegment(bytes), + WebSocketMessageType.Binary, + endOfMessage: true, + cancellationToken); + } + finally + { + _sendLock.Release(); + } + } + + private async Task ReadLoopAsync(CancellationToken cancellationToken) + { + var buffer = new byte[16 * 1024]; + + try + { + while (!cancellationToken.IsCancellationRequested + && _socket.State == WebSocketState.Open) + { + using (var stream = new MemoryStream()) + { + WebSocketReceiveResult result; + + do + { + result = await _socket.ReceiveAsync( + new ArraySegment(buffer), cancellationToken); + + if (result.MessageType == WebSocketMessageType.Close) + { + NotifyClosed("Сервер закрыл соединение."); + return; + } + + if (result.MessageType != WebSocketMessageType.Binary) + { + NotifyClosed("Сервер прислал не бинарный кадр."); + return; + } + + stream.Write(buffer, 0, result.Count); + if (stream.Length > ProtocolConstants.MaxFrameBytes) + { + NotifyClosed("Кадр сервера слишком велик."); + return; + } + } while (!result.EndOfMessage); + + ServerFrame frame; + try + { + frame = ProtocolSerializer.DecodeServer(stream.ToArray()); + } + catch (Exception exception) + { + NotifyClosed("Ошибка разбора кадра: " + exception.Message); + return; + } + + HandleFrame(frame, cancellationToken); + } + } + } + catch (OperationCanceledException) + { + NotifyClosed("Соединение закрыто."); + } + catch (Exception exception) + { + NotifyClosed("Соединение потеряно: " + exception.Message); + } + } + + private void HandleFrame(ServerFrame frame, CancellationToken cancellationToken) + { + switch (frame) + { + case ServerHello hello: + Seat = hello.PlayerIndex; + LastSeq = hello.EventSeq; + HelloReceived?.Invoke(hello); + break; + + case ServerSnapshot snapshot: + CurrentView = snapshot.View; + LastSeq = snapshot.View.EventSeq; + SnapshotReceived?.Invoke(snapshot); + break; + + case ServerEvents events: + if (events.ToSeq > LastSeq) + LastSeq = events.ToSeq; + EventsReceived?.Invoke(events); + break; + + case ServerIntentResult result: + IntentResultReceived?.Invoke(result); + if (_pending.TryGetValue(result.IntentId, out var completion)) + completion.TrySetResult(result); + break; + + case ServerClock clock: + if (clock.EventSeq > LastSeq) + LastSeq = clock.EventSeq; + ClockReceived?.Invoke(clock); + break; + + case ServerPing ping: + _ = SendAsync(new ClientPong { Nonce = ping.Nonce }, cancellationToken); + break; + + case ServerPong _: + break; + + case ServerError error: + ErrorReceived?.Invoke(error); + if (error.Fatal) + NotifyClosed(error.Message); + break; + } + } + + private async Task PingLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested && IsConnected) + { + await Task.Delay(ProtocolConstants.ClientPingIntervalMs, cancellationToken); + await SendAsync(new ClientPing + { + Nonce = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + ClientTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }, cancellationToken); + } + } + catch (OperationCanceledException) + { + // нормальное завершение + } + catch (Exception exception) + { + NotifyClosed("Ping не отправлен: " + exception.Message); + } + } + + private void NotifyClosed(string reason) + { + if (Interlocked.Exchange(ref _closed, 1) != 0) + return; + + foreach (var pending in _pending.Values) + pending.TrySetException(new InvalidOperationException(reason)); + + Closed?.Invoke(reason); + } + + public async ValueTask DisposeAsync() + { + try + { + if (_cts != null) + _cts.Cancel(); + } + catch (Exception) + { + // ignore + } + + try + { + if (_socket.State == WebSocketState.Open) + { + await _socket.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, + "bye", + CancellationToken.None); + } + } + catch (Exception) + { + // ignore + } + + try + { + if (_readTask != null) + await _readTask; + } + catch (Exception) + { + // ignore + } + + try + { + if (_pingTask != null) + await _pingTask; + } + catch (Exception) + { + // ignore + } + + _cts?.Dispose(); + _sendLock.Dispose(); + _socket.Dispose(); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Battle/GameConnection.cs.meta b/client/Assets/Vanguard/Runtime/Battle/GameConnection.cs.meta new file mode 100644 index 0000000..b31cad0 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/GameConnection.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a5e35a7223c142e18f8a3ccd4e4cbd20 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/Battle/MatchConnectionInfo.cs b/client/Assets/Vanguard/Runtime/Battle/MatchConnectionInfo.cs new file mode 100644 index 0000000..9906238 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/MatchConnectionInfo.cs @@ -0,0 +1,12 @@ +namespace Vanguard.Battle +{ + /// Данные, которые нужны для входа в матч после очереди. + public sealed class MatchConnectionInfo + { + public string MatchId { get; set; } = string.Empty; + public int Seat { get; set; } + public string Ticket { get; set; } = string.Empty; + public string WsUrl { get; set; } = string.Empty; + public string DeckName { get; set; } = string.Empty; + } +} diff --git a/client/Assets/Vanguard/Runtime/Battle/MatchConnectionInfo.cs.meta b/client/Assets/Vanguard/Runtime/Battle/MatchConnectionInfo.cs.meta new file mode 100644 index 0000000..b522f65 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/MatchConnectionInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76c2f6cd9b004cf998a031e4862bc9dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/Meta/MetaService.cs b/client/Assets/Vanguard/Runtime/Meta/MetaService.cs index 32e469c..8aa668c 100644 --- a/client/Assets/Vanguard/Runtime/Meta/MetaService.cs +++ b/client/Assets/Vanguard/Runtime/Meta/MetaService.cs @@ -19,6 +19,8 @@ namespace Vanguard.Meta _player = player; } + public string BaseUrl => _api.BaseUrl; + public async Task GetStoreAsync() { var store = await _api.GetAsync("/api/store"); @@ -77,5 +79,22 @@ namespace Vanguard.Meta await _api.PostAsync( "/api/decks/" + deckId.ToString("D") + "/validate", new DeckRequest { Cards = cards }); + + public async Task EnqueueAsync(Guid deckId, string mode) => + await _api.PostAsync( + "/api/game/queue", + new QueueRequest { DeckId = deckId, Mode = mode }); + + public async Task GetQueueStatusAsync(Guid queueId) => + await _api.GetAsync( + "/api/game/queue/" + queueId.ToString("D")); + + public Task CancelQueueAsync(Guid queueId) => + _api.DeleteAsync("/api/game/queue/" + queueId.ToString("D")); + + public async Task CreateTicketAsync(string matchId) => + await _api.PostAsync( + "/api/game/tickets", + new TicketRequest { MatchId = matchId }); } } diff --git a/client/Assets/Vanguard/Runtime/UI/AppRootView.cs b/client/Assets/Vanguard/Runtime/UI/AppRootView.cs index 9da52ea..1a3c72e 100644 --- a/client/Assets/Vanguard/Runtime/UI/AppRootView.cs +++ b/client/Assets/Vanguard/Runtime/UI/AppRootView.cs @@ -1,6 +1,8 @@ using UnityEngine; +using Vanguard.Api.Contracts; using Vanguard.App; using Vanguard.Auth; +using Vanguard.Battle; using Vanguard.Meta; using Vanguard.State; @@ -19,6 +21,8 @@ namespace Vanguard.UI private OnboardingView _onboarding; private CollectionView _collection; private DecksView _decks; + private QueueView _queue; + private BattleView _battle; public AppRootView( AppConfig config, @@ -51,17 +55,23 @@ namespace Vanguard.UI _collection = new CollectionView( canvas.transform, _metaService, _player, ShowMeta); _decks = new DecksView( - canvas.transform, _metaService, _player, ShowMeta); + canvas.transform, _metaService, _player, ShowMeta, ShowQueue); + _queue = new QueueView( + canvas.transform, _metaService, _auth, ShowDecks, ShowBattle); + _battle = new BattleView( + canvas.transform, _metaService, ShowMeta); ShowLogin("Проверяем сессию…"); } + public void Tick() + { + _battle?.Tick(); + } + public void ShowLogin(string status = null) { - _metaView.Hide(); - _onboarding.Hide(); - _collection.Hide(); - _decks.Hide(); + HideAll(); _login.Show(status); } @@ -75,38 +85,49 @@ namespace Vanguard.UI public void ShowMeta() { - _login.Hide(); - _onboarding.Hide(); - _collection.Hide(); - _decks.Hide(); + HideAll(); _metaView.Show(); } public void ShowOnboarding() { - _login.Hide(); - _metaView.Hide(); - _collection.Hide(); - _decks.Hide(); + HideAll(); _onboarding.Show(); } public void ShowCollection() { - _login.Hide(); - _metaView.Hide(); - _onboarding.Hide(); - _decks.Hide(); + HideAll(); _collection.Show(); } public void ShowDecks() + { + HideAll(); + _decks.Show(); + } + + public void ShowQueue(DeckDto deck) + { + HideAll(); + _queue.Show(deck); + } + + public void ShowBattle(MatchConnectionInfo info) + { + HideAll(); + _battle.Show(info); + } + + private void HideAll() { _login.Hide(); _metaView.Hide(); _onboarding.Hide(); _collection.Hide(); - _decks.Show(); + _decks.Hide(); + _queue.Hide(); + _battle.Hide(); } private void OnAuthenticated() diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs new file mode 100644 index 0000000..8fc828f --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -0,0 +1,745 @@ +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.Meta; +using Vanguard.Shared.Protocol; + +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 readonly MetaService _meta; + private readonly Action _onExit; + + private readonly RectTransform _root; + private readonly RectTransform _board; + 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 Text _resultText; + + private GameConnection _connection; + private CancellationTokenSource _cts; + private MatchConnectionInfo _info; + private MatchView _view; + private bool _connecting; + private bool _sending; + private bool _snapshotPending; + private bool _finished; + private bool _confirmConcede; + private float _confirmDeadline; + private long _clockServerMs; + private long _clockReceivedMs; + + public BattleView(Transform parent, MetaService meta, Action onExit) + { + _meta = meta; + _onExit = onExit; + + _root = UiKit.CreatePanel(parent, "BattleScreen", Background); + + 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.SetPreferredHeight(_topInfo, 38f); + + _clock = UiKit.CreateText( + top, "Clock", "—", 22, Color.white, TextAnchor.MiddleRight); + UiKit.SetPreferredHeight(_clock, 44f, 0f); + + _concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 18); + _concedeLabel = _concede.GetComponentInChildren(); + UiKit.SetPreferredHeight(_concede, 44f, 0f); + + var left = UiKit.CreatePanel(_root, "BoardPanel", Background); + left.anchorMin = Vector2.zero; + left.anchorMax = Vector2.one; + left.offsetMin = new Vector2(36f, 28f); + left.offsetMax = new Vector2(-564f, -108f); + UiKit.AddVerticalLayout(left, 8, 8); + _board = left; + + var right = UiKit.CreatePanel(_root, "IntentPanel", Panel); + right.anchorMin = new Vector2(1f, 0f); + right.anchorMax = new Vector2(1f, 1f); + right.pivot = new Vector2(1f, 0.5f); + right.offsetMin = new Vector2(-528f, 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, 110f); + + _reconnect = UiKit.CreateButton(right, "Reconnect", "Переподключиться", OnReconnectClicked, 18); + UiKit.SetPreferredHeight(_reconnect, 46f); + _reconnect.gameObject.SetActive(false); + + _result = BuildResultPanel(_root); + _result.gameObject.SetActive(false); + + _root.gameObject.SetActive(false); + } + + public void Show(MatchConnectionInfo info) + { + _info = info; + _view = null; + _finished = false; + _confirmConcede = false; + _concedeLabel.text = "Сдаться"; + _concede.interactable = true; + _reconnect.gameObject.SetActive(false); + _result.gameObject.SetActive(false); + _topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId; + SetStatus("Подключаемся к матчу…", Muted); + _root.gameObject.SetActive(true); + _ = ConnectAsync(); + } + + public void Hide() + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + + if (_connection != null) + { + var connection = _connection; + _connection = null; + _ = connection.DisposeAsync().AsTask(); + } + + _root.gameObject.SetActive(false); + _result.gameObject.SetActive(false); + _view = null; + } + + public void Tick() + { + if (_confirmConcede && Time.unscaledTime > _confirmDeadline) + { + _confirmConcede = false; + _concedeLabel.text = "Сдаться"; + } + + UpdateClock(); + } + + 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 ConnectAsync() + { + if (_connecting || _info == null) + return; + + _connecting = true; + _reconnect.gameObject.SetActive(false); + _cts?.Cancel(); + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + + try + { + if (_connection != null) + { + await _connection.DisposeAsync(); + _connection = null; + } + + 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); + } + catch (OperationCanceledException) + { + // экран закрыли + } + catch (Exception exception) + { + Debug.LogException(exception); + SetStatus("Не удалось подключиться: " + exception.Message, new Color(1f, 0.55f, 0.45f)); + _reconnect.gameObject.SetActive(true); + } + finally + { + _connecting = false; + } + } + + private void OnHello(ServerHello hello) + { + if (_info != null) + _info.Seat = hello.PlayerIndex; + } + + private void OnSnapshot(ServerSnapshot snapshot) + { + if (!_root.gameObject.activeSelf || snapshot?.View == null) + return; + + _view = snapshot.View; + _finished = _view.IsFinished; + if (_view.Clock != null) + { + _clockServerMs = _view.Clock.ServerTimeMs; + _clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + + Render(); + } + + private void OnEvents(ServerEvents events) + { + _ = RequestSnapshotAsync(); + } + + 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) + return; + + SetStatus("Соединение потеряно: " + reason, new Color(1f, 0.60f, 0.45f)); + _reconnect.gameObject.SetActive(true); + } + + 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 async void OnIntentClicked(IntentPayload intent) + { + if (_sending || _connection == null || _cts == null || _view == null) + return; + + _sending = true; + SetStatus("Отправляем: " + Label(intent), Muted); + + try + { + var result = await _connection.SubmitAsync(intent, _cts.Token); + + if (!result.Accepted) + { + SetStatus( + "Отклонено (" + result.Code + "): " + (result.Reason ?? "без причины"), + new Color(1f, 0.55f, 0.45f)); + } + else + { + 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 OnConcedeClicked() + { + if (_view == null || _finished) + return; + + if (!_confirmConcede) + { + _confirmConcede = true; + _confirmDeadline = Time.unscaledTime + 4f; + _concedeLabel.text = "Подтвердить сдачу"; + SetStatus("Нажмите «Подтвердить сдачу» ещё раз.", new Color(1f, 0.76f, 0.38f)); + return; + } + + _confirmConcede = false; + _concedeLabel.text = "Сдаться"; + OnIntentClicked(new ConcedeIntent()); + } + + private async void OnReconnectClicked() + { + if (_info == null) + return; + + _reconnect.interactable = false; + SetStatus("Получаем новый тикет…", Muted); + + try + { + var ticket = await _meta.CreateTicketAsync(_info.MatchId); + _info.Ticket = ticket.Ticket; + _info.WsUrl = ticket.WsUrl; + await ConnectAsync(); + } + catch (ApiException exception) + { + SetStatus(exception.Message, new Color(1f, 0.55f, 0.45f)); + } + catch (Exception exception) + { + Debug.LogException(exception); + SetStatus("Переподключение не удалось.", new Color(1f, 0.55f, 0.45f)); + } + finally + { + _reconnect.interactable = true; + } + } + + private void OnExitClicked() + { + Hide(); + _onExit(); + } + + private void Render() + { + if (_view == null) + return; + + ClearChildren(_board); + var opponentIndex = _view.You == 0 ? 1 : 0; + var opponent = _view.Players.FirstOrDefault(player => player.Index == opponentIndex) + ?? (_view.Players.Count > opponentIndex ? _view.Players[opponentIndex] : null); + var self = _view.Players.FirstOrDefault(player => player.Index == _view.You) + ?? (_view.Players.Count > _view.You ? _view.Players[_view.You] : null); + + AddPlayerBlock(opponent, false); + AddMatchInfo(_view); + AddPlayerBlock(self, true); + AddHand(self); + RebuildIntents(_view); + UpdateClock(); + + if (_view.IsFinished) + ShowResult(_view); + } + + private void AddPlayerBlock(PlayerView player, bool self) + { + if (player == null) + return; + + var block = UiKit.CreatePanel( + _board, + self ? "SelfBlock" : "OpponentBlock", + Panel); + var blockElement = block.gameObject.AddComponent(); + blockElement.preferredHeight = self ? 250f : 230f; + UiKit.AddVerticalLayout(block, 8, 4); + + var header = UiKit.CreateText( + block, + "Header", + (self ? "Вы: " : "Противник: ") + player.Name + + " · рука " + player.HandCount + + " · колода " + player.DeckCount + + " · урон " + (player.FaceDownDamage + player.FaceUpDamage.Count) + + " · soul " + player.Soul.Count + + " · drop " + player.Drop.Count, + 17, + self ? Color.white : new Color(0.86f, 0.80f, 0.72f), + TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(header, 26f); + + var row = UiKit.CreatePanel(block, "Field", new Color(0f, 0f, 0f, 0f)); + var rowElement = row.gameObject.AddComponent(); + rowElement.flexibleHeight = 1f; + rowElement.minHeight = 150f; + UiKit.AddHorizontalLayout(row, 0, 6); + + AddCardBox(row, player.Vanguard, "Vanguard"); + for (var i = 0; i < 5; i++) + { + var card = player.RearGuards != null && i < player.RearGuards.Length + ? player.RearGuards[i] + : null; + AddCardBox(row, card, "R" + (i + 1)); + } + } + + private void AddHand(PlayerView self) + { + var hand = UiKit.CreatePanel(_board, "HandBlock", Panel); + var handElement = hand.gameObject.AddComponent(); + handElement.preferredHeight = 190f; + UiKit.AddVerticalLayout(hand, 8, 4); + + var header = UiKit.CreateText( + hand, "Header", "Рука", 17, Color.white, TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(header, 22f); + + var row = UiKit.CreatePanel(hand, "Cards", new Color(0f, 0f, 0f, 0f)); + var rowElement = row.gameObject.AddComponent(); + rowElement.flexibleHeight = 1f; + UiKit.AddHorizontalLayout(row, 0, 6); + + if (self?.Hand == null || self.Hand.Count == 0) + { + var empty = UiKit.CreateText(row, "Empty", "Рука пуста", 18, Muted); + UiKit.SetPreferredHeight(empty, 120f); + return; + } + + foreach (var card in self.Hand) + AddCardBox(row, card, "Рука"); + } + + private void AddMatchInfo(MatchView view) + { + var info = UiKit.CreatePanel(_board, "MatchInfo", Panel); + var element = info.gameObject.AddComponent(); + element.preferredHeight = 150f; + UiKit.AddVerticalLayout(info, 10, 3); + + var active = view.ActivePlayer == view.You ? "вы" : "противник"; + AddInfoLine(info, "Фаза: " + view.Phase + " · шаг: " + view.Step + + " · ход " + view.TurnNumber + " · решает: " + active); + + if (view.Battle != null) + { + AddInfoLine( + info, + "Атака: " + SlotName(view.Battle.AttackerSlot) + + " → " + SlotName(view.Battle.TargetSlot) + + " · щит " + view.Battle.GuardShield + + " · проверок " + view.Battle.ChecksRemaining + + (view.Battle.Nullified ? " · отменено" : string.Empty)); + } + + if (view.Choice != null) + AddInfoLine(info, "Выбор: " + view.Choice.Prompt); + + if (view.Ability != null) + AddInfoLine( + info, + "Способность: " + view.Ability.PrintingId + + (view.Ability.Optional ? " (опционально)" : string.Empty)); + + if (view.Trigger != null) + { + AddInfoLine( + info, + "Триггер: " + view.Trigger.PrintingId + + " / " + view.Trigger.Trigger + + " / урон " + view.Trigger.RemainingDamage); + } + + if (view.Order != null && view.Order.Options.Count > 0) + AddInfoLine(info, "Нужно выбрать порядок эффектов: " + view.Order.Options.Count); + + if (view.OpponentChoosing) + AddInfoLine(info, "Противник что-то выбирает…"); + } + + private static void AddInfoLine(Transform parent, string text) + { + var line = UiKit.CreateText( + parent, "Info", text, 16, Muted, TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(line, 22f); + } + + private static void AddCardBox(Transform parent, CardView card, string slot) + { + var panel = UiKit.CreatePanel(parent, "Card_" + slot, Card); + var element = panel.gameObject.AddComponent(); + element.preferredWidth = 132f; + element.preferredHeight = 148f; + element.flexibleWidth = 0f; + UiKit.AddVerticalLayout(panel, 5, 2); + + var slotText = UiKit.CreateText(panel, "Slot", slot, 12, Muted); + UiKit.SetPreferredHeight(slotText, 16f); + + if (card == null) + { + var empty = UiKit.CreateText(panel, "Empty", "Пусто", 14, new Color(1f, 1f, 1f, 0.35f)); + UiKit.SetPreferredHeight(empty, 60f); + return; + } + + var printing = UiKit.CreateText( + panel, "Printing", card.PrintingId, 12, Color.white, TextAnchor.UpperCenter); + printing.horizontalOverflow = HorizontalWrapMode.Wrap; + UiKit.SetPreferredHeight(printing, 42f); + + var stats = UiKit.CreateText( + panel, "Stats", card.Power + " / " + card.Critical, 16, Color.white); + UiKit.SetPreferredHeight(stats, 22f); + + var stateText = (card.Rested ? "REST " : string.Empty) + + (card.FaceUp ? string.Empty : "FACE-DOWN"); + if (!string.IsNullOrEmpty(stateText)) + { + var state = UiKit.CreateText(panel, "State", stateText.Trim(), 12, new Color(1f, 0.76f, 0.38f)); + UiKit.SetPreferredHeight(state, 18f); + } + } + + private void RebuildIntents(MatchView view) + { + ClearChildren(_intents); + + var legal = view.LegalIntents ?? new List(); + var playable = legal.Where(intent => intent is not ConcedeIntent).ToList(); + + if (playable.Count == 0) + { + var waiting = UiKit.CreateText( + _intents, + "Waiting", + view.IsFinished ? "Матч завершён." : "Сейчас решает противник…", + 18, + Muted); + UiKit.SetPreferredHeight(waiting, 110f); + return; + } + + foreach (var intent in playable) + { + var captured = intent; + var button = UiKit.CreateButton( + _intents, + "Intent_" + captured.GetType().Name, + Label(captured), + () => OnIntentClicked(captured), + 17); + UiKit.SetPreferredHeight(button, 50f); + } + } + + private void ShowResult(MatchView view) + { + if (_result.gameObject.activeSelf) + return; + + _result.SetAsLastSibling(); + _result.gameObject.SetActive(true); + _concede.interactable = false; + + _resultText.text = view.Winner < 0 + ? "Ничья или матч прерван." + : view.Winner == view.You + ? "Победа!" + : "Поражение."; + + _resultText.text += "\nМатч " + view.MatchId + " завершён."; + } + + private void UpdateClock() + { + if (_view?.Clock == null || !_view.Clock.IsRunning) + { + _clock.text = "—"; + return; + } + + var localNow = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var serverNow = _clockServerMs + (localNow - _clockReceivedMs); + var remaining = Math.Max(0, _view.Clock.TurnDeadlineMs - serverNow); + var bank = _view.Clock.DecisionPlayer == _view.You + ? _view.Clock.Bank0Ms + : _view.Clock.Bank1Ms; + + _clock.text = Format(remaining) + " · банк " + Format(bank); + } + + private static string Format(long milliseconds) + { + var totalSeconds = Math.Max(0, milliseconds / 1000); + var minutes = totalSeconds / 60; + var seconds = totalSeconds % 60; + return minutes.ToString("00") + ":" + seconds.ToString("00"); + } + + private static void ClearChildren(Transform parent) + { + for (var i = parent.childCount - 1; i >= 0; i--) + UnityEngine.Object.Destroy(parent.GetChild(i).gameObject); + } + + private static string SlotName(int slot) + { + if (slot < 0) + return "Vanguard"; + + return "R" + (slot + 1); + } + + private string Label(IntentPayload intent) + { + switch (intent) + { + case FinishMulliganIntent _: return "Закончить маллиган"; + case MulliganIntent mulligan: return "Маллиган: " + mulligan.HandIndexes.Count + " карт"; + case SkipRideIntent _: return "Пропустить Ride"; + case RideIntent ride: return "Ride: " + ride.PrintingId; + case CallIntent call: return "Call " + call.PrintingId + " → " + SlotName(call.Slot); + case MoveIntent move: return "Move " + SlotName(move.FromSlot) + " → " + SlotName(move.ToSlot); + case EnterBattleIntent _: return "В фазу боя"; + case AttackIntent attack: + return "Атака " + SlotName(attack.AttackerSlot) + " → " + SlotName(attack.TargetSlot) + + (attack.BoosterSlot >= 0 ? " + boost" : string.Empty); + case GuardIntent guard: return "Guard: " + guard.PrintingId; + case InterceptIntent intercept: return "Intercept: " + SlotName(intercept.Slot); + case PassGuardIntent _: return "Пропустить Guard"; + case ResolveTriggerIntent trigger: + return "Триггер: сила " + SlotName(trigger.PowerTarget) + + ", эффект " + SlotName(trigger.EffectTarget); + case SelectTriggerIntent select: return "Выбрать триггер #" + select.Index; + case ResolveAbilityIntent ability: + return ability.Accept ? "Применить способность" : "Пропустить способность"; + case ChooseIntent choose: return ChoiceLabel(choose.Index); + case ActivateAbilityIntent activate: + return "Способность #" + activate.InstanceId; + case EndTurnIntent _: return "Закончить ход"; + case ConcedeIntent _: return "Сдаться"; + default: return intent.GetType().Name; + } + } + + private string ChoiceLabel(int index) + { + if (_view?.Choice?.Options != null + && index >= 0 + && index < _view.Choice.Options.Count) + { + return "Выбрать: " + _view.Choice.Options[index].Label; + } + + return "Выбрать вариант #" + index; + } + + private void SetStatus(string text, Color color) + { + _status.text = text ?? string.Empty; + _status.color = color; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs.meta b/client/Assets/Vanguard/Runtime/UI/BattleView.cs.meta new file mode 100644 index 0000000..cd7e55a --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 46303062fea5408dbe688c66aa184dbd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/UI/DecksView.cs b/client/Assets/Vanguard/Runtime/UI/DecksView.cs index d860293..1e0405a 100644 --- a/client/Assets/Vanguard/Runtime/UI/DecksView.cs +++ b/client/Assets/Vanguard/Runtime/UI/DecksView.cs @@ -17,6 +17,7 @@ namespace Vanguard.UI private readonly MetaService _meta; private readonly PlayerState _player; private readonly Action _onBack; + private readonly Action _onPlay; private readonly RectTransform _root; private RectTransform _content; @@ -32,11 +33,13 @@ namespace Vanguard.UI Transform parent, MetaService meta, PlayerState player, - Action onBack) + Action onBack, + Action onPlay) { _meta = meta; _player = player; _onBack = onBack; + _onPlay = onPlay; _root = UiKit.CreatePanel(parent, "DecksScreen", new Color(0.035f, 0.045f, 0.065f, 1f)); @@ -272,7 +275,7 @@ namespace Vanguard.UI private void OnPlayClicked(DeckDto deck) { - SetStatus("Очередь и бой подключим следующим шагом. Колода: " + deck.Name + ".", new Color(1f, 0.76f, 0.38f)); + _onPlay(deck); } private void ShowConfirm(DeckDto deck) diff --git a/client/Assets/Vanguard/Runtime/UI/QueueView.cs b/client/Assets/Vanguard/Runtime/UI/QueueView.cs new file mode 100644 index 0000000..df32187 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/QueueView.cs @@ -0,0 +1,280 @@ +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; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/UI/QueueView.cs.meta b/client/Assets/Vanguard/Runtime/UI/QueueView.cs.meta new file mode 100644 index 0000000..5c341e7 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/QueueView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8b5314c1af0c4fdc90a52684ae17af8e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs index 449244b..303b1ca 100644 --- a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs @@ -35,6 +35,8 @@ namespace Vanguard.Tests Assert.IsTrue(Resources.FindObjectsOfTypeAll().Any(go => go.name == "OnboardingScreen")); Assert.IsTrue(Resources.FindObjectsOfTypeAll().Any(go => go.name == "CollectionScreen")); Assert.IsTrue(Resources.FindObjectsOfTypeAll().Any(go => go.name == "DecksScreen")); + Assert.IsTrue(Resources.FindObjectsOfTypeAll().Any(go => go.name == "QueueScreen")); + Assert.IsTrue(Resources.FindObjectsOfTypeAll().Any(go => go.name == "BattleScreen")); Cleanup(); } diff --git a/client/Assets/Vanguard/Tests/PlayMode/AppBootstrapPlayModeTests.cs b/client/Assets/Vanguard/Tests/PlayMode/AppBootstrapPlayModeTests.cs index c5c28ba..0139ebd 100644 --- a/client/Assets/Vanguard/Tests/PlayMode/AppBootstrapPlayModeTests.cs +++ b/client/Assets/Vanguard/Tests/PlayMode/AppBootstrapPlayModeTests.cs @@ -11,7 +11,7 @@ namespace Vanguard.Tests public sealed class AppBootstrapPlayModeTests { [UnityTest] - public IEnumerator App_Bootstraps_And_Shows_A_Start_Screen() + public IEnumerator App_Bootstraps_And_Shows_A_Screen() { if (Object.FindAnyObjectByType() == null) { @@ -29,6 +29,8 @@ namespace Vanguard.Tests .FirstOrDefault(go => go.name == "LoginScreen"); var onboarding = Resources.FindObjectsOfTypeAll() .FirstOrDefault(go => go.name == "OnboardingScreen"); + var meta = Resources.FindObjectsOfTypeAll() + .FirstOrDefault(go => go.name == "MetaScreen"); var hierarchy = string.Join( "\n", Resources.FindObjectsOfTypeAll() @@ -36,11 +38,12 @@ namespace Vanguard.Tests .Select(go => go.name + ": activeSelf=" + go.activeSelf + ", activeInHierarchy=" + go.activeInHierarchy)); var hasStartScreen = (login != null && login.activeInHierarchy) - || (onboarding != null && onboarding.activeInHierarchy); + || (onboarding != null && onboarding.activeInHierarchy) + || (meta != null && meta.activeInHierarchy); Assert.IsTrue( hasStartScreen, - "Ни LoginScreen, ни OnboardingScreen не активны.\nInitError: " + "Ни LoginScreen, ни OnboardingScreen, ни MetaScreen не активны.\nInitError: " + VanguardApp.LastInitializationError + "\n" + hierarchy); Cleanup(); diff --git a/client/README.md b/client/README.md index 628264c..838d9f0 100644 --- a/client/README.md +++ b/client/README.md @@ -44,12 +44,17 @@ http://localhost:5080 - Коллекция: список карт, балансы, разбор в пыль с подтверждением. - Колоды: список, удаление, автосборка стартовой колоды из выбранной триал-деки. +- Очередь: classic-ranked, опрос статуса, отмена, тикет и переход в бой. +- `GameConnection`: WebSocket + MessagePack, handshake, snapshot/events, + интенты, ответы на ping, закрытие и переподключение. +- Боевой gray-box: доска, рука, часы, кнопки всех легальных интентов, + сдача с подтверждением и экран результата. - Экран входа и главный экран с балансами, MMR и переходами в разделы. ## Что дальше -1. Очередь и экран ожидания → тикет → WebSocket (`GameConnection`). -2. Боевая сцена: `MatchView`, проекция событий, анимации. +1. Довести боевой UI: арты карт, зоны, анимации, скип/ускорение. +2. Реконнект на всех шагах и аккуратная обработка таймаутов. 3. Полный декбилдер с фильтрами и счётчиками. 4. Магазин и открытие бустера. 5. История матчей и реплей. @@ -78,6 +83,11 @@ MessagePack-сериализатор и юнион-кадры. макеты. DOTween пока не добавлен, чтобы не тянуть Asset Store-зависимость в первый коммит. - Мета-экраны — заглушки: сеть и авторизация настоящие, разделы следующие. +- Бой — gray-box: карты показываются по `printingId`, без артов и анимаций; + действия — кнопки всех `MatchView.LegalIntents`. +- `GameConnection` использует `System.Net.WebSockets.ClientWebSocket` + (Windows/десктоп first). Для WebGL понадобится другой транспорт или + нативный WebSocket-плагин. ## Проверки diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index e6885e7..c357930 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -524,17 +524,23 @@ CI подключён и проходит; серверный блок закр - коллекция: `/api/collection`, балансы, разбор в пыль с подтверждением; - колоды: `/api/decks`, удаление и автосборка стартовой колоды из выбранной триал-деки (50 карт); +- очередь classic-ranked, опрос статуса, отмена, тикет и `wsUrl`; +- `GameConnection` (WebSocket + MessagePack), handshake `ClientHello`, + snapshot/events, интенты с ответом сервера, ping/pong, закрытие; +- серый боевой экран: доска, рука, часы, все легальные интенты кнопками, + сдача с подтверждением, результат; - `Vanguard.Shared.dll` (netstandard2.1) и MessagePack 3.1.12 подключены как плагины; `client/README.md` описывает запуск и ограничения; - EditMode и PlayMode smoke-тесты `Vanguard.Tests.Editor` / `Vanguard.Tests.PlayMode` собирают все экраны и проверяют запуск приложения; live-probe (не входит в набор) проверил онбординг, - коллекцию, создание и удаление колоды против стенда. + коллекцию, создание/удаление колоды, а также два WebSocket-подключения, + маллиган, сдачу и `GameFinished` против стенда. Дальше по клиенту: -1. Очередь → тикет → `GameConnection` (WebSocket + MessagePack). -2. Боевая сцена: `MatchView`, проекция событий, анимации. +1. Довести боевой UI: арты, зоны, анимации, скип/ускорение. +2. Реконнект на всех шагах и аккуратная обработка таймаутов. 3. Полный декбилдер с фильтрами и счётчиками. 4. Магазин и открытие бустера. 5. История матчей и реплей. @@ -586,6 +592,9 @@ CI подключён и проходит; серверный блок закр - **Клиент: HTTP.** В Unity по умолчанию запрещены незащищённые соединения: для dev-стенда в `client/ProjectSettings/ProjectSettings.asset` стоит `insecureHttpOption: 2` (Always). Для релиза — HTTPS и вернуть запрет. +- **Клиент: WebSocket.** `GameConnection` использует + `System.Net.WebSockets.ClientWebSocket`; это окно для Windows-десктопа. + Для WebGL/мобильного IL2CPP нужен отдельный транспорт/плагин. - **Кодировка.** Не использовать PowerShell `Get-Content`/`Set-Content` для UTF-8 файлов с русским текстом: однажды это уже испортило комментарии. Правки — только через `edit`/`write`, либо Python с `encoding="utf-8"`.