Add matchmaking queue, WebSocket connection and gray-box battle
CI / test (push) Successful in 1m21s
Details
CI / test (push) Successful in 1m21s
Details
This commit is contained in:
parent
1aca632958
commit
faf7127e14
|
|
@ -231,10 +231,11 @@
|
||||||
- [x] Сцена `Boot` + runtime bootstrap; `Meta`/`Battle` появятся дальше.
|
- [x] Сцена `Boot` + runtime bootstrap; `Meta`/`Battle` появятся дальше.
|
||||||
- [x] Composition root + DI (VContainer), без синглтон-свалок.
|
- [x] Composition root + DI (VContainer), без синглтон-свалок.
|
||||||
- [x] `ApiClient` (HTTP/JSON), `PlayerState`, таймауты и разбор `ApiError`.
|
- [x] `ApiClient` (HTTP/JSON), `PlayerState`, таймауты и разбор `ApiError`.
|
||||||
- [ ] `GameConnection` (WebSocket + MessagePack), очередь событий,
|
- [x] `GameConnection` (WebSocket + MessagePack), snapshot/events,
|
||||||
`ClientGameState`.
|
`ClientGameState` в виде `CurrentView` + `BattleView`, очередь и тикет.
|
||||||
- [x] Экраны: логин/регистрация, главное меню, онбординг, коллекция,
|
- [x] Экраны: логин/регистрация, главное меню, онбординг, коллекция,
|
||||||
список колод и автосборка стартовой; магазин/полный декбилдер — дальше.
|
список колод и автосборка стартовой; магазин/полный декбилдер — дальше.
|
||||||
|
- [x] Очередь: classic-ranked, опрос, отмена, тикет, переход в матч.
|
||||||
- [ ] Декбилдер: фильтры (клан, грейд, редкость, тип, арт), счётчики,
|
- [ ] Декбилдер: фильтры (клан, грейд, редкость, тип, арт), счётчики,
|
||||||
валидация в реальном времени.
|
валидация в реальном времени.
|
||||||
- [x] Базовая коллекция: список, балансы, разбор в пыль с подтверждением;
|
- [x] Базовая коллекция: список, балансы, разбор в пыль с подтверждением;
|
||||||
|
|
@ -249,16 +250,20 @@
|
||||||
|
|
||||||
## 8. Клиент: боевая сцена `[20–30 дней]`
|
## 8. Клиент: боевая сцена `[20–30 дней]`
|
||||||
|
|
||||||
- [ ] Поле: Vanguard, 2 колонки × 2 ряда, Guardian, рука, Damage, Soul, Drop, Trigger.
|
**Первый gray-box есть:** доска, рука, сила/крит, часы, все легальные
|
||||||
- [ ] Старт: выбор grade 0, маллиган.
|
интенты кнопками, сдача и результат. Арты и анимации — следующие.
|
||||||
- [ ] Ввод: клик/тап → контекстное меню → подсветка валидных целей.
|
|
||||||
- [ ] Отображение силы/крита с модификаторами и подсветкой баффов.
|
- [x] Поле: Vanguard, 5 кругов Арьергарда, рука, damage/soul/drop-счётчики
|
||||||
|
(без артов и анимаций).
|
||||||
|
- [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»).
|
||||||
|
- [x] Ввод: кнопки легальных интентов; подсветка целей — дальше.
|
||||||
|
- [x] Отображение силы/крита из `MatchView`; модификаторы уже посчитаны сервером.
|
||||||
- [ ] Очередь анимаций строго по событиям сервера: ride, call, attack, drive, damage, retire, boost, guard.
|
- [ ] Очередь анимаций строго по событиям сервера: ride, call, attack, drive, damage, retire, boost, guard.
|
||||||
- [ ] Скип/ускорение анимаций.
|
- [ ] Скип/ускорение анимаций.
|
||||||
|
- [x] Таймер хода, индикатор реконнекта, кнопка сдачи (базово).
|
||||||
|
- [x] Экран результата: победа/поражение; Gold/XP и «ещё матч» — дальше.
|
||||||
|
- [x] Догон состояния по `resync`/snapshot после реконнекта (без переигрывания анимаций).
|
||||||
- [ ] Экран противника: таймлайн и текстовый лог боя.
|
- [ ] Экран противника: таймлайн и текстовый лог боя.
|
||||||
- [ ] Таймер хода, индикатор реконнекта, кнопка сдачи.
|
|
||||||
- [ ] Экран результата: победа/поражение, Gold/XP, кнопка «ещё матч».
|
|
||||||
- [ ] Догон состояния после реконнекта (resync + переигрывание очереди).
|
|
||||||
|
|
||||||
**Готово, когда:** полный матч играется мышью без консоли и без рассинхрона с сервером.
|
**Готово, когда:** полный матч играется мышью без консоли и без рассинхрона с сервером.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
|
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
|
||||||
| EF-миграции | готово (`InitialCreate`) |
|
| EF-миграции | готово (`InitialCreate`) |
|
||||||
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
|
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
|
||||||
| Клиент | каркас: Unity 6.6, логин, онбординг, коллекция, колоды |
|
| Клиент | Unity 6.6: логин, онбординг, коллекция, колоды, очередь, gray-box бой |
|
||||||
| Лаунчер | не начато |
|
| Лаунчер | не начато |
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,11 @@ namespace Vanguard.App
|
||||||
_root.ShowLogin("Введите данные игрока.");
|
_root.ShowLogin("Введите данные игрока.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void Update()
|
||||||
|
{
|
||||||
|
_root?.Tick();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
if (_container is IDisposable disposable)
|
if (_container is IDisposable disposable)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,12 @@ namespace Vanguard.Auth
|
||||||
|
|
||||||
public bool IsAuthenticated => _tokens.HasRefreshToken;
|
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)
|
public async Task<AuthResponse> LoginAsync(string userName, string password)
|
||||||
{
|
{
|
||||||
var response = await _api.PostAsync<AuthResponse>(
|
var response = await _api.PostAsync<AuthResponse>(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6b4976ff16294e6dba7e193dc27cefcb
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a5e35a7223c142e18f8a3ccd4e4cbd20
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 76c2f6cd9b004cf998a031e4862bc9dd
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -19,6 +19,8 @@ namespace Vanguard.Meta
|
||||||
_player = player;
|
_player = player;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string BaseUrl => _api.BaseUrl;
|
||||||
|
|
||||||
public async Task<StoreResponse> GetStoreAsync()
|
public async Task<StoreResponse> GetStoreAsync()
|
||||||
{
|
{
|
||||||
var store = await _api.GetAsync<StoreResponse>("/api/store");
|
var store = await _api.GetAsync<StoreResponse>("/api/store");
|
||||||
|
|
@ -77,5 +79,22 @@ namespace Vanguard.Meta
|
||||||
await _api.PostAsync<DeckValidationResponse>(
|
await _api.PostAsync<DeckValidationResponse>(
|
||||||
"/api/decks/" + deckId.ToString("D") + "/validate",
|
"/api/decks/" + deckId.ToString("D") + "/validate",
|
||||||
new DeckRequest { Cards = cards });
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using Vanguard.Api.Contracts;
|
||||||
using Vanguard.App;
|
using Vanguard.App;
|
||||||
using Vanguard.Auth;
|
using Vanguard.Auth;
|
||||||
|
using Vanguard.Battle;
|
||||||
using Vanguard.Meta;
|
using Vanguard.Meta;
|
||||||
using Vanguard.State;
|
using Vanguard.State;
|
||||||
|
|
||||||
|
|
@ -19,6 +21,8 @@ namespace Vanguard.UI
|
||||||
private OnboardingView _onboarding;
|
private OnboardingView _onboarding;
|
||||||
private CollectionView _collection;
|
private CollectionView _collection;
|
||||||
private DecksView _decks;
|
private DecksView _decks;
|
||||||
|
private QueueView _queue;
|
||||||
|
private BattleView _battle;
|
||||||
|
|
||||||
public AppRootView(
|
public AppRootView(
|
||||||
AppConfig config,
|
AppConfig config,
|
||||||
|
|
@ -51,17 +55,23 @@ namespace Vanguard.UI
|
||||||
_collection = new CollectionView(
|
_collection = new CollectionView(
|
||||||
canvas.transform, _metaService, _player, ShowMeta);
|
canvas.transform, _metaService, _player, ShowMeta);
|
||||||
_decks = new DecksView(
|
_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("Проверяем сессию…");
|
ShowLogin("Проверяем сессию…");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Tick()
|
||||||
|
{
|
||||||
|
_battle?.Tick();
|
||||||
|
}
|
||||||
|
|
||||||
public void ShowLogin(string status = null)
|
public void ShowLogin(string status = null)
|
||||||
{
|
{
|
||||||
_metaView.Hide();
|
HideAll();
|
||||||
_onboarding.Hide();
|
|
||||||
_collection.Hide();
|
|
||||||
_decks.Hide();
|
|
||||||
_login.Show(status);
|
_login.Show(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,38 +85,49 @@ namespace Vanguard.UI
|
||||||
|
|
||||||
public void ShowMeta()
|
public void ShowMeta()
|
||||||
{
|
{
|
||||||
_login.Hide();
|
HideAll();
|
||||||
_onboarding.Hide();
|
|
||||||
_collection.Hide();
|
|
||||||
_decks.Hide();
|
|
||||||
_metaView.Show();
|
_metaView.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowOnboarding()
|
public void ShowOnboarding()
|
||||||
{
|
{
|
||||||
_login.Hide();
|
HideAll();
|
||||||
_metaView.Hide();
|
|
||||||
_collection.Hide();
|
|
||||||
_decks.Hide();
|
|
||||||
_onboarding.Show();
|
_onboarding.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowCollection()
|
public void ShowCollection()
|
||||||
{
|
{
|
||||||
_login.Hide();
|
HideAll();
|
||||||
_metaView.Hide();
|
|
||||||
_onboarding.Hide();
|
|
||||||
_decks.Hide();
|
|
||||||
_collection.Show();
|
_collection.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowDecks()
|
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();
|
_login.Hide();
|
||||||
_metaView.Hide();
|
_metaView.Hide();
|
||||||
_onboarding.Hide();
|
_onboarding.Hide();
|
||||||
_collection.Hide();
|
_collection.Hide();
|
||||||
_decks.Show();
|
_decks.Hide();
|
||||||
|
_queue.Hide();
|
||||||
|
_battle.Hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAuthenticated()
|
private void OnAuthenticated()
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 46303062fea5408dbe688c66aa184dbd
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -17,6 +17,7 @@ namespace Vanguard.UI
|
||||||
private readonly MetaService _meta;
|
private readonly MetaService _meta;
|
||||||
private readonly PlayerState _player;
|
private readonly PlayerState _player;
|
||||||
private readonly Action _onBack;
|
private readonly Action _onBack;
|
||||||
|
private readonly Action<DeckDto> _onPlay;
|
||||||
|
|
||||||
private readonly RectTransform _root;
|
private readonly RectTransform _root;
|
||||||
private RectTransform _content;
|
private RectTransform _content;
|
||||||
|
|
@ -32,11 +33,13 @@ namespace Vanguard.UI
|
||||||
Transform parent,
|
Transform parent,
|
||||||
MetaService meta,
|
MetaService meta,
|
||||||
PlayerState player,
|
PlayerState player,
|
||||||
Action onBack)
|
Action onBack,
|
||||||
|
Action<DeckDto> onPlay)
|
||||||
{
|
{
|
||||||
_meta = meta;
|
_meta = meta;
|
||||||
_player = player;
|
_player = player;
|
||||||
_onBack = onBack;
|
_onBack = onBack;
|
||||||
|
_onPlay = onPlay;
|
||||||
|
|
||||||
_root = UiKit.CreatePanel(parent, "DecksScreen", new Color(0.035f, 0.045f, 0.065f, 1f));
|
_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)
|
private void OnPlayClicked(DeckDto deck)
|
||||||
{
|
{
|
||||||
SetStatus("Очередь и бой подключим следующим шагом. Колода: " + deck.Name + ".", new Color(1f, 0.76f, 0.38f));
|
_onPlay(deck);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowConfirm(DeckDto deck)
|
private void ShowConfirm(DeckDto deck)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8b5314c1af0c4fdc90a52684ae17af8e
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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 == "OnboardingScreen"));
|
||||||
Assert.IsTrue(Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == "CollectionScreen"));
|
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 == "DecksScreen"));
|
||||||
|
Assert.IsTrue(Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == "QueueScreen"));
|
||||||
|
Assert.IsTrue(Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == "BattleScreen"));
|
||||||
|
|
||||||
Cleanup();
|
Cleanup();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ namespace Vanguard.Tests
|
||||||
public sealed class AppBootstrapPlayModeTests
|
public sealed class AppBootstrapPlayModeTests
|
||||||
{
|
{
|
||||||
[UnityTest]
|
[UnityTest]
|
||||||
public IEnumerator App_Bootstraps_And_Shows_A_Start_Screen()
|
public IEnumerator App_Bootstraps_And_Shows_A_Screen()
|
||||||
{
|
{
|
||||||
if (Object.FindAnyObjectByType<VanguardApp>() == null)
|
if (Object.FindAnyObjectByType<VanguardApp>() == null)
|
||||||
{
|
{
|
||||||
|
|
@ -29,6 +29,8 @@ namespace Vanguard.Tests
|
||||||
.FirstOrDefault(go => go.name == "LoginScreen");
|
.FirstOrDefault(go => go.name == "LoginScreen");
|
||||||
var onboarding = Resources.FindObjectsOfTypeAll<GameObject>()
|
var onboarding = Resources.FindObjectsOfTypeAll<GameObject>()
|
||||||
.FirstOrDefault(go => go.name == "OnboardingScreen");
|
.FirstOrDefault(go => go.name == "OnboardingScreen");
|
||||||
|
var meta = Resources.FindObjectsOfTypeAll<GameObject>()
|
||||||
|
.FirstOrDefault(go => go.name == "MetaScreen");
|
||||||
var hierarchy = string.Join(
|
var hierarchy = string.Join(
|
||||||
"\n",
|
"\n",
|
||||||
Resources.FindObjectsOfTypeAll<GameObject>()
|
Resources.FindObjectsOfTypeAll<GameObject>()
|
||||||
|
|
@ -36,11 +38,12 @@ namespace Vanguard.Tests
|
||||||
.Select(go => go.name + ": activeSelf=" + go.activeSelf + ", activeInHierarchy=" + go.activeInHierarchy));
|
.Select(go => go.name + ": activeSelf=" + go.activeSelf + ", activeInHierarchy=" + go.activeInHierarchy));
|
||||||
|
|
||||||
var hasStartScreen = (login != null && login.activeInHierarchy)
|
var hasStartScreen = (login != null && login.activeInHierarchy)
|
||||||
|| (onboarding != null && onboarding.activeInHierarchy);
|
|| (onboarding != null && onboarding.activeInHierarchy)
|
||||||
|
|| (meta != null && meta.activeInHierarchy);
|
||||||
|
|
||||||
Assert.IsTrue(
|
Assert.IsTrue(
|
||||||
hasStartScreen,
|
hasStartScreen,
|
||||||
"Ни LoginScreen, ни OnboardingScreen не активны.\nInitError: "
|
"Ни LoginScreen, ни OnboardingScreen, ни MetaScreen не активны.\nInitError: "
|
||||||
+ VanguardApp.LastInitializationError + "\n" + hierarchy);
|
+ VanguardApp.LastInitializationError + "\n" + hierarchy);
|
||||||
|
|
||||||
Cleanup();
|
Cleanup();
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,17 @@ http://localhost:5080
|
||||||
- Коллекция: список карт, балансы, разбор в пыль с подтверждением.
|
- Коллекция: список карт, балансы, разбор в пыль с подтверждением.
|
||||||
- Колоды: список, удаление, автосборка стартовой колоды из выбранной
|
- Колоды: список, удаление, автосборка стартовой колоды из выбранной
|
||||||
триал-деки.
|
триал-деки.
|
||||||
|
- Очередь: classic-ranked, опрос статуса, отмена, тикет и переход в бой.
|
||||||
|
- `GameConnection`: WebSocket + MessagePack, handshake, snapshot/events,
|
||||||
|
интенты, ответы на ping, закрытие и переподключение.
|
||||||
|
- Боевой gray-box: доска, рука, часы, кнопки всех легальных интентов,
|
||||||
|
сдача с подтверждением и экран результата.
|
||||||
- Экран входа и главный экран с балансами, MMR и переходами в разделы.
|
- Экран входа и главный экран с балансами, MMR и переходами в разделы.
|
||||||
|
|
||||||
## Что дальше
|
## Что дальше
|
||||||
|
|
||||||
1. Очередь и экран ожидания → тикет → WebSocket (`GameConnection`).
|
1. Довести боевой UI: арты карт, зоны, анимации, скип/ускорение.
|
||||||
2. Боевая сцена: `MatchView`, проекция событий, анимации.
|
2. Реконнект на всех шагах и аккуратная обработка таймаутов.
|
||||||
3. Полный декбилдер с фильтрами и счётчиками.
|
3. Полный декбилдер с фильтрами и счётчиками.
|
||||||
4. Магазин и открытие бустера.
|
4. Магазин и открытие бустера.
|
||||||
5. История матчей и реплей.
|
5. История матчей и реплей.
|
||||||
|
|
@ -78,6 +83,11 @@ MessagePack-сериализатор и юнион-кадры.
|
||||||
макеты. DOTween пока не добавлен, чтобы не тянуть Asset Store-зависимость
|
макеты. DOTween пока не добавлен, чтобы не тянуть Asset Store-зависимость
|
||||||
в первый коммит.
|
в первый коммит.
|
||||||
- Мета-экраны — заглушки: сеть и авторизация настоящие, разделы следующие.
|
- Мета-экраны — заглушки: сеть и авторизация настоящие, разделы следующие.
|
||||||
|
- Бой — gray-box: карты показываются по `printingId`, без артов и анимаций;
|
||||||
|
действия — кнопки всех `MatchView.LegalIntents`.
|
||||||
|
- `GameConnection` использует `System.Net.WebSockets.ClientWebSocket`
|
||||||
|
(Windows/десктоп first). Для WebGL понадобится другой транспорт или
|
||||||
|
нативный WebSocket-плагин.
|
||||||
|
|
||||||
## Проверки
|
## Проверки
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -524,17 +524,23 @@ CI подключён и проходит; серверный блок закр
|
||||||
- коллекция: `/api/collection`, балансы, разбор в пыль с подтверждением;
|
- коллекция: `/api/collection`, балансы, разбор в пыль с подтверждением;
|
||||||
- колоды: `/api/decks`, удаление и автосборка стартовой колоды из
|
- колоды: `/api/decks`, удаление и автосборка стартовой колоды из
|
||||||
выбранной триал-деки (50 карт);
|
выбранной триал-деки (50 карт);
|
||||||
|
- очередь classic-ranked, опрос статуса, отмена, тикет и `wsUrl`;
|
||||||
|
- `GameConnection` (WebSocket + MessagePack), handshake `ClientHello`,
|
||||||
|
snapshot/events, интенты с ответом сервера, ping/pong, закрытие;
|
||||||
|
- серый боевой экран: доска, рука, часы, все легальные интенты кнопками,
|
||||||
|
сдача с подтверждением, результат;
|
||||||
- `Vanguard.Shared.dll` (netstandard2.1) и MessagePack 3.1.12 подключены
|
- `Vanguard.Shared.dll` (netstandard2.1) и MessagePack 3.1.12 подключены
|
||||||
как плагины; `client/README.md` описывает запуск и ограничения;
|
как плагины; `client/README.md` описывает запуск и ограничения;
|
||||||
- EditMode и PlayMode smoke-тесты `Vanguard.Tests.Editor` /
|
- EditMode и PlayMode smoke-тесты `Vanguard.Tests.Editor` /
|
||||||
`Vanguard.Tests.PlayMode` собирают все экраны и проверяют запуск
|
`Vanguard.Tests.PlayMode` собирают все экраны и проверяют запуск
|
||||||
приложения; live-probe (не входит в набор) проверил онбординг,
|
приложения; live-probe (не входит в набор) проверил онбординг,
|
||||||
коллекцию, создание и удаление колоды против стенда.
|
коллекцию, создание/удаление колоды, а также два WebSocket-подключения,
|
||||||
|
маллиган, сдачу и `GameFinished` против стенда.
|
||||||
|
|
||||||
Дальше по клиенту:
|
Дальше по клиенту:
|
||||||
|
|
||||||
1. Очередь → тикет → `GameConnection` (WebSocket + MessagePack).
|
1. Довести боевой UI: арты, зоны, анимации, скип/ускорение.
|
||||||
2. Боевая сцена: `MatchView`, проекция событий, анимации.
|
2. Реконнект на всех шагах и аккуратная обработка таймаутов.
|
||||||
3. Полный декбилдер с фильтрами и счётчиками.
|
3. Полный декбилдер с фильтрами и счётчиками.
|
||||||
4. Магазин и открытие бустера.
|
4. Магазин и открытие бустера.
|
||||||
5. История матчей и реплей.
|
5. История матчей и реплей.
|
||||||
|
|
@ -586,6 +592,9 @@ CI подключён и проходит; серверный блок закр
|
||||||
- **Клиент: HTTP.** В Unity по умолчанию запрещены незащищённые соединения:
|
- **Клиент: HTTP.** В Unity по умолчанию запрещены незащищённые соединения:
|
||||||
для dev-стенда в `client/ProjectSettings/ProjectSettings.asset` стоит
|
для dev-стенда в `client/ProjectSettings/ProjectSettings.asset` стоит
|
||||||
`insecureHttpOption: 2` (Always). Для релиза — HTTPS и вернуть запрет.
|
`insecureHttpOption: 2` (Always). Для релиза — HTTPS и вернуть запрет.
|
||||||
|
- **Клиент: WebSocket.** `GameConnection` использует
|
||||||
|
`System.Net.WebSockets.ClientWebSocket`; это окно для Windows-десктопа.
|
||||||
|
Для WebGL/мобильного IL2CPP нужен отдельный транспорт/плагин.
|
||||||
- **Кодировка.** Не использовать PowerShell `Get-Content`/`Set-Content` для
|
- **Кодировка.** Не использовать PowerShell `Get-Content`/`Set-Content` для
|
||||||
UTF-8 файлов с русским текстом: однажды это уже испортило комментарии.
|
UTF-8 файлов с русским текстом: однажды это уже испортило комментарии.
|
||||||
Правки — только через `edit`/`write`, либо Python с `encoding="utf-8"`.
|
Правки — только через `edit`/`write`, либо Python с `encoding="utf-8"`.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue