From 6b4b2ab389829b0b1ace71f81e8a46a39b3e0009 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Wed, 21 Oct 2026 18:50:01 +0500 Subject: [PATCH] Add card catalog API and deck builder --- IMPLEMENTATION_CHECKLIST.md | 8 +- README.md | 6 +- .../Assets/Vanguard/Runtime/Api/ApiClient.cs | 8 + .../Vanguard/Runtime/Api/ApiContracts.cs | 14 + .../Runtime/App/CollectionSceneController.cs | 1 + .../Runtime/App/DeckBuilderSceneController.cs | 11 +- .../Vanguard/Runtime/Meta/MetaService.cs | 9 + .../Vanguard/Runtime/UI/DeckBuilderView.cs | 888 ++++++++++++++++++ .../Runtime/UI/DeckBuilderView.cs.meta | 2 + client/Assets/Vanguard/Runtime/UI/UiKit.cs | 154 ++- .../Vanguard/Tests/Editor/ClientSmokeTests.cs | 7 +- client/README.md | 16 +- docs/HANDOFF.md | 19 + docs/SESSION-STATE.md | 17 +- docs/server-api.md | 29 + .../src/Vanguard.Server/Meta/MetaEndpoints.cs | 39 + 16 files changed, 1169 insertions(+), 59 deletions(-) create mode 100644 client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs create mode 100644 client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs.meta diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 3503ff2..6eaafe7 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -242,10 +242,12 @@ - [x] `GameConnection` (WebSocket + MessagePack), snapshot/events, `ClientGameState` в виде `CurrentView` + `BattleView`, очередь и тикет. - [x] Экраны: логин/регистрация, главное меню, онбординг, коллекция, - список колод и автосборка стартовой; магазин/полный декбилдер — дальше. + список колод; магазин и декбилдер подключены. - [x] Очередь: classic-ranked, опрос, отмена, тикет, переход в матч. -- [ ] Декбилдер: фильтры (клан, грейд, редкость, тип, арт), счётчики, - валидация в реальном времени. +- [x] Простой декбилдер: фильтры (клан, грейд, триггер) в оверлее, + сброс, статистика триггеров/грейдов, коллекция сеткой, текущая колода, + создание/редактирование/сохранение, лимиты 50/4/owned. +- [ ] Декбилдер: drag&drop, live-валидация, редкость/тип/арт, пресеты. - [x] Базовая коллекция: список, балансы, разбор в пыль с подтверждением; арты и «сколько в колодах» — дальше. - [x] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая diff --git a/README.md b/README.md index 33ea4db..12c7df4 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,11 @@ | Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово | | Сервер: матч по сети, матчмейкинг, реконнект | готово | | PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово | -| Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово | +| Мета-API: JWT, коллекция, каталог, колоды, магазин, история, реплеи | готово | | 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 бой | | Лаунчер | не начато | ## Требования @@ -150,6 +150,6 @@ dotnet run --project server/src/Vanguard.Cli -- translations check - `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам. - `docs/HANDOFF.md` — состояние на конец сессии и порядок следующих шагов. - `docs/protocol.md` — протокол матча: кадры, интенты, события, таймеры, реконнект. -- `docs/server-api.md` — HTTP API: JWT, коллекция, колоды, магазин, очередь, +- `docs/server-api.md` — HTTP API: JWT, коллекция, каталог, колоды, магазин, очередь, история и реплеи. - `content/cards-mvp.md` — каталог карт с разбором состава сетов. diff --git a/client/Assets/Vanguard/Runtime/Api/ApiClient.cs b/client/Assets/Vanguard/Runtime/Api/ApiClient.cs index b52bbae..cb52551 100644 --- a/client/Assets/Vanguard/Runtime/Api/ApiClient.cs +++ b/client/Assets/Vanguard/Runtime/Api/ApiClient.cs @@ -58,6 +58,14 @@ namespace Vanguard.Api public Task PostAsync(string path, object body, bool authenticated = true) => SendAsync("POST", path, body, authenticated); + public async Task PutAsync(string path, object body, bool authenticated = true) + { + var response = await SendAsync("PUT", path, body, authenticated); + return string.IsNullOrEmpty(response) + ? default + : JsonConvert.DeserializeObject(response, JsonSettings); + } + public Task DeleteAsync(string path, bool authenticated = true) => SendAsync("DELETE", path, null, authenticated); diff --git a/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs b/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs index c6042c3..ba7d6ee 100644 --- a/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs +++ b/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs @@ -271,4 +271,18 @@ namespace Vanguard.Api.Contracts [JsonProperty("eventSeq")] public long EventSeq { get; set; } [JsonProperty("view")] public MatchView View { get; set; } } + + public sealed class CardCatalogItemDto + { + [JsonProperty("printingId")] public string PrintingId { get; set; } = string.Empty; + [JsonProperty("nameId")] public string NameId { get; set; } = string.Empty; + [JsonProperty("nameEn")] public string NameEn { get; set; } = string.Empty; + [JsonProperty("rarity")] public string Rarity { get; set; } = string.Empty; + [JsonProperty("grade")] public int Grade { get; set; } + [JsonProperty("trigger")] public string Trigger { get; set; } = "None"; + [JsonProperty("clan")] public string Clan { get; set; } = string.Empty; + [JsonProperty("isSentinel")] public bool IsSentinel { get; set; } + [JsonProperty("isParallel")] public bool IsParallel { get; set; } + [JsonProperty("basePrintingId")] public string BasePrintingId { get; set; } + } } diff --git a/client/Assets/Vanguard/Runtime/App/CollectionSceneController.cs b/client/Assets/Vanguard/Runtime/App/CollectionSceneController.cs index 41fb8ab..02eab81 100644 --- a/client/Assets/Vanguard/Runtime/App/CollectionSceneController.cs +++ b/client/Assets/Vanguard/Runtime/App/CollectionSceneController.cs @@ -18,6 +18,7 @@ namespace Vanguard.App Resolve(), Resolve(), () => _ = Flow.LoadLobbyAsync()); + _view.Show(); } private void OnDestroy() diff --git a/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs b/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs index 7b9f65e..66494f2 100644 --- a/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs +++ b/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs @@ -1,30 +1,31 @@ using UnityEngine; using Vanguard.Api.Contracts; +using Vanguard.Auth; using Vanguard.Meta; -using Vanguard.State; using Vanguard.UI; namespace Vanguard.App { public sealed class DeckBuilderSceneController : SceneControllerBase { - private DecksView _view; + private DeckBuilderView _view; private void Start() { SceneEnvironment.Build(SceneTheme.DeckBuilder); var canvas = CreateCanvas("DeckBuilder Canvas"); - _view = new DecksView( + _view = new DeckBuilderView( canvas.transform, Resolve(), - Resolve(), + Resolve(), () => _ = Flow.LoadLobbyAsync(), deck => _ = Flow.OpenLobbyWithQueueAsync(deck)); + _view.Show(); } private void OnDestroy() { - _view?.Dispose(); + _view?.Hide(); } } } diff --git a/client/Assets/Vanguard/Runtime/Meta/MetaService.cs b/client/Assets/Vanguard/Runtime/Meta/MetaService.cs index ed4ea69..da070ff 100644 --- a/client/Assets/Vanguard/Runtime/Meta/MetaService.cs +++ b/client/Assets/Vanguard/Runtime/Meta/MetaService.cs @@ -72,6 +72,11 @@ namespace Vanguard.Meta "/api/decks", new DeckRequest { Name = name, Cards = cards }); + public async Task UpdateDeckAsync(Guid deckId, string name, List cards) => + await _api.PutAsync( + "/api/decks/" + deckId.ToString("D"), + new DeckRequest { Name = name, Cards = cards }); + public Task DeleteDeckAsync(Guid deckId) => _api.DeleteAsync("/api/decks/" + deckId.ToString("D")); @@ -132,5 +137,9 @@ namespace Vanguard.Meta public async Task GetReplayAsync(string matchId, bool includeFrames = true) => await _api.GetAsync( "/api/replays/" + matchId + "?includeFrames=" + (includeFrames ? "true" : "false")); + + public async Task> GetCatalogAsync() => + await _api.GetAsync>("/api/cards") + ?? new List(); } } diff --git a/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs b/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs new file mode 100644 index 0000000..f8e33ab --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs @@ -0,0 +1,888 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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 +{ + /// + /// Простой декбилдер: коллекция слева, текущая колода справа, + /// фильтры в оверлее, статистика по триггерам и грейдам. + /// + public sealed class DeckBuilderView + { + private readonly MetaService _meta; + private readonly AuthService _auth; + private readonly Action _onBack; + private readonly Action _onPlay; + + private readonly RectTransform _root; + private readonly RectTransform _listView; + private readonly RectTransform _editorView; + private RectTransform _listContent; + private RectTransform _collectionContent; + private RectTransform _deckContent; + private InputField _nameInput; + private Text _listStatus; + private Text _editorStatus; + private Text _stats; + private Text _deckCount; + private Text _filterSummary; + private RectTransform _filterOverlay; + private RectTransform _clanContent; + private RectTransform _confirmOverlay; + private Text _confirmText; + private DeckDto _pendingDelete; + + private readonly List _decks = new List(); + private readonly List _collection = new List(); + private readonly Dictionary _catalog = + new Dictionary(StringComparer.Ordinal); + + private readonly List _cards = new List(); + private readonly Dictionary _collectionCountTexts = + new Dictionary(StringComparer.Ordinal); + private DeckDto _editing; + private string _filterClan = "Все"; + private string _filterTrigger = "Все"; + private int _filterGrade = -1; + private bool _loading; + private bool _busy; + + public DeckBuilderView( + Transform parent, + MetaService meta, + AuthService auth, + Action onBack, + Action onPlay) + { + _meta = meta; + _auth = auth; + _onBack = onBack; + _onPlay = onPlay; + + _root = UiKit.CreatePanel(parent, "DeckBuilderScreen", new Color(0.018f, 0.03f, 0.06f, 0.97f)); + + _listView = BuildListView(); + _editorView = BuildEditorView(); + _filterOverlay = BuildFilterOverlay(); + _confirmOverlay = BuildDeleteConfirm(); + _listView.gameObject.SetActive(false); + _editorView.gameObject.SetActive(false); + _filterOverlay.gameObject.SetActive(false); + _confirmOverlay.gameObject.SetActive(false); + _root.gameObject.SetActive(false); + } + + public void Show() + { + UiKit.SetActive(_root, true); + ShowList(); + _ = LoadAsync(); + } + + public void Hide() + { + _filterOverlay.gameObject.SetActive(false); + _confirmOverlay.gameObject.SetActive(false); + _root.gameObject.SetActive(false); + } + + private RectTransform BuildListView() + { + var view = UiKit.CreatePanel(_root, "DeckList", new Color(0.018f, 0.03f, 0.06f, 0.97f)); + + var top = BuildTopBar(view, "Колоды", out _); + var create = UiKit.CreateButton(top, "Create", "Создать колоду", NewDeck, 19); + UiKit.SetPreferredHeight(create, 46f, 0f); + + var body = UiKit.CreatePanel(view, "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 hint = UiKit.CreateText( + body, + "Hint", + "Откройте колоду для редактирования или создайте новую. «В бой» ставит колоду в очередь.", + 17, + new Color(0.60f, 0.68f, 0.84f), + TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(hint, 26f); + + var scroll = UiKit.CreateScrollView(body, "DeckListScroll", out _listContent, 8f); + var element = scroll.gameObject.AddComponent(); + element.flexibleHeight = 1f; + element.minHeight = 200f; + + _listStatus = UiKit.CreateText( + body, "Status", string.Empty, 18, + new Color(0.62f, 0.68f, 0.82f), TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(_listStatus, 34f); + + return view; + } + + private RectTransform BuildEditorView() + { + var view = UiKit.CreatePanel(_root, "DeckEditor", new Color(0.018f, 0.03f, 0.06f, 0.97f)); + + var top = UiKit.CreatePanel(view, "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, 18, 12); + + var back = UiKit.CreateButton(top, "Back", "← Колоды", BackToCards, 18); + UiKit.SetPreferredHeight(back, 44f, 0f); + + var title = UiKit.CreateText(top, "Title", "Конструктор", 28, Color.white, TextAnchor.MiddleLeft); + title.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(title, 44f, 0f); + + _nameInput = UiKit.CreateInput(top, "Name", "Название колоды"); + UiKit.SetPreferredHeight(_nameInput, 46f); + + var save = UiKit.CreateButton(top, "Save", "Сохранить", SaveDeck, 18); + UiKit.SetPreferredHeight(save, 44f, 0f); + + var body = UiKit.CreatePanel(view, "Body", new Color(0f, 0f, 0f, 0f)); + body.anchorMin = Vector2.zero; + body.anchorMax = Vector2.one; + body.offsetMin = new Vector2(34f, 30f); + body.offsetMax = new Vector2(-34f, -106f); + UiKit.AddHorizontalLayout(body, 0, 14); + + var left = UiKit.CreatePanel(body, "CollectionArea", new Color(0.04f, 0.06f, 0.10f, 0.7f)); + var leftElement = left.gameObject.AddComponent(); + leftElement.flexibleWidth = 1f; + leftElement.flexibleHeight = 1f; + leftElement.minHeight = 300f; + UiKit.AddVerticalLayout(left, 10, 6); + + var filterRow = UiKit.CreatePanel(left, "FilterRow", new Color(0f, 0f, 0f, 0f)); + UiKit.SetPreferredHeight(filterRow, 48f); + UiKit.AddHorizontalLayout(filterRow, 0, 10); + + var filters = UiKit.CreateButton(filterRow, "Filters", "Фильтры", OpenFilters, 18); + UiKit.SetPreferredHeight(filters, 44f, 0f); + + var reset = UiKit.CreateButton(filterRow, "ResetFilters", "Сбросить фильтры", ResetFilters, 17); + UiKit.SetPreferredHeight(reset, 44f, 0f); + + _filterSummary = UiKit.CreateText( + filterRow, "FilterSummary", "Фильтры: все", 16, + new Color(0.60f, 0.68f, 0.84f), TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(_filterSummary, 44f); + + var statsPanel = UiKit.CreatePanel(left, "Stats", new Color(0.06f, 0.08f, 0.13f, 1f)); + UiKit.SetPreferredHeight(statsPanel, 76f); + UiKit.AddVerticalLayout(statsPanel, 8, 2); + + _stats = UiKit.CreateText( + statsPanel, "Text", string.Empty, 16, + new Color(0.72f, 0.78f, 0.92f), TextAnchor.MiddleLeft); + _stats.horizontalOverflow = HorizontalWrapMode.Wrap; + UiKit.SetPreferredHeight(_stats, 56f); + + var gridScroll = UiKit.CreateGridScrollView( + left, + "CollectionGrid", + new Vector2(166f, 186f), + new Vector2(8f, 8f), + out _collectionContent); + var gridElement = gridScroll.gameObject.AddComponent(); + gridElement.flexibleHeight = 1f; + gridElement.minHeight = 220f; + + var right = UiKit.CreatePanel(body, "DeckArea", new Color(0.04f, 0.06f, 0.10f, 0.85f)); + var rightElement = right.gameObject.AddComponent(); + rightElement.preferredWidth = 430f; + rightElement.minWidth = 360f; + rightElement.flexibleWidth = 0f; + rightElement.flexibleHeight = 1f; + rightElement.minHeight = 300f; + UiKit.AddVerticalLayout(right, 10, 6); + + _deckCount = UiKit.CreateText( + right, "DeckCount", "Колода: 0/50", 20, Color.white, TextAnchor.MiddleLeft); + _deckCount.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(_deckCount, 30f); + + var deckScroll = UiKit.CreateHorizontalScrollView( + right, "DeckScroll", out _deckContent, 6f); + var deckElement = deckScroll.gameObject.AddComponent(); + deckElement.flexibleHeight = 1f; + deckElement.minHeight = 220f; + + var editorStatusPanel = UiKit.CreatePanel(right, "StatusPanel", new Color(0f, 0f, 0f, 0f)); + UiKit.SetPreferredHeight(editorStatusPanel, 54f); + _editorStatus = UiKit.CreateText( + editorStatusPanel, "Status", string.Empty, 16, + new Color(0.62f, 0.68f, 0.82f), TextAnchor.UpperLeft); + _editorStatus.horizontalOverflow = HorizontalWrapMode.Wrap; + UiKit.SetPreferredHeight(_editorStatus, 50f); + + return view; + } + + private RectTransform BuildFilterOverlay() + { + var overlay = UiKit.CreatePanel( + _editorView, "FilterOverlay", new Color(0f, 0f, 0f, 0.78f)); + + var card = UiKit.CreateCenteredCard( + overlay, "FilterCard", new Vector2(860f, 820f), + new Color(0.06f, 0.08f, 0.13f, 1f)); + UiKit.AddVerticalLayout(card, 26, 10); + + var title = UiKit.CreateText(card, "Title", "Фильтры коллекции", 28, Color.white); + title.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(title, 40f); + + AddFilterSection(card, "Ранг", new[] { "Все", "0", "1", "2", "3" }, OnGradeFilter); + AddFilterSection(card, "Триггер", new[] { "Все", "None", "Critical", "Draw", "Stand", "Heal" }, OnTriggerFilter); + + var clanTitle = UiKit.CreateText( + card, "ClanTitle", "Клан", 17, new Color(0.66f, 0.74f, 0.90f), TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(clanTitle, 24f); + + var clanScroll = UiKit.CreateScrollView(card, "ClanScroll", out _clanContent, 4f); + var clanElement = clanScroll.gameObject.AddComponent(); + clanElement.flexibleHeight = 1f; + clanElement.minHeight = 150f; + + var close = UiKit.CreateButton(card, "Close", "Готово", CloseFilters, 20); + UiKit.SetPreferredHeight(close, 48f); + + return overlay; + } + + private void RebuildClanFilters() + { + ClearChildren(_clanContent); + AddClanButton(_clanContent, "Все"); + foreach (var clan in _catalog.Values.Select(item => item.Clan).Distinct().OrderBy(item => item)) + AddClanButton(_clanContent, clan); + } + + private RectTransform BuildDeleteConfirm() + { + var overlay = UiKit.CreatePanel( + _root, "DeleteDeckConfirm", new Color(0f, 0f, 0f, 0.78f)); + + var card = UiKit.CreateCenteredCard( + overlay, "ConfirmCard", new Vector2(640f, 300f), + new Color(0.06f, 0.08f, 0.13f, 1f)); + UiKit.AddVerticalLayout(card, 30, 12); + + var title = UiKit.CreateText(card, "Title", "Удалить колоду?", 28, Color.white); + title.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(title, 42f); + + _confirmText = UiKit.CreateText( + card, "Text", string.Empty, 19, + new Color(0.72f, 0.78f, 0.92f)); + UiKit.SetPreferredHeight(_confirmText, 70f); + + var yes = UiKit.CreateButton(card, "Yes", "Удалить", ConfirmDelete, 20); + UiKit.SetPreferredHeight(yes, 48f); + + var no = UiKit.CreateButton(card, "No", "Отмена", CancelDelete, 20); + UiKit.SetPreferredHeight(no, 44f); + + return overlay; + } + + private void RequestDelete(DeckDto deck) + { + _pendingDelete = deck; + _confirmText.text = deck.Name + "\nКолода будет удалена без восстановления."; + _confirmOverlay.SetAsLastSibling(); + _confirmOverlay.gameObject.SetActive(true); + } + + private void CancelDelete() + { + _pendingDelete = null; + _confirmOverlay.gameObject.SetActive(false); + } + + private void ConfirmDelete() + { + var deck = _pendingDelete; + _confirmOverlay.gameObject.SetActive(false); + _pendingDelete = null; + if (deck != null) + DeleteDeck(deck); + } + + private void AddFilterSection(Transform parent, string title, string[] labels, Action onSelected) + { + var caption = UiKit.CreateText( + parent, "Caption_" + title, title, 17, + new Color(0.66f, 0.74f, 0.90f), TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(caption, 24f); + + var row = UiKit.CreatePanel(parent, "Row_" + title, new Color(0f, 0f, 0f, 0f)); + UiKit.SetPreferredHeight(row, 44f); + UiKit.AddHorizontalLayout(row, 0, 6); + + foreach (var label in labels) + { + var captured = label; + var button = UiKit.CreateButton( + row, "Filter_" + label, label, () => onSelected(captured), 16); + UiKit.SetPreferredHeight(button, 40f); + } + } + + private void AddClanButton(Transform parent, string clan) + { + var captured = clan; + var button = UiKit.CreateButton( + parent, "Clan_" + clan, clan, () => OnClanFilter(captured), 16); + UiKit.SetPreferredHeight(button, 40f); + } + + private RectTransform BuildTopBar(RectTransform view, string titleText, out Text title) + { + var top = UiKit.CreatePanel(view, "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, 18, 12); + + var back = UiKit.CreateButton(top, "Back", "← В лобби", () => _onBack(), 18); + UiKit.SetPreferredHeight(back, 44f, 0f); + + title = UiKit.CreateText(top, "Title", titleText, 30, Color.white, TextAnchor.MiddleLeft); + title.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(title, 46f); + return top; + } + + private async Task LoadAsync() + { + if (_loading) + return; + + _loading = true; + _listStatus.text = "Загружаем коллекцию и колоды…"; + _listStatus.color = new Color(0.62f, 0.68f, 0.82f); + ClearChildren(_listContent); + + try + { + await _auth.EnsureAccessAsync(); + var collection = await _meta.GetCollectionAsync(); + var catalog = await _meta.GetCatalogAsync(); + var decks = await _meta.GetDecksAsync(); + if (!_root.gameObject.activeSelf) + return; + + _collection.Clear(); + _collection.AddRange(collection.Items ?? new List()); + _catalog.Clear(); + foreach (var item in catalog) + _catalog[item.PrintingId] = item; + RebuildClanFilters(); + _decks.Clear(); + _decks.AddRange(decks); + + BuildDeckList(); + _listStatus.text = _decks.Count == 0 + ? "Колод пока нет. Создайте первую." + : "Колод: " + _decks.Count + "."; + } + catch (ApiException exception) + { + _listStatus.text = exception.Message; + _listStatus.color = new Color(1f, 0.55f, 0.45f); + } + catch (Exception exception) + { + Debug.LogException(exception); + _listStatus.text = "Не удалось загрузить декбилдер."; + _listStatus.color = new Color(1f, 0.55f, 0.45f); + } + finally + { + _loading = false; + } + } + + private void BuildDeckList() + { + ClearChildren(_listContent); + if (_decks.Count == 0) + return; + + foreach (var deck in _decks) + { + var row = UiKit.CreatePanel( + _listContent, "Deck_" + deck.Id, new Color(0.07f, 0.09f, 0.14f, 1f)); + var rowElement = row.gameObject.AddComponent(); + rowElement.preferredHeight = 82f; + UiKit.AddHorizontalLayout(row, 14, 10); + + var name = UiKit.CreateText( + row, "Name", deck.Name, 21, Color.white, TextAnchor.MiddleLeft); + name.fontStyle = FontStyle.Bold; + var nameElement = name.gameObject.AddComponent(); + nameElement.flexibleWidth = 1f; + nameElement.preferredHeight = 40f; + + var info = UiKit.CreateText( + row, "Info", deck.Cards.Count + " карт · " + deck.Format, 16, + new Color(0.60f, 0.68f, 0.84f), TextAnchor.MiddleLeft); + var infoElement = info.gameObject.AddComponent(); + infoElement.preferredWidth = 190f; + infoElement.preferredHeight = 36f; + infoElement.flexibleWidth = 0f; + + var edit = UiKit.CreateButton(row, "Edit", "Открыть", () => OpenDeck(deck), 17); + var editElement = edit.gameObject.AddComponent(); + editElement.preferredWidth = 120f; + editElement.preferredHeight = 44f; + editElement.flexibleWidth = 0f; + + var play = UiKit.CreateButton(row, "Play", "В бой", () => _onPlay(deck), 17); + var playElement = play.gameObject.AddComponent(); + playElement.preferredWidth = 100f; + playElement.preferredHeight = 44f; + playElement.flexibleWidth = 0f; + + var delete = UiKit.CreateButton(row, "Delete", "Удалить", () => RequestDelete(deck), 16); + var deleteElement = delete.gameObject.AddComponent(); + deleteElement.preferredWidth = 105f; + deleteElement.preferredHeight = 44f; + deleteElement.flexibleWidth = 0f; + } + } + + private void NewDeck() + { + _editing = null; + _cards.Clear(); + _nameInput.text = "Новая колода"; + ShowEditor(); + } + + private void OpenDeck(DeckDto deck) + { + _editing = deck; + _cards.Clear(); + _cards.AddRange(deck.Cards); + _nameInput.text = deck.Name; + ShowEditor(); + } + + private void ShowEditor() + { + _listView.gameObject.SetActive(false); + _editorView.gameObject.SetActive(true); + _nameInput.text = _editing?.Name ?? "Новая колода"; + _deckCount.text = "Колода: " + _cards.Count + "/50"; + _editorStatus.text = string.Empty; + RebuildEditor(); + } + + private void BackToCards() + { + _filterOverlay.gameObject.SetActive(false); + _confirmOverlay.gameObject.SetActive(false); + _editorView.gameObject.SetActive(false); + _listView.gameObject.SetActive(true); + BuildDeckList(); + } + + private void RebuildEditor() + { + UpdateStats(); + RebuildDeck(); + RebuildCollection(); + _deckCount.text = "Колода: " + _cards.Count + "/50"; + } + + private void RebuildCollection() + { + ClearChildren(_collectionContent); + _collectionCountTexts.Clear(); + if (_catalog.Count == 0) + return; + + foreach (var item in _collection.OrderBy(item => GradeOf(item)).ThenBy(item => item.NameEn, StringComparer.Ordinal)) + { + if (!_catalog.TryGetValue(item.PrintingId, out var meta)) + continue; + + if (_filterGrade >= 0 && meta.Grade != _filterGrade) + continue; + if (_filterTrigger != "Все" && meta.Trigger != _filterTrigger) + continue; + if (_filterClan != "Все" && meta.Clan != _filterClan) + continue; + + AddCollectionCell(item, meta); + } + } + + private void AddCollectionCell(CollectionItemDto item, CardCatalogItemDto meta) + { + var cell = UiKit.CreatePanel( + _collectionContent, "Owned_" + item.PrintingId, new Color(0.08f, 0.11f, 0.17f, 1f)); + var button = cell.gameObject.AddComponent