Add match history and replay viewer
CI / test (push) Successful in 7m1s Details

This commit is contained in:
MpakobecEx 2026-10-21 18:26:02 +05:00
parent e0ee7cdaa7
commit 5199e11d2d
14 changed files with 598 additions and 17 deletions

View File

@ -250,6 +250,7 @@
арты и «сколько в колодах» — дальше. арты и «сколько в колодах» — дальше.
- [x] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая - [x] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая
триал-дека; анимация открытия — дальше. триал-дека; анимация открытия — дальше.
- [x] История матчей и реплей: список результатов, события и кадры.
- [ ] Локализация RU/EN, переключение на лету (пакет подключён). - [ ] Локализация RU/EN, переключение на лету (пакет подключён).
- [ ] Настройки: язык, звук, FPS, мьют. - [ ] Настройки: язык, звук, FPS, мьют.

View File

@ -21,7 +21,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: 3D-сцены, логин, онбординг, коллекция, колоды, магазин, очередь, gray-box бой | | Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, колоды, магазин, история/реплей, очередь, gray-box бой |
| Лаунчер | не начато | | Лаунчер | не начато |
## Требования ## Требования

View File

@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Vanguard.Shared.Protocol;
namespace Vanguard.Api.Contracts namespace Vanguard.Api.Contracts
{ {
@ -222,4 +224,51 @@ namespace Vanguard.Api.Contracts
[JsonProperty("rarity")] public string Rarity { get; set; } = string.Empty; [JsonProperty("rarity")] public string Rarity { get; set; } = string.Empty;
[JsonProperty("isNew")] public bool IsNew { get; set; } [JsonProperty("isNew")] public bool IsNew { get; set; }
} }
public sealed class MatchHistoryResponseDto
{
[JsonProperty("items")] public List<MatchHistoryDto> Items { get; set; } = new List<MatchHistoryDto>();
}
public sealed class MatchHistoryDto
{
[JsonProperty("matchId")] public string MatchId { get; set; } = string.Empty;
[JsonProperty("mode")] public string Mode { get; set; } = string.Empty;
[JsonProperty("opponentName")] public string OpponentName { get; set; } = string.Empty;
[JsonProperty("result")] public string Result { get; set; } = string.Empty;
[JsonProperty("gold")] public int Gold { get; set; }
[JsonProperty("xp")] public int Xp { get; set; }
[JsonProperty("mmrBefore")] public int MmrBefore { get; set; }
[JsonProperty("mmrAfter")] public int MmrAfter { get; set; }
[JsonProperty("finishReason")] public string FinishReason { get; set; } = string.Empty;
[JsonProperty("playedAt")] public DateTimeOffset PlayedAt { get; set; }
[JsonProperty("replayAvailable")] public bool ReplayAvailable { get; set; }
}
public sealed class ReplayResponseDto
{
[JsonProperty("matchId")] public string MatchId { get; set; } = string.Empty;
[JsonProperty("mode")] public string Mode { get; set; } = string.Empty;
[JsonProperty("player0")] public string Player0 { get; set; } = string.Empty;
[JsonProperty("player1")] public string Player1 { get; set; } = string.Empty;
[JsonProperty("viewerSeat")] public int ViewerSeat { get; set; }
[JsonProperty("winner")] public int Winner { get; set; }
[JsonProperty("finishReason")] public string FinishReason { get; set; } = string.Empty;
[JsonProperty("eventHash")] public string EventHash { get; set; } = string.Empty;
[JsonProperty("events")] public List<ReplayEventDto> Events { get; set; } = new List<ReplayEventDto>();
[JsonProperty("frames")] public List<ReplayFrameDto> Frames { get; set; } = new List<ReplayFrameDto>();
}
public sealed class ReplayEventDto
{
[JsonProperty("seq")] public long Seq { get; set; }
[JsonProperty("kind")] public string Kind { get; set; } = string.Empty;
[JsonProperty("event")] public JToken Event { get; set; }
}
public sealed class ReplayFrameDto
{
[JsonProperty("eventSeq")] public long EventSeq { get; set; }
[JsonProperty("view")] public MatchView View { get; set; }
}
} }

View File

