Add card catalog API and deck builder
CI / test (push) Successful in 7m35s Details

This commit is contained in:
MpakobecEx 2026-10-21 18:50:01 +05:00
parent 5199e11d2d
commit 6b4b2ab389
16 changed files with 1169 additions and 59 deletions

View File

@ -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 карт, вторая

View File

@ -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` — каталог карт с разбором состава сетов.

View File

@ -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<T> PutAsync<T>(string path, object body, bool authenticated = true)
{
var response = await SendAsync("PUT", path, body, authenticated);
return string.IsNullOrEmpty(response)
? default
: JsonConvert.DeserializeObject<T>(response, JsonSettings);
}
public Task DeleteAsync(string path, bool authenticated = true) =>
SendAsync("DELETE", path, null, authenticated);

View File

@ -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; }
}
}

View File

@ -18,6 +18,7 @@ namespace Vanguard.App
Resolve<MetaService>(),
Resolve<PlayerState>(),
() => _ = Flow.LoadLobbyAsync());
_view.Show();
}
private void OnDestroy()

View File

@ -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<MetaService>(),
Resolve<PlayerState>(),
Resolve<AuthService>(),
() => _ = Flow.LoadLobbyAsync(),
deck => _ = Flow.OpenLobbyWithQueueAsync(deck));
_view.Show();
}
private void OnDestroy()
{
_view?.Dispose();
_view?.Hide();
}
}
}

View File

@ -72,6 +72,11 @@ namespace Vanguard.Meta
"/api/decks",
new DeckRequest { Name = name, Cards = cards });
public async Task<DeckDto> UpdateDeckAsync(Guid deckId, string name, List<string> cards) =>
await _api.PutAsync<DeckDto>(
"/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<ReplayResponseDto> GetReplayAsync(string matchId, bool includeFrames = true) =>
await _api.GetAsync<ReplayResponseDto>(
"/api/replays/" + matchId + "?includeFrames=" + (includeFrames ? "true" : "false"));
public async Task<List<CardCatalogItemDto>> GetCatalogAsync() =>
await _api.GetAsync<List<CardCatalogItemDto>>("/api/cards")
?? new List<CardCatalogItemDto>();
}
}

View File

@ -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
{
/// <summary>
/// Простой декбилдер: коллекция слева, текущая колода справа,
/// фильтры в оверлее, статистика по триггерам и грейдам.
/// </summary>
public sealed class DeckBuilderView
{
private readonly MetaService _meta;
private readonly AuthService _auth;
private readonly Action _onBack;
private readonly Action<DeckDto> _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<DeckDto> _decks = new List<DeckDto>();
private readonly List<CollectionItemDto> _collection = new List<CollectionItemDto>();
private readonly Dictionary<string, CardCatalogItemDto> _catalog =
new Dictionary<string, CardCatalogItemDto>(StringComparer.Ordinal);
private readonly List<string> _cards = new List<string>();
private readonly Dictionary<string, Text> _collectionCountTexts =
new Dictionary<string, Text>(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<DeckDto> 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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
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<string> 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<CollectionItemDto>());
_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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
infoElement.preferredWidth = 190f;
infoElement.preferredHeight = 36f;
infoElement.flexibleWidth = 0f;
var edit = UiKit.CreateButton(row, "Edit", "Открыть", () => OpenDeck(deck), 17);
var editElement = edit.gameObject.AddComponent<LayoutElement>();
editElement.preferredWidth = 120f;
editElement.preferredHeight = 44f;
editElement.flexibleWidth = 0f;
var play = UiKit.CreateButton(row, "Play", "В бой", () => _onPlay(deck), 17);
var playElement = play.gameObject.AddComponent<LayoutElement>();
playElement.preferredWidth = 100f;
playElement.preferredHeight = 44f;
playElement.flexibleWidth = 0f;
var delete = UiKit.CreateButton(row, "Delete", "Удалить", () => RequestDelete(deck), 16);
var deleteElement = delete.gameObject.AddComponent<LayoutElement>();
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<Button>();
var image = cell.GetComponent<Image>();
button.targetGraphic = image;
button.onClick.AddListener(() => AddCard(item));
UiKit.AddVerticalLayout(cell, 6, 2);
var name = UiKit.CreateText(
cell, "Name", item.NameEn, 14, Color.white, TextAnchor.UpperLeft);
name.fontStyle = FontStyle.Bold;
name.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(name, 34f);
var printing = UiKit.CreateText(
cell, "Printing", item.PrintingId, 11,
new Color(0.56f, 0.64f, 0.78f), TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(printing, 16f);
var info = UiKit.CreateText(
cell, "Info", "G" + meta.Grade + " · " + meta.Trigger + " · " + meta.Clan,
12,
new Color(0.66f, 0.74f, 0.90f),
TextAnchor.MiddleLeft);
info.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(info, 30f);
var counts = UiKit.CreateText(
cell,
"Counts",
meta.Rarity + " · есть " + item.Count + " · в колоде " + UsedByName(item.NameId),
12,
new Color(0.56f, 0.64f, 0.78f),
TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(counts, 16f);
_collectionCountTexts[item.PrintingId] = counts;
}
private void RebuildDeck()
{
ClearChildren(_deckContent);
if (_cards.Count == 0)
{
var empty = UiKit.CreateText(
_deckContent, "Empty", "Колода пуста", 16,
new Color(0.62f, 0.68f, 0.82f));
UiKit.SetPreferredHeight(empty, 120f);
return;
}
foreach (var group in _cards.GroupBy(card => card).OrderBy(group => group.Key))
{
var printingId = group.Key;
var count = group.Count();
var name = _catalog.TryGetValue(printingId, out var meta)
? meta.NameEn
: printingId;
var cell = UiKit.CreatePanel(
_deckContent, "Deck_" + printingId, new Color(0.09f, 0.12f, 0.20f, 1f));
var element = cell.gameObject.AddComponent<LayoutElement>();
element.preferredWidth = 142f;
element.preferredHeight = 190f;
element.flexibleWidth = 0f;
UiKit.AddVerticalLayout(cell, 6, 2);
var nameText = UiKit.CreateText(
cell, "Name", name, 13, Color.white, TextAnchor.UpperLeft);
nameText.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(nameText, 38f);
var idText = UiKit.CreateText(
cell, "Printing", printingId, 10,
new Color(0.56f, 0.64f, 0.78f));
UiKit.SetPreferredHeight(idText, 16f);
var info = UiKit.CreateText(
cell,
"Info",
"×" + count + (meta != null ? " · G" + meta.Grade + " · " + meta.Trigger : string.Empty),
12,
new Color(0.70f, 0.78f, 0.94f));
UiKit.SetPreferredHeight(info, 22f);
var remove = UiKit.CreateButton(
cell, "Remove", "−", () => RemoveCard(printingId), 20);
UiKit.SetPreferredHeight(remove, 38f);
}
}
private void AddCard(CollectionItemDto item)
{
if (!_catalog.TryGetValue(item.PrintingId, out var meta))
return;
if (_cards.Count >= 50)
{
SetEditorStatus("В колоде уже 50 карт.");
return;
}
if (UsedByName(item.NameId) >= 4)
{
SetEditorStatus("Не больше 4 копий одной карты.");
return;
}
if (_cards.Count(card => card == item.PrintingId) >= item.Count)
{
SetEditorStatus("В коллекции больше нет свободных копий.");
return;
}
_cards.Add(item.PrintingId);
SetEditorStatus(string.Empty);
RefreshAfterDeckChange();
}
private void RemoveCard(string printingId)
{
var index = _cards.LastIndexOf(printingId);
if (index >= 0)
_cards.RemoveAt(index);
RefreshAfterDeckChange();
}
private void RefreshAfterDeckChange()
{
UpdateStats();
RebuildDeck();
RefreshCollectionCounts();
_deckCount.text = "Колода: " + _cards.Count + "/50";
}
private void RefreshCollectionCounts()
{
foreach (var pair in _collectionCountTexts)
{
var item = _collection.Find(candidate => candidate.PrintingId == pair.Key);
if (item == null || !_catalog.TryGetValue(pair.Key, out var meta))
continue;
pair.Value.text = meta.Rarity + " · есть " + item.Count
+ " · в колоде " + UsedByName(item.NameId);
}
}
private void UpdateStats()
{
var triggers = new Dictionary<string, int>(StringComparer.Ordinal)
{
["—"] = 0, ["K"] = 0, ["D"] = 0, ["S"] = 0, ["H"] = 0,
};
var grades = new int[4];
foreach (var printingId in _cards)
{
if (!_catalog.TryGetValue(printingId, out var meta))
continue;
if (meta.Grade >= 0 && meta.Grade < grades.Length)
grades[meta.Grade]++;
var key = meta.Trigger switch
{
"Critical" => "K",
"Draw" => "D",
"Stand" => "S",
"Heal" => "H",
_ => "—",
};
triggers[key]++;
}
_stats.text = "Триггеры: — " + triggers["—"] + " · K " + triggers["K"]
+ " · D " + triggers["D"] + " · S " + triggers["S"] + " · H " + triggers["H"]
+ "\nGrade: 0 — " + grades[0] + " · 1 — " + grades[1]
+ " · 2 — " + grades[2] + " · 3 — " + grades[3];
}
private int UsedByName(string nameId) =>
_cards.Count(printingId =>
_catalog.TryGetValue(printingId, out var meta) && meta.NameId == nameId);
private int GradeOf(CollectionItemDto item) =>
_catalog.TryGetValue(item.PrintingId, out var meta) ? meta.Grade : int.MaxValue;
private void OpenFilters()
{
_filterOverlay.SetAsLastSibling();
_filterOverlay.gameObject.SetActive(true);
}
private void CloseFilters()
{
_filterOverlay.gameObject.SetActive(false);
}
private void OnGradeFilter(string value)
{
_filterGrade = value == "Все" ? -1 : int.Parse(value);
UpdateFilterSummary();
RebuildCollection();
}
private void OnTriggerFilter(string value)
{
_filterTrigger = value;
UpdateFilterSummary();
RebuildCollection();
}
private void OnClanFilter(string value)
{
_filterClan = value;
UpdateFilterSummary();
RebuildCollection();
}
private void ResetFilters()
{
_filterGrade = -1;
_filterTrigger = "Все";
_filterClan = "Все";
UpdateFilterSummary();
RebuildCollection();
}
private void UpdateFilterSummary()
{
_filterSummary.text = "Фильтры: "
+ (_filterClan == "Все" ? "клан все" : "клан " + _filterClan)
+ " · " + (_filterTrigger == "Все" ? "триггер все" : _filterTrigger)
+ " · " + (_filterGrade < 0 ? "grade все" : "grade " + _filterGrade);
}
private async void SaveDeck()
{
if (_busy)
return;
_busy = true;
SetEditorStatus("Сохраняем…");
try
{
await _auth.EnsureAccessAsync();
var name = string.IsNullOrWhiteSpace(_nameInput.text)
? "Новая колода"
: _nameInput.text.Trim();
var result = _editing == null
? await _meta.CreateDeckAsync(name, new List<string>(_cards))
: await _meta.UpdateDeckAsync(_editing.Id, name, new List<string>(_cards));
_editing = result;
_cards.Clear();
_cards.AddRange(result.Cards);
_nameInput.text = result.Name;
UpsertDeck(result);
SetEditorStatus("Сохранено: " + result.Cards.Count + " карт.");
_deckCount.text = "Колода: " + _cards.Count + "/50";
RebuildEditor();
}
catch (ApiException exception)
{
var details = exception.Violations.Count > 0
? "\n" + string.Join("\n", exception.Violations.Take(3).Select(v => v.Message))
: string.Empty;
SetEditorStatus(exception.Message + details);
}
catch (Exception exception)
{
Debug.LogException(exception);
SetEditorStatus("Не удалось сохранить колоду.");
}
finally
{
_busy = false;
}
}
private void UpsertDeck(DeckDto deck)
{
var index = _decks.FindIndex(item => item.Id == deck.Id);
if (index >= 0)
_decks[index] = deck;
else
_decks.Add(deck);
}
private async void DeleteDeck(DeckDto deck)
{
if (_busy)
return;
_busy = true;
try
{
await _meta.DeleteDeckAsync(deck.Id);
_decks.RemoveAll(item => item.Id == deck.Id);
BuildDeckList();
_listStatus.text = "Колода удалена.";
}
catch (ApiException exception)
{
_listStatus.text = exception.Message;
}
catch (Exception exception)
{
Debug.LogException(exception);
_listStatus.text = "Не удалось удалить колоду.";
}
finally
{
_busy = false;
}
}
private void SetEditorStatus(string text)
{
_editorStatus.text = text;
_editorStatus.color = text.StartsWith("Не удалось", StringComparison.Ordinal)
|| text.Contains("Не больше") || text.Contains("В колоде уже") || text.Contains("больше нет")
? new Color(1f, 0.55f, 0.45f)
: new Color(0.62f, 0.68f, 0.82f);
}
private void ShowList()
{
_editorView.gameObject.SetActive(false);
_filterOverlay.gameObject.SetActive(false);
_confirmOverlay.gameObject.SetActive(false);
_listView.gameObject.SetActive(true);
}
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: c5f4c9e2a8d34b6e9f71b2d0a3c8614f

View File

@ -241,31 +241,66 @@ namespace Vanguard.UI
out RectTransform content,
float spacing = 8f)
{
var root = new GameObject(
name,
typeof(RectTransform),
typeof(Image),
typeof(ScrollRect));
var rootRect = (RectTransform)root.transform;
rootRect.SetParent(parent, false);
Stretch(rootRect);
var root = CreateScrollRoot(parent, name);
var viewport = CreateViewport(root);
content = CreateVerticalContent(viewport, spacing);
ConfigureScroll(root, viewport, content, horizontal: false);
return root;
}
var background = root.GetComponent<Image>();
background.color = new Color(0f, 0f, 0f, 0.01f);
background.raycastTarget = true;
var viewportGo = new GameObject(
"Viewport",
typeof(RectTransform),
typeof(RectMask2D));
var viewport = (RectTransform)viewportGo.transform;
viewport.SetParent(rootRect, false);
Stretch(viewport);
public static RectTransform CreateHorizontalScrollView(
Transform parent,
string name,
out RectTransform content,
float spacing = 8f)
{
var root = CreateScrollRoot(parent, name);
var viewport = CreateViewport(root);
var contentGo = new GameObject(
"Content",
typeof(RectTransform),
typeof(VerticalLayoutGroup),
typeof(HorizontalLayoutGroup),
typeof(ContentSizeFitter));
content = (RectTransform)contentGo.transform;
content.SetParent(viewport, false);
content.anchorMin = new Vector2(0f, 0.5f);
content.anchorMax = new Vector2(0f, 0.5f);
content.pivot = new Vector2(0f, 0.5f);
content.anchoredPosition = Vector2.zero;
content.sizeDelta = Vector2.zero;
var layout = contentGo.GetComponent<HorizontalLayoutGroup>();
layout.padding = new RectOffset(4, 4, 4, 4);
layout.spacing = spacing;
layout.childAlignment = TextAnchor.MiddleLeft;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = false;
layout.childForceExpandHeight = false;
var fitter = contentGo.GetComponent<ContentSizeFitter>();
fitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.verticalFit = ContentSizeFitter.FitMode.Unconstrained;
ConfigureScroll(root, viewport, content, horizontal: true);
return root;
}
public static RectTransform CreateGridScrollView(
Transform parent,
string name,
Vector2 cellSize,
Vector2 spacing,
out RectTransform content)
{
var root = CreateScrollRoot(parent, name);
var viewport = CreateViewport(root);
var contentGo = new GameObject(
"Content",
typeof(RectTransform),
typeof(GridLayoutGroup),
typeof(ContentSizeFitter));
content = (RectTransform)contentGo.transform;
content.SetParent(viewport, false);
@ -273,9 +308,68 @@ namespace Vanguard.UI
content.anchorMax = new Vector2(1f, 1f);
content.pivot = new Vector2(0.5f, 1f);
content.anchoredPosition = Vector2.zero;
content.sizeDelta = new Vector2(0f, 0f);
content.sizeDelta = Vector2.zero;
var layout = contentGo.GetComponent<VerticalLayoutGroup>();
var layout = contentGo.GetComponent<GridLayoutGroup>();
layout.cellSize = cellSize;
layout.spacing = spacing;
layout.padding = new RectOffset(4, 4, 4, 4);
layout.startCorner = GridLayoutGroup.Corner.UpperLeft;
layout.startAxis = GridLayoutGroup.Axis.Horizontal;
layout.childAlignment = TextAnchor.UpperLeft;
layout.constraint = GridLayoutGroup.Constraint.Flexible;
layout.constraintCount = 1;
var fitter = contentGo.GetComponent<ContentSizeFitter>();
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
ConfigureScroll(root, viewport, content, horizontal: false);
return root;
}
private static RectTransform CreateScrollRoot(Transform parent, string name)
{
var root = new GameObject(
name,
typeof(RectTransform),
typeof(Image),
typeof(ScrollRect));
var rect = (RectTransform)root.transform;
rect.SetParent(parent, false);
Stretch(rect);
var image = root.GetComponent<Image>();
image.color = new Color(0f, 0f, 0f, 0.01f);
image.raycastTarget = true;
return rect;
}
private static RectTransform CreateViewport(RectTransform root)
{
var go = new GameObject("Viewport", typeof(RectTransform), typeof(RectMask2D));
var viewport = (RectTransform)go.transform;
viewport.SetParent(root, false);
Stretch(viewport);
return viewport;
}
private static RectTransform CreateVerticalContent(RectTransform viewport, float spacing)
{
var go = new GameObject(
"Content",
typeof(RectTransform),
typeof(VerticalLayoutGroup),
typeof(ContentSizeFitter));
var content = (RectTransform)go.transform;
content.SetParent(viewport, false);
content.anchorMin = new Vector2(0f, 1f);
content.anchorMax = new Vector2(1f, 1f);
content.pivot = new Vector2(0.5f, 1f);
content.anchoredPosition = Vector2.zero;
content.sizeDelta = Vector2.zero;
var layout = go.GetComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(4, 4, 4, 4);
layout.spacing = spacing;
layout.childAlignment = TextAnchor.UpperCenter;
@ -284,21 +378,27 @@ namespace Vanguard.UI
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
var fitter = contentGo.GetComponent<ContentSizeFitter>();
var fitter = go.GetComponent<ContentSizeFitter>();
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
return content;
}
private static void ConfigureScroll(
RectTransform root,
RectTransform viewport,
RectTransform content,
bool horizontal)
{
var scroll = root.GetComponent<ScrollRect>();
scroll.viewport = viewport;
scroll.content = content;
scroll.horizontal = false;
scroll.vertical = true;
scroll.horizontal = horizontal;
scroll.vertical = !horizontal;
scroll.movementType = ScrollRect.MovementType.Clamped;
scroll.scrollSensitivity = 30f;
scroll.inertia = true;
scroll.decelerationRate = 0.135f;
return rootRect;
}
public static void Stretch(RectTransform rect)

View File

@ -38,8 +38,8 @@ namespace Vanguard.Tests
canvas.transform, auth, meta, () => { }, () => { });
var collection = new CollectionView(
canvas.transform, meta, player, () => { });
var decks = new DecksView(
canvas.transform, meta, player, () => { }, _ => { });
var decks = new DeckBuilderView(
canvas.transform, meta, auth, () => { }, _ => { });
var queue = new QueueView(
canvas.transform, meta, auth, () => { }, _ => { });
var store = new StoreView(
@ -53,7 +53,7 @@ namespace Vanguard.Tests
Assert.IsTrue(Exists("MetaScreen"));
Assert.IsTrue(Exists("OnboardingScreen"));
Assert.IsTrue(Exists("CollectionScreen"));
Assert.IsTrue(Exists("DecksScreen"));
Assert.IsTrue(Exists("DeckBuilderScreen"));
Assert.IsTrue(Exists("StoreScreen"));
Assert.IsTrue(Exists("HistoryScreen"));
Assert.IsTrue(Exists("ReplayScreen"));
@ -63,7 +63,6 @@ namespace Vanguard.Tests
// После смены сцены старые views не должны реагировать на PlayerState.
lobby.Dispose();
collection.Dispose();
decks.Dispose();
store.Dispose();
player.Apply(new MeResponse
{

View File

@ -50,8 +50,8 @@ http://localhost:5080
- Восстановление сессии по refresh-токену при старте.
- Онбординг: две триал-деки из `/api/store`, выбор одной, 50 карт и 5 токенов.
- Коллекция: список карт, балансы, разбор в пыль с подтверждением.
- Колоды: список, удаление, автосборка стартовой колоды из выбранной
триал-деки.
- Колоды: список, удаление, создание/редактирование в декбилдере;
фильтры (клан/грейд/триггер), статистика, текущая колода.
- Магазин в Lobby: бустер за Gold/токен, показ 5 карт, покупка второй
триал-деки.
- История матчей: список результатов, награды, MMR; просмотр реплея по
@ -65,14 +65,10 @@ http://localhost:5080
## Что дальше
1. Разнести gray-box по 3D-сценам **Login / Lobby / Collection /
DeckBuilder / Battle** и добавить `SceneFlow`.
2. Довести боевой UI: арты карт, зоны, анимации, скип/ускорение.
3. Реконнект на всех шагах и аккуратная обработка таймаутов.
4. Полный декбилдер с фильтрами и счётчиками.
5. Магазин и открытие бустера.
6. История матчей и реплей.
7. Локализация RU/EN, настройки, DOTween-анимации.
1. Довести боевой UI: арты карт, зоны, анимации, скип/ускорение.
2. Реконнект на всех шагах и аккуратная обработка таймаутов.
3. Декбилдер: drag&drop, live-валидация, редкость/тип/арт, пресеты.
4. Локализация RU/EN, настройки, DOTween-анимации.
## Плагины

View File

@ -703,3 +703,22 @@ DeckBuilder / Battle; Boot и общие сервисы остаются пер
останется без способностей; проверять покрытие скриптом.
- **Тестовые колоды.** `BuildDeck()` в тестах не содержит все карты; для
конкретной карты использовать `MakeCard()` и класть вручную.
## 2026-10-21 — декбилдер и каталог карт
- Добавлен `GET /api/cards` (Bearer): все принты с `grade`, `trigger`,
`clan`, `rarity`, `isSentinel`, `isParallel`, `basePrintingId`.
Клиент объединяет каталог с коллекцией по `printingId`; правила для
фильтров/статистики не дублируются в клиенте.
- Новый `DeckBuilderView`: список колод → создание/редактирование.
В редакторе: фильтры в оверлее (grade/trigger/clan) и отдельный сброс,
статистика по триггерам и грейдам, коллекция сеткой, справа
горизонтальная зона текущей колоды, сохранение через POST/PUT.
- Ограничения в UI: 50 карт, ≤4 по `nameId`, не больше owned-копий;
серверная валидация остаётся источником истины.
- `DeckBuilderSceneController` и `CollectionSceneController` теперь
вызывают `Show()`; раньше сцены создавали скрытый корень.
- `MetaService.GetCatalogAsync`, `UpdateDeckAsync`; `ApiClient.PutAsync`.
- Проверки: серверные 131 + 44 на `ssh home`; Unity EditMode 2/2,
`SCENE_SMOKE success=True active=Login`; live `GET /api/cards` — 124
принта.

View File

@ -39,11 +39,14 @@
- Сервисы: `ApiClient`, `AuthService`, `MetaService`, `TokenStore`,
`PlayerState`, `GameConnection`.
- Работает: логин/регистрация/refresh, онбординг, коллекция с разбором,
список/удаление/автосборка колоды, магазин (бустер/триал-дека),
история матчей и реплей, очередь, WebSocket-матч (snapshot, интенты,
маллиган, сдача, `GameFinished`), таймер, результат.
- Не готово: полноценный декбилдер, арты/модели/анимации, локализация
RU/EN, настройки, DOTween.
простой декбилдер (коллекция + текущая колода, фильтры в оверлее,
статистика триггеров/грейдов, создание/редактирование/сохранение),
магазин (бустер/триал-дека), история матчей и реплей, очередь,
WebSocket-матч (snapshot, интенты, маллиган, сдача, `GameFinished`),
таймер, результат.
- Декбилдер: `DeckBuilderView`; каталог метаданных — `GET /api/cards`
(grade, trigger, clan, rarity, printing), клиент объединяет с коллекцией.
- Не готово: арты/модели/анимации, локализация RU/EN, настройки, DOTween.
## Запуск
@ -98,7 +101,7 @@ client/Assets/Vanguard/Runtime/App/
BattleSceneController.cs
client/Assets/Vanguard/Runtime/UI/
LoginView.cs MetaView.cs OnboardingView.cs
CollectionView.cs DecksView.cs QueueView.cs BattleView.cs
CollectionView.cs DeckBuilderView.cs QueueView.cs BattleView.cs
UiKit.cs
client/Assets/Vanguard/Runtime/Battle/GameConnection.cs
client/Assets/Vanguard/Runtime/Api/ApiClient.cs + ApiContracts.cs
@ -123,7 +126,7 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack
## Следующие шаги
1. Боевой UI: арты, зоны, анимации, скип; реконнект и таймауты.
2. Полный декбилдер: фильтры, счётчики, валидация.
2. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты.
3. 3D-наполнение: модели/арт, prefab-экраны, камера.
4. Локализация RU/EN, настройки, DOTween.
5. Лаунчер WPF.

View File

@ -173,6 +173,35 @@ POST /api/collection/craft [Bearer]
Оба эндпоинта возвращают актуальные `Balances`; `idempotencyKey` защищает
от двойного списания/начисления.
### Каталог карт
```
GET /api/cards [Bearer]
```
Отдаёт все принты каталога для декбилдера и фильтров. Клиент объединяет
их с коллекцией по `printingId`.
```json
[
{
"printingId": "BT01/034EN",
"nameId": "BT01-034",
"nameEn": "Dragon Monk, Ginkai",
"rarity": "C",
"grade": 2,
"trigger": "None",
"clan": "Tachikaze",
"isSentinel": false,
"isParallel": false,
"basePrintingId": null
}
]
```
`grade` — 0–3, `trigger` — `None/Critical/Draw/Stand/Heal`,
`clan` — имя клана, `rarity` — `C/R/RR/RRR/SP/TD`.
---
## 5. Колоды

View File

@ -1,4 +1,5 @@
using System.Security.Claims;
using Vanguard.Rules.Cards;
using Vanguard.Server.Api;
using Vanguard.Server.Auth;
using Vanguard.Server.Configuration;
@ -25,6 +26,18 @@ public sealed record OnboardingResponse(Balances Balances, string DeckId);
public sealed record ShareResponse(string Code);
public sealed record MatchHistoryResponse(IReadOnlyList<MatchHistoryDto> Items);
public sealed record CardCatalogItemDto(
string PrintingId,
string NameId,
string NameEn,
string Rarity,
int Grade,
string Trigger,
string Clan,
bool IsSentinel,
bool IsParallel,
string? BasePrintingId);
public static class MetaEndpoints
{
public static void MapMetaEndpoints(this WebApplication app)
@ -60,6 +73,32 @@ public static class MetaEndpoints
app.MapGet("/api/matches", ListMatches).RequireAuthorization();
app.MapGet("/api/matches/{matchId}", GetMatch).RequireAuthorization();
app.MapGet("/api/replays/{matchId}", GetReplay).RequireAuthorization();
app.MapGet("/api/cards", GetCards).RequireAuthorization();
}
private static IResult GetCards(CardCatalog catalog)
{
var items = catalog.Printings
.Select(printing =>
{
var card = catalog.Card(printing.NameId);
return new CardCatalogItemDto(
printing.PrintingId,
card.NameId,
card.NameEn,
printing.Rarity.ToString(),
card.Grade,
card.Trigger.ToString(),
card.Clan.ToString(),
card.IsSentinel,
printing.IsParallel,
printing.BasePrintingId);
})
.OrderBy(item => item.NameEn, StringComparer.Ordinal)
.ThenBy(item => item.PrintingId, StringComparer.Ordinal)
.ToList();
return Results.Ok(items);
}
private static async Task<IResult> GetCollection(