diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index b814dad..3503ff2 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -250,6 +250,7 @@ арты и «сколько в колодах» — дальше. - [x] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая триал-дека; анимация открытия — дальше. +- [x] История матчей и реплей: список результатов, события и кадры. - [ ] Локализация RU/EN, переключение на лету (пакет подключён). - [ ] Настройки: язык, звук, FPS, мьют. diff --git a/README.md b/README.md index 270428a..33ea4db 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ | CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` | | EF-миграции | готово (`InitialCreate`) | | Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` | -| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, колоды, магазин, очередь, gray-box бой | +| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, колоды, магазин, история/реплей, очередь, gray-box бой | | Лаунчер | не начато | ## Требования diff --git a/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs b/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs index 250c60f..c6042c3 100644 --- a/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs +++ b/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Vanguard.Shared.Protocol; namespace Vanguard.Api.Contracts { @@ -222,4 +224,51 @@ namespace Vanguard.Api.Contracts [JsonProperty("rarity")] public string Rarity { get; set; } = string.Empty; [JsonProperty("isNew")] public bool IsNew { get; set; } } + + public sealed class MatchHistoryResponseDto + { + [JsonProperty("items")] public List Items { get; set; } = new List(); + } + + 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 Events { get; set; } = new List(); + [JsonProperty("frames")] public List Frames { get; set; } = new List(); + } + + 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; } + } } diff --git a/client/Assets/Vanguard/Runtime/App/LobbySceneController.cs b/client/Assets/Vanguard/Runtime/App/LobbySceneController.cs index 2af780e..a675d53 100644 --- a/client/Assets/Vanguard/Runtime/App/LobbySceneController.cs +++ b/client/Assets/Vanguard/Runtime/App/LobbySceneController.cs @@ -17,6 +17,7 @@ namespace Vanguard.App private OnboardingView _onboarding; private QueueView _queue; private StoreView _store; + private HistoryView _history; private void Start() { @@ -35,6 +36,7 @@ namespace Vanguard.App () => _ = Flow.LoadCollectionAsync(), () => _ = Flow.LoadDeckBuilderAsync(), ShowStore, + ShowHistory, () => _ = Flow.LoadDeckBuilderAsync(), ShowOnboarding); _onboarding = new OnboardingView( @@ -43,6 +45,8 @@ namespace Vanguard.App canvas.transform, _meta, _auth, OnQueueBack, OnMatched); _store = new StoreView( canvas.transform, _meta, _auth, _player, OnStoreBack); + _history = new HistoryView( + canvas.transform, _meta, _auth, OnHistoryBack); ShowDefault(); } @@ -82,6 +86,18 @@ namespace Vanguard.App _metaView.Show(); } + private void ShowHistory() + { + HideAll(); + _history.Show(); + } + + private void OnHistoryBack() + { + _history.Hide(); + _metaView.Show(); + } + private void OnOnboarded() { HideAll(); @@ -110,6 +126,7 @@ namespace Vanguard.App _onboarding.Hide(); _queue.Hide(); _store.Hide(); + _history.Hide(); } private void OnDestroy() diff --git a/client/Assets/Vanguard/Runtime/Meta/MetaService.cs b/client/Assets/Vanguard/Runtime/Meta/MetaService.cs index b858df5..ed4ea69 100644 --- a/client/Assets/Vanguard/Runtime/Meta/MetaService.cs +++ b/client/Assets/Vanguard/Runtime/Meta/MetaService.cs @@ -124,5 +124,13 @@ namespace Vanguard.Meta _player.ApplyBalances(balances); return balances; } + + public async Task GetHistoryAsync(int limit = 20, int offset = 0) => + await _api.GetAsync( + "/api/matches?limit=" + limit + "&offset=" + offset); + + public async Task GetReplayAsync(string matchId, bool includeFrames = true) => + await _api.GetAsync( + "/api/replays/" + matchId + "?includeFrames=" + (includeFrames ? "true" : "false")); } } diff --git a/client/Assets/Vanguard/Runtime/UI/HistoryView.cs b/client/Assets/Vanguard/Runtime/UI/HistoryView.cs new file mode 100644 index 0000000..e6a21a4 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/HistoryView.cs @@ -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 +{ + /// История матчей в Lobby и вход в реплей. + 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(); + 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 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(); + 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(); + 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(); + 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(); + 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); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/UI/HistoryView.cs.meta b/client/Assets/Vanguard/Runtime/UI/HistoryView.cs.meta new file mode 100644 index 0000000..0888647 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/HistoryView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 91ee87dd87941b9458c372243c178f87 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/UI/MetaView.cs b/client/Assets/Vanguard/Runtime/UI/MetaView.cs index df0fd01..e289816 100644 --- a/client/Assets/Vanguard/Runtime/UI/MetaView.cs +++ b/client/Assets/Vanguard/Runtime/UI/MetaView.cs @@ -17,6 +17,7 @@ namespace Vanguard.UI private readonly Action _onCollection; private readonly Action _onDecks; private readonly Action _onStore; + private readonly Action _onHistory; private readonly Action _onPlay; private readonly Action _onOnboarding; private bool _disposed; @@ -43,6 +44,7 @@ namespace Vanguard.UI Action onCollection, Action onDecks, Action onStore, + Action onHistory, Action onPlay, Action onOnboarding) { @@ -52,6 +54,7 @@ namespace Vanguard.UI _onCollection = onCollection; _onDecks = onDecks; _onStore = onStore; + _onHistory = onHistory; _onPlay = onPlay; _onOnboarding = onOnboarding; @@ -112,6 +115,7 @@ namespace Vanguard.UI AddAction(actions, "Коллекция", _onCollection); AddAction(actions, "Колоды", _onDecks); AddAction(actions, "Магазин", _onStore); + AddAction(actions, "История", _onHistory); AddAction(actions, "В бой", _onPlay); var lower = UiKit.CreatePanel(content, "Lower", new Color(0.07f, 0.085f, 0.115f, 1f)); diff --git a/client/Assets/Vanguard/Runtime/UI/ReplayView.cs b/client/Assets/Vanguard/Runtime/UI/ReplayView.cs new file mode 100644 index 0000000..a7d281b --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/ReplayView.cs @@ -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 +{ + /// Просмотр реплея: события и кадры состояния, собранные сервером. + 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(); + 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(); + 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); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/UI/ReplayView.cs.meta b/client/Assets/Vanguard/Runtime/UI/ReplayView.cs.meta new file mode 100644 index 0000000..404d990 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/ReplayView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2a398981cee95674a82fb16062699fcb \ No newline at end of file diff --git a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs index 582b940..874e669 100644 --- a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs @@ -33,7 +33,7 @@ namespace Vanguard.Tests var lobby = new MetaView( canvas.transform, auth, player, () => { }, - () => { }, () => { }, () => { }, () => { }, () => { }); + () => { }, () => { }, () => { }, () => { }, () => { }, () => { }); var onboarding = new OnboardingView( canvas.transform, auth, meta, () => { }, () => { }); var collection = new CollectionView( @@ -44,6 +44,8 @@ namespace Vanguard.Tests canvas.transform, meta, auth, () => { }, _ => { }); var store = new StoreView( canvas.transform, meta, auth, player, () => { }); + var history = new HistoryView( + canvas.transform, meta, auth, () => { }); var battle = new BattleView( canvas.transform, meta, () => { }); @@ -53,6 +55,8 @@ namespace Vanguard.Tests Assert.IsTrue(Exists("CollectionScreen")); Assert.IsTrue(Exists("DecksScreen")); Assert.IsTrue(Exists("StoreScreen")); + Assert.IsTrue(Exists("HistoryScreen")); + Assert.IsTrue(Exists("ReplayScreen")); Assert.IsTrue(Exists("QueueScreen")); Assert.IsTrue(Exists("BattleScreen")); diff --git a/client/README.md b/client/README.md index b0b6fd9..555ce98 100644 --- a/client/README.md +++ b/client/README.md @@ -54,6 +54,8 @@ http://localhost:5080 триал-деки. - Магазин в Lobby: бустер за Gold/токен, показ 5 карт, покупка второй триал-деки. +- История матчей: список результатов, награды, MMR; просмотр реплея по + кадрам и событиям. - Очередь: classic-ranked, опрос статуса, отмена, тикет и переход в бой. - `GameConnection`: WebSocket + MessagePack, handshake, snapshot/events, интенты, ответы на ping, закрытие и переподключение. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 77e28f7..ccfdc4a 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -546,6 +546,8 @@ CI подключён и проходит; серверный блок закр выбранной триал-деки (50 карт); - магазин в Lobby: `/api/store`, бустер за Gold/токен с показом 5 карт, покупка второй триал-деки; +- история матчей и реплей: `/api/matches`, `/api/replays/{id}`, просмотр + событий и кадров состояния; - очередь classic-ranked, опрос статуса, отмена, тикет и `wsUrl`; - `GameConnection` (WebSocket + MessagePack), handshake `ClientHello`, snapshot/events, интенты с ответом сервера, ping/pong, закрытие; @@ -566,11 +568,10 @@ CI подключён и проходит; серверный блок закр Дальше по клиенту: -1. История матчей и реплей: `GET /api/matches`, `GET /api/replays/{id}`. +1. Боевой UI: арты, зоны, анимации, скип/ускорение; реконнект и таймауты. 2. Полный декбилдер: фильтры, счётчики, валидация. -3. Боевой UI: арты, зоны, анимации, скип/ускорение; реконнект и таймауты. -4. 3D-наполнение сцен: модели/арт, prefab-экраны, камера. -5. Локализация RU/EN, настройки, DOTween. +3. 3D-наполнение сцен: модели/арт, prefab-экраны, камера. +4. Локализация RU/EN, настройки, DOTween. **Зафиксированное визуальное направление** — `docs/client-visual-direction.md`: 3D-клиент на URP, отдельные сцены-экраны Login / Lobby / Collection / diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 6e09cf9..d86b578 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -39,11 +39,11 @@ - Сервисы: `ApiClient`, `AuthService`, `MetaService`, `TokenStore`, `PlayerState`, `GameConnection`. - Работает: логин/регистрация/refresh, онбординг, коллекция с разбором, - список/удаление/автосборка колоды, магазин (бустер/триал-дека), очередь, - WebSocket-матч (snapshot, интенты, маллиган, сдача, `GameFinished`), - таймер, результат. -- Не готово: полноценный декбилдер, история/реплей в UI, арты/модели/ - анимации, локализация RU/EN, настройки, DOTween. + список/удаление/автосборка колоды, магазин (бустер/триал-дека), + история матчей и реплей, очередь, WebSocket-матч (snapshot, интенты, + маллиган, сдача, `GameFinished`), таймер, результат. +- Не готово: полноценный декбилдер, арты/модели/анимации, локализация + RU/EN, настройки, DOTween. ## Запуск @@ -122,12 +122,11 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack ## Следующие шаги -1. История матчей и реплей: `GET /api/matches`, `GET /api/replays/{id}`. -2. Боевой UI: арты, зоны, анимации, скип; реконнект и таймауты. -3. Полный декбилдер: фильтры, счётчики, валидация. -4. 3D-наполнение: модели/арт, prefab-экраны, камера. -5. Локализация RU/EN, настройки, DOTween. -6. Лаунчер WPF. +1. Боевой UI: арты, зоны, анимации, скип; реконнект и таймауты. +2. Полный декбилдер: фильтры, счётчики, валидация. +3. 3D-наполнение: модели/арт, prefab-экраны, камера. +4. Локализация RU/EN, настройки, DOTween. +5. Лаунчер WPF. ## Подводные камни