@ -17,6 +17,7 @@ namespace Vanguard.App
private OnboardingView _onboarding; private OnboardingView _onboarding;
private QueueView _queue; private QueueView _queue;
private StoreView _store; private StoreView _store;
private HistoryView _history;
private void Start() private void Start()
{ {
@ -35,6 +36,7 @@ namespace Vanguard.App
() => _ = Flow.LoadCollectionAsync(), () => _ = Flow.LoadCollectionAsync(),
() => _ = Flow.LoadDeckBuilderAsync(), () => _ = Flow.LoadDeckBuilderAsync(),
ShowStore, ShowStore,
ShowHistory,
() => _ = Flow.LoadDeckBuilderAsync(), () => _ = Flow.LoadDeckBuilderAsync(),
ShowOnboarding); ShowOnboarding);
_onboarding = new OnboardingView( _onboarding = new OnboardingView(
@ -43,6 +45,8 @@ namespace Vanguard.App
canvas.transform, _meta, _auth, OnQueueBack, OnMatched); canvas.transform, _meta, _auth, OnQueueBack, OnMatched);
_store = new StoreView( _store = new StoreView(
canvas.transform, _meta, _auth, _player, OnStoreBack); canvas.transform, _meta, _auth, _player, OnStoreBack);
_history = new HistoryView(
canvas.transform, _meta, _auth, OnHistoryBack);
ShowDefault(); ShowDefault();
} }
@ -82,6 +86,18 @@ namespace Vanguard.App
_metaView.Show(); _metaView.Show();
} }
private void ShowHistory()
{
HideAll();
_history.Show();
}
private void OnHistoryBack()
{
_history.Hide();
_metaView.Show();
}
private void OnOnboarded() private void OnOnboarded()
{ {
HideAll(); HideAll();
@ -110,6 +126,7 @@ namespace Vanguard.App
_onboarding.Hide(); _onboarding.Hide();
_queue.Hide(); _queue.Hide();
_store.Hide(); _store.Hide();
_history.Hide();
} }
private void OnDestroy() private void OnDestroy()

View File

@ -124,5 +124,13 @@ namespace Vanguard.Meta
_player.ApplyBalances(balances); _player.ApplyBalances(balances);
return balances; return balances;
} }
public async Task<MatchHistoryResponseDto> GetHistoryAsync(int limit = 20, int offset = 0) =>
await _api.GetAsync<MatchHistoryResponseDto>(
"/api/matches?limit=" + limit + "&offset=" + offset);
public async Task<ReplayResponseDto> GetReplayAsync(string matchId, bool includeFrames = true) =>
await _api.GetAsync<ReplayResponseDto>(
"/api/replays/" + matchId + "?includeFrames=" + (includeFrames ? "true" : "false"));
} }
} }

View File

