Add match history and replay viewer
CI / test (push) Successful in 7m1s
Details
CI / test (push) Successful in 7m1s
Details
This commit is contained in:
parent
e0ee7cdaa7
commit
5199e11d2d
|
|
@ -250,6 +250,7 @@
|
||||||
арты и «сколько в колодах» — дальше.
|
арты и «сколько в колодах» — дальше.
|
||||||
- [x] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая
|
- [x] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая
|
||||||
триал-дека; анимация открытия — дальше.
|
триал-дека; анимация открытия — дальше.
|
||||||
|
- [x] История матчей и реплей: список результатов, события и кадры.
|
||||||
- [ ] Локализация RU/EN, переключение на лету (пакет подключён).
|
- [ ] Локализация RU/EN, переключение на лету (пакет подключён).
|
||||||
- [ ] Настройки: язык, звук, FPS, мьют.
|
- [ ] Настройки: язык, звук, FPS, мьют.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 бой |
|
||||||
| Лаунчер | не начато |
|
| Лаунчер | не начато |
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 91ee87dd87941b9458c372243c178f87
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2a398981cee95674a82fb16062699fcb
|
||||||
|
|
@ -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"));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, закрытие и переподключение.
|
||||||
|
|
|
||||||
|
|
@ -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 /
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
||||||
## Подводные камни
|
## Подводные камни
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue