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] 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 + переигрывание очереди).
|
||||
|
||||
**Готово, когда:** полный матч играется мышью без консоли и без рассинхрона с сервером.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 бой |
|
||||
| Лаунчер | не начато |
|
||||
|
||||
## Требования
|
||||
|
|
|
|||
|
|
@ -73,6 +73,11 @@ namespace Vanguard.App
|
|||
_root.ShowLogin("Введите данные игрока.");
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
_root?.Tick();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_container is IDisposable disposable)
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 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)
|
||||
|
|
|
|||
|
|
@ -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 == "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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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-плагин.
|
||||
|
||||
## Проверки
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue