Add blocking reconnect overlay with automatic retries
CI / test (push) Successful in 2m13s
Details
CI / test (push) Successful in 2m13s
Details
This commit is contained in:
parent
1c0cb4ecb0
commit
e81b966867
|
|
@ -60,6 +60,12 @@ namespace Vanguard.UI
|
||||||
|
|
||||||
private GameConnection _connection;
|
private GameConnection _connection;
|
||||||
private CancellationTokenSource _cts;
|
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 MatchConnectionInfo _info;
|
||||||
private MatchView _view;
|
private MatchView _view;
|
||||||
private bool _connecting;
|
private bool _connecting;
|
||||||
|
|
@ -213,6 +219,7 @@ namespace Vanguard.UI
|
||||||
BuildOverlaysAndZones();
|
BuildOverlaysAndZones();
|
||||||
BuildPhasePanel();
|
BuildPhasePanel();
|
||||||
BuildCombatStats();
|
BuildCombatStats();
|
||||||
|
BuildReconnectOverlay();
|
||||||
_cardMenu = new CardActionMenuView();
|
_cardMenu = new CardActionMenuView();
|
||||||
_cardMenu.Build(_root);
|
_cardMenu.Build(_root);
|
||||||
|
|
||||||
|
|
@ -233,6 +240,7 @@ namespace Vanguard.UI
|
||||||
public void Show(MatchConnectionInfo info)
|
public void Show(MatchConnectionInfo info)
|
||||||
{
|
{
|
||||||
_info = info;
|
_info = info;
|
||||||
|
CancelReconnect();
|
||||||
_view = null;
|
_view = null;
|
||||||
_finished = false;
|
_finished = false;
|
||||||
_confirmConcede = false;
|
_confirmConcede = false;
|
||||||
|
|
@ -286,6 +294,7 @@ namespace Vanguard.UI
|
||||||
|
|
||||||
public void Hide()
|
public void Hide()
|
||||||
{
|
{
|
||||||
|
CancelReconnect();
|
||||||
_cts?.Cancel();
|
_cts?.Cancel();
|
||||||
_cts?.Dispose();
|
_cts?.Dispose();
|
||||||
_cts = null;
|
_cts = null;
|
||||||
|
|
@ -294,6 +303,7 @@ namespace Vanguard.UI
|
||||||
{
|
{
|
||||||
var connection = _connection;
|
var connection = _connection;
|
||||||
_connection = null;
|
_connection = null;
|
||||||
|
DetachConnection(connection);
|
||||||
_ = connection.DisposeAsync().AsTask();
|
_ = 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)
|
if (_connecting || _info == null)
|
||||||
return;
|
return false;
|
||||||
|
|
||||||
_connecting = true;
|
_connecting = true;
|
||||||
_reconnect.gameObject.SetActive(false);
|
_reconnect.gameObject.SetActive(false);
|
||||||
_cts?.Cancel();
|
_cts?.Cancel();
|
||||||
_cts?.Dispose();
|
_cts?.Dispose();
|
||||||
_cts = new CancellationTokenSource();
|
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_connection != null)
|
if (_connection != null)
|
||||||
{
|
{
|
||||||
await _connection.DisposeAsync();
|
var previous = _connection;
|
||||||
_connection = null;
|
_connection = null;
|
||||||
|
DetachConnection(previous);
|
||||||
|
await previous.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
var connection = new GameConnection();
|
var connection = new GameConnection();
|
||||||
|
|
@ -586,16 +598,20 @@ namespace Vanguard.UI
|
||||||
_cts.Token);
|
_cts.Token);
|
||||||
|
|
||||||
SetStatus("Соединение открыто. Ожидаем состояние…", Muted);
|
SetStatus("Соединение открыто. Ожидаем состояние…", Muted);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// экран закрыли
|
return false;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
Debug.LogException(exception);
|
Debug.LogException(exception);
|
||||||
SetStatus("Не удалось подключиться: " + exception.Message, new Color(1f, 0.55f, 0.45f));
|
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
|
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)
|
private void OnHello(ServerHello hello)
|
||||||
{
|
{
|
||||||
if (_info != null)
|
if (_info != null)
|
||||||
_info.Seat = hello.PlayerIndex;
|
_info.Seat = hello.PlayerIndex;
|
||||||
|
if (_reconnecting)
|
||||||
|
FinishReconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSnapshot(ServerSnapshot snapshot)
|
private void OnSnapshot(ServerSnapshot snapshot)
|
||||||
|
|
@ -614,6 +645,9 @@ namespace Vanguard.UI
|
||||||
if (!_root.gameObject.activeSelf || snapshot?.View == null)
|
if (!_root.gameObject.activeSelf || snapshot?.View == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (_reconnecting)
|
||||||
|
FinishReconnect();
|
||||||
|
|
||||||
_view = snapshot.View;
|
_view = snapshot.View;
|
||||||
_finished = _view.IsFinished;
|
_finished = _view.IsFinished;
|
||||||
_pendingIntents.Clear();
|
_pendingIntents.Clear();
|
||||||
|
|
@ -634,6 +668,9 @@ namespace Vanguard.UI
|
||||||
{
|
{
|
||||||
foreach (var envelope in events.Events)
|
foreach (var envelope in events.Events)
|
||||||
{
|
{
|
||||||
|
if (envelope.Event is GameFinishedView)
|
||||||
|
_finished = true;
|
||||||
|
|
||||||
var line = Describe(envelope.Event);
|
var line = Describe(envelope.Event);
|
||||||
if (!string.IsNullOrWhiteSpace(line))
|
if (!string.IsNullOrWhiteSpace(line))
|
||||||
_logLines.Add(line);
|
_logLines.Add(line);
|
||||||
|
|
@ -776,11 +813,142 @@ namespace Vanguard.UI
|
||||||
|
|
||||||
private void OnClosed(string reason)
|
private void OnClosed(string reason)
|
||||||
{
|
{
|
||||||
if (!_root.gameObject.activeSelf || _finished)
|
if (!_root.gameObject.activeSelf || _finished || _view?.IsFinished == true)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
SetStatus("Соединение потеряно: " + reason, new Color(1f, 0.60f, 0.45f));
|
BeginReconnect(reason);
|
||||||
_reconnect.gameObject.SetActive(true);
|
}
|
||||||
|
|
||||||
|
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()
|
private async Task RequestSnapshotAsync()
|
||||||
|
|
@ -888,6 +1056,59 @@ namespace Vanguard.UI
|
||||||
ApplyIntentPanelLayout();
|
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()
|
private void ApplyIntentPanelLayout()
|
||||||
{
|
{
|
||||||
var shift = _intentPanelVisible ? 0f : IntentPanelShift;
|
var shift = _intentPanelVisible ? 0f : IntentPanelShift;
|
||||||
|
|
@ -916,34 +1137,12 @@ namespace Vanguard.UI
|
||||||
OnIntentClicked(new ConcedeIntent());
|
OnIntentClicked(new ConcedeIntent());
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnReconnectClicked()
|
private void OnReconnectClicked()
|
||||||
{
|
{
|
||||||
if (_info == null)
|
if (_info == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_reconnect.interactable = false;
|
BeginReconnect("Переподключение…");
|
||||||
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()
|
private void OnExitClicked()
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ PlayerSettings:
|
||||||
androidApplicationEntry: 1
|
androidApplicationEntry: 1
|
||||||
defaultIsNativeResolution: 0
|
defaultIsNativeResolution: 0
|
||||||
macRetinaSupport: 1
|
macRetinaSupport: 1
|
||||||
runInBackground: 0
|
runInBackground: 1
|
||||||
callOnDisableOnAssetBundleUnload: 1
|
callOnDisableOnAssetBundleUnload: 1
|
||||||
muteOtherAudioSources: 0
|
muteOtherAudioSources: 0
|
||||||
Prepare IOS For Recording: 0
|
Prepare IOS For Recording: 0
|
||||||
|
|
|
||||||
|
|
@ -1009,5 +1009,10 @@ Battle; Boot и общие сервисы остаются персистент
|
||||||
- UI: reference-разрешение **1920×1080**, CanvasScaler `matchWidthOrHeight
|
- UI: reference-разрешение **1920×1080**, CanvasScaler `matchWidthOrHeight
|
||||||
= 0.5`; standalone принудительно `1920×1080 FullScreenWindow`,
|
= 0.5`; standalone принудительно `1920×1080 FullScreenWindow`,
|
||||||
`defaultIsNativeResolution = 0`.
|
`defaultIsNativeResolution = 0`.
|
||||||
|
- Потеря WebSocket: полноэкранный `ReconnectOverlay` блокирует бой,
|
||||||
|
автоматические попытки 0/2/10/30 с, после `ServerHello` overlay
|
||||||
|
скрывается; в overlay есть выход в меню. Игра работает в фоне
|
||||||
|
(`runInBackground = 1`), чтобы heartbeat не останавливался при потере
|
||||||
|
фокуса.
|
||||||
- Проверки: сервер **142** правил + **45** серверных, `validate` и
|
- Проверки: сервер **142** правил + **45** серверных, `validate` и
|
||||||
`translations check` OK; клиент EditMode **30/30**.
|
`translations check` OK; клиент EditMode **30/30**.
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,12 @@
|
||||||
overlay, а не выбирает случайную.
|
overlay, а не выбирает случайную.
|
||||||
- В фазе защиты доступны «Защититься» (если есть допустимые карты) и
|
- В фазе защиты доступны «Защититься» (если есть допустимые карты) и
|
||||||
«Не защищаться»; выбор карты защиты и сброса идёт через overlay.
|
«Не защищаться»; выбор карты защиты и сброса идёт через overlay.
|
||||||
|
- Локальный standalone работает в фоне (`runInBackground`), чтобы heartbeat
|
||||||
|
WebSocket не останавливался при потере фокуса.
|
||||||
|
- При потере соединения весь бой блокируется полноэкранным overlay
|
||||||
|
«Соединение потеряно»; автоматический reconnect идёт с задержками
|
||||||
|
0/2/10/30 с, после успеха overlay скрывается и поле показывается снова.
|
||||||
|
Кнопка внутри overlay позволяет выйти в меню.
|
||||||
- Правую панель действий можно временно скрыть кнопкой «Скрыть панель»
|
- Правую панель действий можно временно скрыть кнопкой «Скрыть панель»
|
||||||
в верхней строке; поле остаётся видимым, вернуть — «Показать панель».
|
в верхней строке; поле остаётся видимым, вернуть — «Показать панель».
|
||||||
При скрытии панель фаз и блоки противника сдвигаются вправо.
|
При скрытии панель фаз и блоки противника сдвигаются вправо.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue