Add card catalog API and deck builder
CI / test (push) Successful in 7m35s
Details
CI / test (push) Successful in 7m35s
Details
This commit is contained in:
parent
5199e11d2d
commit
6b4b2ab389
|
|
@ -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 карт, вторая
|
||||
|
|
|
|||
|
|
@ -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` — каталог карт с разбором состава сетов.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ namespace Vanguard.App
|
|||
Resolve<MetaService>(),
|
||||
Resolve<PlayerState>(),
|
||||
() => _ = Flow.LoadLobbyAsync());
|
||||
_view.Show();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c5f4c9e2a8d34b6e9f71b2d0a3c8614f
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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-анимации.
|
||||
|
||||
## Плагины
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
принта.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. Колоды
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue