Add matchmaking queue, WebSocket connection and gray-box battle
CI / test (push) Successful in 1m21s Details

This commit is contained in:
MpakobecEx 2026-10-21 17:28:15 +05:00
parent 1aca632958
commit faf7127e14
20 changed files with 1538 additions and 38 deletions

View File

@ -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 + переигрывание очереди).
**Готово, когда:** полный матч играется мышью без консоли и без рассинхрона с сервером.

View File

@ -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 бой |
| Лаунчер | не начато |
## Требования

View File

@ -73,6 +73,11 @@ namespace Vanguard.App
_root.ShowLogin("Введите данные игрока.");
}
private void Update()
{
_root?.Tick();
}
private void OnDestroy()
{
if (_container is IDisposable disposable)

View File

@ -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<AuthResponse> LoginAsync(string userName, string password)
{
var response = await _api.PostAsync<AuthResponse>(

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6b4976ff16294e6dba7e193dc27cefcb
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
{
/// <summary>
/// WebSocket-клиент матча. Кадры — MessagePack из Vanguard.Shared.
/// Чтение идёт в async-цикле на главном Unity-потоке, поэтому события
/// можно безопасно обрабатывать в UI.
/// </summary>
public sealed class GameConnection : IAsyncDisposable
{
private readonly ClientWebSocket _socket = new ClientWebSocket();
private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
private readonly ConcurrentDictionary<string, TaskCompletionSource<ServerIntentResult>> _pending =
new ConcurrentDictionary<string, TaskCompletionSource<ServerIntentResult>>();
private CancellationTokenSource _cts;
private Task _readTask;
private Task _pingTask;
private int _closed;
public event Action<ServerHello> HelloReceived;
public event Action<ServerSnapshot> SnapshotReceived;
public event Action<ServerEvents> EventsReceived;
public event Action<ServerIntentResult> IntentResultReceived;
public event Action<ServerClock> ClockReceived;
public event Action<ServerError> ErrorReceived;
public event Action<string> 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<ServerIntentResult> SubmitAsync(
IntentPayload intent,
CancellationToken cancellationToken)
{
var intentId = Guid.NewGuid().ToString("N");
var completion = new TaskCompletionSource<ServerIntentResult>();
_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<byte>(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<byte>(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();
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a5e35a7223c142e18f8a3ccd4e4cbd20
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,12 @@
namespace Vanguard.Battle
{
/// <summary>Данные, которые нужны для входа в матч после очереди.</summary>
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;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 76c2f6cd9b004cf998a031e4862bc9dd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -19,6 +19,8 @@ namespace Vanguard.Meta
_player = player;
}
public string BaseUrl => _api.BaseUrl;
public async Task<StoreResponse> GetStoreAsync()
{
var store = await _api.GetAsync<StoreResponse>("/api/store");
@ -77,5 +79,22 @@ namespace Vanguard.Meta
await _api.PostAsync<DeckValidationResponse>(
"/api/decks/" + deckId.ToString("D") + "/validate",
new DeckRequest { Cards = cards });
public async Task<QueueAccepted> EnqueueAsync(Guid deckId, string mode) =>
await _api.PostAsync<QueueAccepted>(
"/api/game/queue",
new QueueRequest { DeckId = deckId, Mode = mode });
public async Task<QueueStatusResponse> GetQueueStatusAsync(Guid queueId) =>
await _api.GetAsync<QueueStatusResponse>(
"/api/game/queue/" + queueId.ToString("D"));
public Task CancelQueueAsync(Guid queueId) =>
_api.DeleteAsync("/api/game/queue/" + queueId.ToString("D"));
public async Task<TicketResponse> CreateTicketAsync(string matchId) =>
await _api.PostAsync<TicketResponse>(
"/api/game/tickets",
new TicketRequest { MatchId = matchId });
}
}

View File

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

View File

@ -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
{
/// <summary>
/// Серый боевой экран: доска, рука, часы и кнопки всех легальных интентов.
/// Красоту и анимации добавим поверх этой рабочей логики.
/// </summary>
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<Text>();
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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
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<IntentPayload>();
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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 46303062fea5408dbe688c66aa184dbd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -17,6 +17,7 @@ namespace Vanguard.UI
private readonly MetaService _meta;
private readonly PlayerState _player;
private readonly Action _onBack;
private readonly Action<DeckDto> _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<DeckDto> 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)

View File

@ -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
{
/// <summary>Очередь classic-ranked и переход к матчу по тикету.</summary>
public sealed class QueueView
{
private readonly MetaService _meta;
private readonly AuthService _auth;
private readonly Action _onBack;
private readonly Action<MatchConnectionInfo> _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<MatchConnectionInfo> 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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8b5314c1af0c4fdc90a52684ae17af8e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -35,6 +35,8 @@ namespace Vanguard.Tests
Assert.IsTrue(Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == "OnboardingScreen"));
Assert.IsTrue(Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == "CollectionScreen"));
Assert.IsTrue(Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == "DecksScreen"));
Assert.IsTrue(Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == "QueueScreen"));
Assert.IsTrue(Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == "BattleScreen"));
Cleanup();
}

View File

@ -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<VanguardApp>() == null)
{
@ -29,6 +29,8 @@ namespace Vanguard.Tests
.FirstOrDefault(go => go.name == "LoginScreen");
var onboarding = Resources.FindObjectsOfTypeAll<GameObject>()
.FirstOrDefault(go => go.name == "OnboardingScreen");
var meta = Resources.FindObjectsOfTypeAll<GameObject>()
.FirstOrDefault(go => go.name == "MetaScreen");
var hierarchy = string.Join(
"\n",
Resources.FindObjectsOfTypeAll<GameObject>()
@ -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();

View File

@ -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-плагин.
## Проверки

View File

@ -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"`.