@ -0,0 +1,218 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using Vanguard.Api;
using Vanguard.Api.Contracts;
using Vanguard.Auth;
using Vanguard.Meta;
namespace Vanguard.UI
{
/// <summary>История матчей в Lobby и вход в реплей.</summary>
public sealed class HistoryView
{
private readonly MetaService _meta;
private readonly AuthService _auth;
private readonly Action _onBack;
private readonly RectTransform _root;
private readonly Text _status;
private readonly RectTransform _content;
private readonly ReplayView _replay;
private bool _loading;
public HistoryView(
Transform parent,
MetaService meta,
AuthService auth,
Action onBack)
{
_meta = meta;
_auth = auth;
_onBack = onBack;
_root = UiKit.CreatePanel(parent, "HistoryScreen", new Color(0.018f, 0.03f, 0.06f, 0.96f));
var top = UiKit.CreatePanel(_root, "TopBar", new Color(0.06f, 0.08f, 0.13f, 1f));
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, 22, 16);
var back = UiKit.CreateButton(top, "Back", "← В лобби", () => _onBack(), 18);
UiKit.SetPreferredHeight(back, 44f, 0f);
var title = UiKit.CreateText(top, "Title", "История матчей", 32, Color.white, TextAnchor.MiddleLeft);
title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(title, 46f);
var hint = UiKit.CreateText(
top, "Hint", "Только ваши матчи · реплей доступен участникам", 16,
new Color(0.60f, 0.68f, 0.84f), TextAnchor.MiddleRight);
UiKit.SetPreferredHeight(hint, 36f);
var body = UiKit.CreatePanel(_root, "Body", new Color(0f, 0f, 0f, 0f));
body.anchorMin = Vector2.zero;
body.anchorMax = Vector2.one;
body.offsetMin = new Vector2(44f, 30f);
body.offsetMax = new Vector2(-44f, -110f);
UiKit.AddVerticalLayout(body, 8, 10);
var scroll = UiKit.CreateScrollView(body, "HistoryScroll", out _content, 8f);
var scrollElement = scroll.gameObject.AddComponent<LayoutElement>();
scrollElement.flexibleHeight = 1f;
scrollElement.minHeight = 200f;
_status = UiKit.CreateText(
body, "Status", string.Empty, 18,
new Color(0.62f, 0.68f, 0.82f), TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(_status, 34f);
_replay = new ReplayView(_root, _meta, HideReplay);
_root.gameObject.SetActive(false);
}
public void Show()
{
UiKit.SetActive(_root, true);
_replay.Hide();
_ = LoadAsync();
}
public void Hide()
{
_replay.Hide();
UiKit.SetActive(_root, false);
}
private async Task LoadAsync()
{
if (_loading)
return;
_loading = true;
SetStatus("Загружаем историю…", new Color(0.62f, 0.68f, 0.82f));
ClearChildren(_content);
try
{
await _auth.EnsureAccessAsync();
var history = await _meta.GetHistoryAsync();
if (_root == null || !_root.gameObject.activeSelf)
return;
Build(history.Items);
SetStatus(
history.Items == null || history.Items.Count == 0
? "Матчей пока нет."
: "Матчей: " + history.Items.Count + ".",
new Color(0.62f, 0.68f, 0.82f));
}
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
{
_loading = false;
}
}
private void Build(IReadOnlyList<MatchHistoryDto> items)
{
ClearChildren(_content);
if (items == null || items.Count == 0)
return;
foreach (var item in items)
AddRow(item);
}
private void AddRow(MatchHistoryDto item)
{
var row = UiKit.CreatePanel(_content, "Match_" + item.MatchId, new Color(0.07f, 0.09f, 0.14f, 1f));
var rowElement = row.gameObject.AddComponent<LayoutElement>();
rowElement.preferredHeight = 96f;
UiKit.AddHorizontalLayout(row, 14, 14);
var info = UiKit.CreatePanel(row, "Info", new Color(0f, 0f, 0f, 0f));
var infoElement = info.gameObject.AddComponent<LayoutElement>();
infoElement.flexibleWidth = 1f;
infoElement.preferredHeight = 64f;
UiKit.AddVerticalLayout(info, 0, 2);
var opponent = UiKit.CreateText(
info,
"Opponent",
(item.Result == "win" ? "Победа" : item.Result == "loss" ? "Поражение" : "Ничья")
+ " против " + item.OpponentName,
21,
item.Result == "win" ? new Color(0.45f, 0.85f, 0.55f) : new Color(1f, 0.62f, 0.48f),
TextAnchor.MiddleLeft);
opponent.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(opponent, 28f);
var detail = UiKit.CreateText(
info,
"Detail",
item.Mode + " · " + item.FinishReason + " · "
+ item.PlayedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm"),
16,
new Color(0.60f, 0.68f, 0.84f),
TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(detail, 24f);
var rewards = UiKit.CreateText(
row,
"Rewards",
"Gold " + item.Gold + " · XP " + item.Xp
+ "\nMMR " + item.MmrBefore + " → " + item.MmrAfter,
16,
new Color(0.72f, 0.78f, 0.92f));
var rewardsElement = rewards.gameObject.AddComponent<LayoutElement>();
rewardsElement.preferredWidth = 210f;
rewardsElement.preferredHeight = 56f;
var replay = UiKit.CreateButton(
row,
"Replay",
item.ReplayAvailable ? "Реплей" : "Нет реплея",
() => _replay.Show(item.MatchId),
17);
replay.interactable = item.ReplayAvailable;
var replayElement = replay.gameObject.AddComponent<LayoutElement>();
replayElement.preferredWidth = 130f;
replayElement.preferredHeight = 46f;
replayElement.flexibleWidth = 0f;
}
private void HideReplay()
{
_replay.Hide();
}
private void SetStatus(string text, Color color)
{
if (_status == null)
return;
_status.text = text ?? string.Empty;
_status.color = color;
}
private static void ClearChildren(Transform parent)
{
for (var i = parent.childCount - 1; i >= 0; i--)
UnityEngine.Object.Destroy(parent.GetChild(i).gameObject);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 91ee87dd87941b9458c372243c178f87

View File

@ -17,6 +17,7 @@ namespace Vanguard.UI
private readonly Action _onCollection; private readonly Action _onCollection;
private readonly Action _onDecks; private readonly Action _onDecks;
private readonly Action _onStore; private readonly Action _onStore;
private readonly Action _onHistory;
private readonly Action _onPlay; private readonly Action _onPlay;
private readonly Action _onOnboarding; private readonly Action _onOnboarding;
private bool _disposed; private bool _disposed;
@ -43,6 +44,7 @@ namespace Vanguard.UI
Action onCollection, Action onCollection,
Action onDecks, Action onDecks,
Action onStore, Action onStore,
Action onHistory,
Action onPlay, Action onPlay,
Action onOnboarding) Action onOnboarding)
{ {
@ -52,6 +54,7 @@ namespace Vanguard.UI
_onCollection = onCollection; _onCollection = onCollection;
_onDecks = onDecks; _onDecks = onDecks;
_onStore = onStore; _onStore = onStore;
_onHistory = onHistory;
_onPlay = onPlay; _onPlay = onPlay;
_onOnboarding = onOnboarding; _onOnboarding = onOnboarding;
@ -112,6 +115,7 @@ namespace Vanguard.UI
AddAction(actions, "Коллекция", _onCollection); AddAction(actions, "Коллекция", _onCollection);
AddAction(actions, "Колоды", _onDecks); AddAction(actions, "Колоды", _onDecks);
AddAction(actions, "Магазин", _onStore); AddAction(actions, "Магазин", _onStore);
AddAction(actions, "История", _onHistory);
AddAction(actions, "В бой", _onPlay); AddAction(actions, "В бой", _onPlay);
var lower = UiKit.CreatePanel(content, "Lower", new Color(0.07f, 0.085f, 0.115f, 1f)); var lower = UiKit.CreatePanel(content, "Lower", new Color(0.07f, 0.085f, 0.115f, 1f));

View File

@ -0,0 +1,274 @@
using System;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using Vanguard.Api;
using Vanguard.Api.Contracts;
using Vanguard.Meta;
using Vanguard.Shared.Protocol;
namespace Vanguard.UI
{
/// <summary>Просмотр реплея: события и кадры состояния, собранные сервером.</summary>
public sealed class ReplayView
{
private readonly MetaService _meta;
private readonly Action _onClose;
private readonly RectTransform _root;
private readonly Text _title;
private readonly Text _result;
private readonly Text _frameLabel;
private readonly Text _summary;
private readonly Text _status;
private readonly RectTransform _events;
private ReplayResponseDto _replay;
private int _frameIndex;
private bool _loading;
public ReplayView(Transform parent, MetaService meta, Action onClose)
{
_meta = meta;
_onClose = onClose;
_root = UiKit.CreatePanel(parent, "ReplayScreen", new Color(0.015f, 0.025f, 0.05f, 0.97f));
var top = UiKit.CreatePanel(_root, "TopBar", new Color(0.06f, 0.08f, 0.13f, 1f));
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, 22, 16);
var back = UiKit.CreateButton(top, "Back", "← К истории", () => _onClose(), 18);
UiKit.SetPreferredHeight(back, 44f, 0f);
_title = UiKit.CreateText(top, "Title", "Реплей", 30, Color.white, TextAnchor.MiddleLeft);
_title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(_title, 46f);
_result = UiKit.CreateText(top, "Result", string.Empty, 20,
new Color(0.85f, 0.90f, 1f), TextAnchor.MiddleRight);
UiKit.SetPreferredHeight(_result, 36f);
var body = UiKit.CreatePanel(_root, "Body", new Color(0f, 0f, 0f, 0f));
body.anchorMin = Vector2.zero;
body.anchorMax = Vector2.one;
body.offsetMin = new Vector2(40f, 28f);
body.offsetMax = new Vector2(-40f, -110f);
UiKit.AddVerticalLayout(body, 8, 10);
var summaryPanel = UiKit.CreatePanel(body, "SummaryPanel", new Color(0.06f, 0.08f, 0.13f, 1f));
UiKit.SetPreferredHeight(summaryPanel, 250f);
UiKit.AddVerticalLayout(summaryPanel, 18, 8);
_frameLabel = UiKit.CreateText(
summaryPanel, "Frame", "Кадр", 19, Color.white, TextAnchor.MiddleLeft);
_frameLabel.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(_frameLabel, 28f);
_summary = UiKit.CreateText(
summaryPanel, "Summary", string.Empty, 16,
new Color(0.72f, 0.78f, 0.92f), TextAnchor.UpperLeft);
_summary.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(_summary, 170f);
var controls = UiKit.CreatePanel(body, "Controls", new Color(0f, 0f, 0f, 0f));
UiKit.SetPreferredHeight(controls, 52f);
UiKit.AddHorizontalLayout(controls, 0, 12);
var prev = UiKit.CreateButton(controls, "Prev", "◀ Кадр", OnPrev, 18);
UiKit.SetPreferredHeight(prev, 46f);
var next = UiKit.CreateButton(controls, "Next", "Кадр ▶", OnNext, 18);
UiKit.SetPreferredHeight(next, 46f);
var eventsTitle = UiKit.CreateText(
body, "EventsTitle", "События", 22, Color.white, TextAnchor.MiddleLeft);
eventsTitle.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(eventsTitle, 30f);
var scroll = UiKit.CreateScrollView(body, "EventsScroll", out _events, 4f);
var scrollElement = scroll.gameObject.AddComponent<LayoutElement>();
scrollElement.flexibleHeight = 1f;
scrollElement.minHeight = 160f;
_status = UiKit.CreateText(
body, "Status", string.Empty, 17,
new Color(0.62f, 0.68f, 0.82f), TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(_status, 30f);
_root.gameObject.SetActive(false);
}
public void Show(string matchId)
{
UiKit.SetActive(_root, true);
_replay = null;
_frameIndex = 0;
SetStatus("Загружаем реплей…", new Color(0.62f, 0.68f, 0.82f));
_ = LoadAsync(matchId);
}
public void Hide() => UiKit.SetActive(_root, false);
private async Task LoadAsync(string matchId)
{
if (_loading)
return;
_loading = true;
try
{
_replay = await _meta.GetReplayAsync(matchId);
if (_root == null || !_root.gameObject.activeSelf)
return;
_title.text = "Реплей · " + _replay.MatchId;
_result.text = _replay.Winner == _replay.ViewerSeat
? "Победа"
: _replay.Winner < 0 ? "Ничья" : "Поражение";
_result.color = _replay.Winner == _replay.ViewerSeat
? new Color(0.45f, 0.85f, 0.55f)
: new Color(1f, 0.58f, 0.45f);
_frameIndex = Math.Max(0, (_replay.Frames?.Count ?? 0) - 1);
RebuildEvents();
UpdateFrame();
SetStatus(
"Кадров: " + (_replay.Frames?.Count ?? 0) + " · событий: "
+ (_replay.Events?.Count ?? 0),
new Color(0.62f, 0.68f, 0.82f));
}
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
{
_loading = false;
}
}
private void RebuildEvents()
{
ClearChildren(_events);
if (_replay?.Events == null || _replay.Events.Count == 0)
{
var empty = UiKit.CreateText(
_events, "Empty", "Событий нет.", 16,
new Color(0.62f, 0.68f, 0.82f));
UiKit.SetPreferredHeight(empty, 60f);
return;
}
foreach (var item in _replay.Events)
{
var line = UiKit.CreateText(
_events,
"Event",
"#" + item.Seq + " " + item.Kind,
15,
new Color(0.72f, 0.78f, 0.92f),
TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(line, 22f);
}
}
private void OnPrev()
{
if (_replay?.Frames == null || _replay.Frames.Count == 0)
return;
_frameIndex = Math.Max(0, _frameIndex - 1);
UpdateFrame();
}
private void OnNext()
{
if (_replay?.Frames == null || _replay.Frames.Count == 0)
return;
_frameIndex = Math.Min(_replay.Frames.Count - 1, _frameIndex + 1);
UpdateFrame();
}
private void UpdateFrame()
{
if (_replay?.Frames == null || _replay.Frames.Count == 0)
{
_frameLabel.text = "Кадров нет";
_summary.text = string.Empty;
return;
}
var frame = _replay.Frames[Mathf.Clamp(_frameIndex, 0, _replay.Frames.Count - 1)];
_frameLabel.text = "Кадр " + (_frameIndex + 1) + " / " + _replay.Frames.Count
+ " · eventSeq " + frame.EventSeq;
var view = frame.View;
if (view == null)
{
_summary.text = "Состояние кадра недоступно.";
return;
}
var text = "Фаза: " + view.Phase + " · шаг: " + view.Step
+ " · ход " + view.TurnNumber
+ " · решает: " + (view.ActivePlayer == view.You ? "вы" : "противник")
+ "\n";
var opponent = view.You == 0 ? 1 : 0;
foreach (var index in new[] { view.You, opponent })
{
if (view.Players == null || index >= view.Players.Count)
continue;
var player = view.Players[index];
text += (index == view.You ? "Вы: " : "Противник: ") + player.Name
+ " · рука " + player.HandCount
+ " · колода " + player.DeckCount
+ " · урон " + (player.FaceDownDamage + player.FaceUpDamage.Count)
+ " · Vanguard " + (player.Vanguard?.PrintingId ?? "—")
+ "\n RG: " + RearGuards(player) + "\n";
}
_summary.text = text;
}
private static string RearGuards(PlayerView player)
{
if (player.RearGuards == null)
return "—";
var parts = new System.Collections.Generic.List<string>();
for (var i = 0; i < player.RearGuards.Length; i++)
{
var card = player.RearGuards[i];
parts.Add("R" + (i + 1) + ":" + (card?.PrintingId ?? "—"));
}
return string.Join(" · ", parts);
}
private void SetStatus(string text, Color color)
{
if (_status == null)
return;
_status.text = text ?? string.Empty;
_status.color = color;
}
private static void ClearChildren(Transform parent)
{
for (var i = parent.childCount - 1; i >= 0; i--)
UnityEngine.Object.Destroy(parent.GetChild(i).gameObject);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2a398981cee95674a82fb16062699fcb

View File

@ -33,7 +33,7 @@ namespace Vanguard.Tests
var lobby = new MetaView( var lobby = new MetaView(
canvas.transform, auth, player, () => { }, canvas.transform, auth, player, () => { },
() => { }, () => { }, () => { }, () => { }, () => { }); () => { }, () => { }, () => { }, () => { }, () => { }, () => { });
var onboarding = new OnboardingView( var onboarding = new OnboardingView(
canvas.transform, auth, meta, () => { }, () => { }); canvas.transform, auth, meta, () => { }, () => { });
var collection = new CollectionView( var collection = new CollectionView(
@ -44,6 +44,8 @@ namespace Vanguard.Tests
canvas.transform, meta, auth, () => { }, _ => { }); canvas.transform, meta, auth, () => { }, _ => { });
var store = new StoreView( var store = new StoreView(
canvas.transform, meta, auth, player, () => { }); canvas.transform, meta, auth, player, () => { });
var history = new HistoryView(
canvas.transform, meta, auth, () => { });
var battle = new BattleView( var battle = new BattleView(
canvas.transform, meta, () => { }); canvas.transform, meta, () => { });
@ -53,6 +55,8 @@ namespace Vanguard.Tests
Assert.IsTrue(Exists("CollectionScreen")); Assert.IsTrue(Exists("CollectionScreen"));
Assert.IsTrue(Exists("DecksScreen")); Assert.IsTrue(Exists("DecksScreen"));
Assert.IsTrue(Exists("StoreScreen")); Assert.IsTrue(Exists("StoreScreen"));
Assert.IsTrue(Exists("HistoryScreen"));
Assert.IsTrue(Exists("ReplayScreen"));
Assert.IsTrue(Exists("QueueScreen")); Assert.IsTrue(Exists("QueueScreen"));
Assert.IsTrue(Exists("BattleScreen")); Assert.IsTrue(Exists("BattleScreen"));

View File

@ -54,6 +54,8 @@ http://localhost:5080
триал-деки. триал-деки.
- Магазин в Lobby: бустер за Gold/токен, показ 5 карт, покупка второй - Магазин в Lobby: бустер за Gold/токен, показ 5 карт, покупка второй
триал-деки. триал-деки.
- История матчей: список результатов, награды, MMR; просмотр реплея по
кадрам и событиям.
- Очередь: classic-ranked, опрос статуса, отмена, тикет и переход в бой. - Очередь: classic-ranked, опрос статуса, отмена, тикет и переход в бой.
- `GameConnection`: WebSocket + MessagePack, handshake, snapshot/events, - `GameConnection`: WebSocket + MessagePack, handshake, snapshot/events,
интенты, ответы на ping, закрытие и переподключение. интенты, ответы на ping, закрытие и переподключение.

View File

@ -546,6 +546,8 @@ CI подключён и проходит; серверный блок закр
выбранной триал-деки (50 карт); выбранной триал-деки (50 карт);
- магазин в Lobby: `/api/store`, бустер за Gold/токен с показом 5 карт, - магазин в Lobby: `/api/store`, бустер за Gold/токен с показом 5 карт,
покупка второй триал-деки; покупка второй триал-деки;
- история матчей и реплей: `/api/matches`, `/api/replays/{id}`, просмотр
событий и кадров состояния;
- очередь classic-ranked, опрос статуса, отмена, тикет и `wsUrl`; - очередь classic-ranked, опрос статуса, отмена, тикет и `wsUrl`;
- `GameConnection` (WebSocket + MessagePack), handshake `ClientHello`, - `GameConnection` (WebSocket + MessagePack), handshake `ClientHello`,
snapshot/events, интенты с ответом сервера, ping/pong, закрытие; snapshot/events, интенты с ответом сервера, ping/pong, закрытие;
@ -566,11 +568,10 @@ CI подключён и проходит; серверный блок закр
Дальше по клиенту: Дальше по клиенту:
1. История матчей и реплей: `GET /api/matches`, `GET /api/replays/{id}`. 1. Боевой UI: арты, зоны, анимации, скип/ускорение; реконнект и таймауты.
2. Полный декбилдер: фильтры, счётчики, валидация. 2. Полный декбилдер: фильтры, счётчики, валидация.
3. Боевой UI: арты, зоны, анимации, скип/ускорение; реконнект и таймауты. 3. 3D-наполнение сцен: модели/арт, prefab-экраны, камера.
4. 3D-наполнение сцен: модели/арт, prefab-экраны, камера. 4. Локализация RU/EN, настройки, DOTween.
5. Локализация RU/EN, настройки, DOTween.
**Зафиксированное визуальное направление** — `docs/client-visual-direction.md`: **Зафиксированное визуальное направление** — `docs/client-visual-direction.md`:
3D-клиент на URP, отдельные сцены-экраны Login / Lobby / Collection / 3D-клиент на URP, отдельные сцены-экраны Login / Lobby / Collection /

View File

@ -39,11 +39,11 @@
- Сервисы: `ApiClient`, `AuthService`, `MetaService`, `TokenStore`, - Сервисы: `ApiClient`, `AuthService`, `MetaService`, `TokenStore`,
`PlayerState`, `GameConnection`. `PlayerState`, `GameConnection`.
- Работает: логин/регистрация/refresh, онбординг, коллекция с разбором, - Работает: логин/регистрация/refresh, онбординг, коллекция с разбором,
список/удаление/автосборка колоды, магазин (бустер/триал-дека), очередь, список/удаление/автосборка колоды, магазин (бустер/триал-дека),
WebSocket-матч (snapshot, интенты, маллиган, сдача, `GameFinished`), история матчей и реплей, очередь, WebSocket-матч (snapshot, интенты,
таймер, результат. маллиган, сдача, `GameFinished`), таймер, результат.
- Не готово: полноценный декбилдер, история/реплей в UI, арты/модели/ - Не готово: полноценный декбилдер, арты/модели/анимации, локализация
анимации, локализация RU/EN, настройки, DOTween. RU/EN, настройки, DOTween.
## Запуск ## Запуск
@ -122,12 +122,11 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack
## Следующие шаги ## Следующие шаги
1. История матчей и реплей: `GET /api/matches`, `GET /api/replays/{id}`. 1. Боевой UI: арты, зоны, анимации, скип; реконнект и таймауты.
2. Боевой UI: арты, зоны, анимации, скип; реконнект и таймауты. 2. Полный декбилдер: фильтры, счётчики, валидация.
3. Полный декбилдер: фильтры, счётчики, валидация. 3. 3D-наполнение: модели/арт, prefab-экраны, камера.
4. 3D-наполнение: модели/арт, prefab-экраны, камера. 4. Локализация RU/EN, настройки, DOTween.
5. Локализация RU/EN, настройки, DOTween. 5. Лаунчер WPF.
6. Лаунчер WPF.
## Подводные камни ## Подводные камни