diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index a1ca21b..3ea1cff 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -60,6 +60,12 @@ namespace Vanguard.UI 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 MatchConnectionInfo _info; private MatchView _view; private bool _connecting; @@ -213,6 +219,7 @@ namespace Vanguard.UI BuildOverlaysAndZones(); BuildPhasePanel(); BuildCombatStats(); + BuildReconnectOverlay(); _cardMenu = new CardActionMenuView(); _cardMenu.Build(_root); @@ -233,6 +240,7 @@ namespace Vanguard.UI public void Show(MatchConnectionInfo info) { _info = info; + CancelReconnect(); _view = null; _finished = false; _confirmConcede = false; @@ -286,6 +294,7 @@ namespace Vanguard.UI public void Hide() { + CancelReconnect(); _cts?.Cancel(); _cts?.Dispose(); _cts = null; @@ -294,6 +303,7 @@ namespace Vanguard.UI { var connection = _connection; _connection = null; + DetachConnection(connection); _ = connection.DisposeAsync().AsTask(); } @@ -550,23 +560,25 @@ namespace Vanguard.UI } } - private async Task ConnectAsync() + private async Task ConnectAsync(CancellationToken cancellationToken = default) { if (_connecting || _info == null) - return; + return false; _connecting = true; _reconnect.gameObject.SetActive(false); _cts?.Cancel(); _cts?.Dispose(); - _cts = new CancellationTokenSource(); + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); try { if (_connection != null) { - await _connection.DisposeAsync(); + var previous = _connection; _connection = null; + DetachConnection(previous); + await previous.DisposeAsync(); } var connection = new GameConnection(); @@ -586,16 +598,20 @@ namespace Vanguard.UI _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(true); + _reconnect.gameObject.SetActive(false); + if (!_reconnecting) + BeginReconnect("Не удалось подключиться: " + exception.Message); + return false; } finally { @@ -603,10 +619,25 @@ namespace Vanguard.UI } } + 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) @@ -614,6 +645,9 @@ namespace Vanguard.UI if (!_root.gameObject.activeSelf || snapshot?.View == null) return; + if (_reconnecting) + FinishReconnect(); + _view = snapshot.View; _finished = _view.IsFinished; _pendingIntents.Clear(); @@ -634,6 +668,9 @@ namespace Vanguard.UI { 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); @@ -776,11 +813,142 @@ namespace Vanguard.UI private void OnClosed(string reason) { - if (!_root.gameObject.activeSelf || _finished) + if (!_root.gameObject.activeSelf || _finished || _view?.IsFinished == true) return; - SetStatus("Соединение потеряно: " + reason, new Color(1f, 0.60f, 0.45f)); - _reconnect.gameObject.SetActive(true); + 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() @@ -888,6 +1056,59 @@ namespace Vanguard.UI ApplyIntentPanelLayout(); } + private void BuildReconnectOverlay() + { + _reconnectOverlay = UiKit.CreatePanel( + _root, + "ReconnectOverlay", + new Color(0f, 0f, 0f, 0.86f)); + var card = UiKit.CreateCenteredCard( + _reconnectOverlay, + "ReconnectCard", + new Vector2(680f, 280f), + Panel); + UiKit.AddVerticalLayout(card, 22, 10); + + var title = UiKit.CreateText( + card, + "ReconnectTitle", + "Соединение потеряно", + 26, + new Color(1f, 0.78f, 0.42f), + TextAnchor.MiddleCenter); + title.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(title, 42f); + + _reconnectOverlayHint = UiKit.CreateText( + card, + "ReconnectHint", + "Переподключаемся…", + 18, + Color.white, + TextAnchor.MiddleCenter); + UiKit.SetPreferredHeight(_reconnectOverlayHint, 32f); + + _reconnectOverlayText = UiKit.CreateText( + card, + "ReconnectReason", + string.Empty, + 14, + new Color(0.72f, 0.80f, 0.94f), + TextAnchor.MiddleCenter); + _reconnectOverlayText.horizontalOverflow = HorizontalWrapMode.Wrap; + UiKit.SetPreferredHeight(_reconnectOverlayText, 64f); + + var exit = UiKit.CreateButton( + card, + "ReconnectExit", + "Выйти в меню", + OnExitClicked, + 17); + UiKit.SetPreferredHeight(exit, 44f); + + _reconnectOverlay.gameObject.SetActive(false); + } + private void ApplyIntentPanelLayout() { var shift = _intentPanelVisible ? 0f : IntentPanelShift; @@ -916,34 +1137,12 @@ namespace Vanguard.UI OnIntentClicked(new ConcedeIntent()); } - private async void OnReconnectClicked() + private 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; - } + BeginReconnect("Переподключение…"); } private void OnExitClicked() diff --git a/client/ProjectSettings/ProjectSettings.asset b/client/ProjectSettings/ProjectSettings.asset index eb7067e..8e532d6 100644 --- a/client/ProjectSettings/ProjectSettings.asset +++ b/client/ProjectSettings/ProjectSettings.asset @@ -87,7 +87,7 @@ PlayerSettings: androidApplicationEntry: 1 defaultIsNativeResolution: 0 macRetinaSupport: 1 - runInBackground: 0 + runInBackground: 1 callOnDisableOnAssetBundleUnload: 1 muteOtherAudioSources: 0 Prepare IOS For Recording: 0 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 9cff51a..4ef1290 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1009,5 +1009,10 @@ Battle; Boot и общие сервисы остаются персистент - UI: reference-разрешение **1920×1080**, CanvasScaler `matchWidthOrHeight = 0.5`; standalone принудительно `1920×1080 FullScreenWindow`, `defaultIsNativeResolution = 0`. +- Потеря WebSocket: полноэкранный `ReconnectOverlay` блокирует бой, + автоматические попытки 0/2/10/30 с, после `ServerHello` overlay + скрывается; в overlay есть выход в меню. Игра работает в фоне + (`runInBackground = 1`), чтобы heartbeat не останавливался при потере + фокуса. - Проверки: сервер **142** правил + **45** серверных, `validate` и `translations check` OK; клиент EditMode **30/30**. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 73710d9..a9f3bb3 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -91,6 +91,12 @@ overlay, а не выбирает случайную. - В фазе защиты доступны «Защититься» (если есть допустимые карты) и «Не защищаться»; выбор карты защиты и сброса идёт через overlay. +- Локальный standalone работает в фоне (`runInBackground`), чтобы heartbeat + WebSocket не останавливался при потере фокуса. +- При потере соединения весь бой блокируется полноэкранным overlay + «Соединение потеряно»; автоматический reconnect идёт с задержками + 0/2/10/30 с, после успеха overlay скрывается и поле показывается снова. + Кнопка внутри overlay позволяет выйти в меню. - Правую панель действий можно временно скрыть кнопкой «Скрыть панель» в верхней строке; поле остаётся видимым, вернуть — «Показать панель». При скрытии панель фаз и блоки противника сдвигаются вправо.