Add blocking reconnect overlay with automatic retries
CI / test (push) Successful in 2m13s Details

This commit is contained in:
MpakobecEx 2026-10-23 16:59:51 +05:00
parent 1c0cb4ecb0
commit e81b966867
4 changed files with 244 additions and 34 deletions

View File

@ -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<bool> 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()

View File

@ -87,7 +87,7 @@ PlayerSettings:
androidApplicationEntry: 1
defaultIsNativeResolution: 0
macRetinaSupport: 1
runInBackground: 0
runInBackground: 1
callOnDisableOnAssetBundleUnload: 1
muteOtherAudioSources: 0
Prepare IOS For Recording: 0

View File

@ -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**.

View File

@ -91,6 +91,12 @@
overlay, а не выбирает случайную.
- В фазе защиты доступны «Защититься» (если есть допустимые карты) и
«Не защищаться»; выбор карты защиты и сброса идёт через overlay.
- Локальный standalone работает в фоне (`runInBackground`), чтобы heartbeat
WebSocket не останавливался при потере фокуса.
- При потере соединения весь бой блокируется полноэкранным overlay
«Соединение потеряно»; автоматический reconnect идёт с задержками
0/2/10/30 с, после успеха overlay скрывается и поле показывается снова.
Кнопка внутри overlay позволяет выйти в меню.
- Правую панель действий можно временно скрыть кнопкой «Скрыть панель»
в верхней строке; поле остаётся видимым, вернуть — «Показать панель».
При скрытии панель фаз и блоки противника сдвигаются вправо.