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,
|
- [x] `GameConnection` (WebSocket + MessagePack), snapshot/events,
|
||||||
`ClientGameState` в виде `CurrentView` + `BattleView`, очередь и тикет.
|
`ClientGameState` в виде `CurrentView` + `BattleView`, очередь и тикет.
|
||||||
- [x] Экраны: логин/регистрация, главное меню, онбординг, коллекция,
|
- [x] Экраны: логин/регистрация, главное меню, онбординг, коллекция,
|
||||||
список колод и автосборка стартовой; магазин/полный декбилдер — дальше.
|
список колод; магазин и декбилдер подключены.
|
||||||
- [x] Очередь: classic-ranked, опрос, отмена, тикет, переход в матч.
|
- [x] Очередь: classic-ranked, опрос, отмена, тикет, переход в матч.
|
||||||
- [ ] Декбилдер: фильтры (клан, грейд, редкость, тип, арт), счётчики,
|
- [x] Простой декбилдер: фильтры (клан, грейд, триггер) в оверлее,
|
||||||
валидация в реальном времени.
|
сброс, статистика триггеров/грейдов, коллекция сеткой, текущая колода,
|
||||||
|
создание/редактирование/сохранение, лимиты 50/4/owned.
|
||||||
|
- [ ] Декбилдер: drag&drop, live-валидация, редкость/тип/арт, пресеты.
|
||||||
- [x] Базовая коллекция: список, балансы, разбор в пыль с подтверждением;
|
- [x] Базовая коллекция: список, балансы, разбор в пыль с подтверждением;
|
||||||
арты и «сколько в колодах» — дальше.
|
арты и «сколько в колодах» — дальше.
|
||||||
- [x] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая
|
- [x] Магазин в Lobby: бустер за Gold/токен с показом 5 карт, вторая
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,11 @@
|
||||||
| Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово |
|
| Протокол и контракты (`docs/protocol.md`, `Vanguard.Shared`) | готово |
|
||||||
| Сервер: матч по сети, матчмейкинг, реконнект | готово |
|
| Сервер: матч по сети, матчмейкинг, реконнект | готово |
|
||||||
| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово |
|
| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово |
|
||||||
| Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово |
|
| Мета-API: JWT, коллекция, каталог, колоды, магазин, история, реплеи | готово |
|
||||||
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
|
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
|
||||||
| EF-миграции | готово (`InitialCreate`) |
|
| EF-миграции | готово (`InitialCreate`) |
|
||||||
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
|
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
|
||||||
| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, колоды, магазин, история/реплей, очередь, gray-box бой |
|
| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, gray-box бой |
|
||||||
| Лаунчер | не начато |
|
| Лаунчер | не начато |
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
@ -150,6 +150,6 @@ dotnet run --project server/src/Vanguard.Cli -- translations check
|
||||||
- `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам.
|
- `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам.
|
||||||
- `docs/HANDOFF.md` — состояние на конец сессии и порядок следующих шагов.
|
- `docs/HANDOFF.md` — состояние на конец сессии и порядок следующих шагов.
|
||||||
- `docs/protocol.md` — протокол матча: кадры, интенты, события, таймеры, реконнект.
|
- `docs/protocol.md` — протокол матча: кадры, интенты, события, таймеры, реконнект.
|
||||||
- `docs/server-api.md` — HTTP API: JWT, коллекция, колоды, магазин, очередь,
|
- `docs/server-api.md` — HTTP API: JWT, коллекция, каталог, колоды, магазин, очередь,
|
||||||
история и реплеи.
|
история и реплеи.
|
||||||
- `content/cards-mvp.md` — каталог карт с разбором состава сетов.
|
- `content/cards-mvp.md` — каталог карт с разбором состава сетов.
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,14 @@ namespace Vanguard.Api
|
||||||
public Task PostAsync(string path, object body, bool authenticated = true) =>
|
public Task PostAsync(string path, object body, bool authenticated = true) =>
|
||||||
SendAsync("POST", path, body, authenticated);
|
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) =>
|
public Task DeleteAsync(string path, bool authenticated = true) =>
|
||||||
SendAsync("DELETE", path, null, authenticated);
|
SendAsync("DELETE", path, null, authenticated);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -271,4 +271,18 @@ namespace Vanguard.Api.Contracts
|
||||||
[JsonProperty("eventSeq")] public long EventSeq { get; set; }
|
[JsonProperty("eventSeq")] public long EventSeq { get; set; }
|
||||||
[JsonProperty("view")] public MatchView View { 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<MetaService>(),
|
||||||
Resolve<PlayerState>(),
|
Resolve<PlayerState>(),
|
||||||
() => _ = Flow.LoadLobbyAsync());
|
() => _ = Flow.LoadLobbyAsync());
|
||||||
|
_view.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,31 @@
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Vanguard.Api.Contracts;
|
using Vanguard.Api.Contracts;
|
||||||
|
using Vanguard.Auth;
|
||||||
using Vanguard.Meta;
|
using Vanguard.Meta;
|
||||||
using Vanguard.State;
|
|
||||||
using Vanguard.UI;
|
using Vanguard.UI;
|
||||||
|
|
||||||
namespace Vanguard.App
|
namespace Vanguard.App
|
||||||
{
|
{
|
||||||
public sealed class DeckBuilderSceneController : SceneControllerBase
|
public sealed class DeckBuilderSceneController : SceneControllerBase
|
||||||
{
|
{
|
||||||
private DecksView _view;
|
private DeckBuilderView _view;
|
||||||
|
|
||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
SceneEnvironment.Build(SceneTheme.DeckBuilder);
|
SceneEnvironment.Build(SceneTheme.DeckBuilder);
|
||||||
var canvas = CreateCanvas("DeckBuilder Canvas");
|
var canvas = CreateCanvas("DeckBuilder Canvas");
|
||||||
_view = new DecksView(
|
_view = new DeckBuilderView(
|
||||||
canvas.transform,
|
canvas.transform,
|
||||||
Resolve<MetaService>(),
|
Resolve<MetaService>(),
|
||||||
Resolve<PlayerState>(),
|
Resolve<AuthService>(),
|
||||||
() => _ = Flow.LoadLobbyAsync(),
|
() => _ = Flow.LoadLobbyAsync(),
|
||||||
deck => _ = Flow.OpenLobbyWithQueueAsync(deck));
|
deck => _ = Flow.OpenLobbyWithQueueAsync(deck));
|
||||||
|
_view.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
_view?.Dispose();
|
_view?.Hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,11 @@ namespace Vanguard.Meta
|
||||||
"/api/decks",
|
"/api/decks",
|
||||||
new DeckRequest { Name = name, Cards = cards });
|
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) =>
|
public Task DeleteDeckAsync(Guid deckId) =>
|
||||||
_api.DeleteAsync("/api/decks/" + deckId.ToString("D"));
|
_api.DeleteAsync("/api/decks/" + deckId.ToString("D"));
|
||||||
|
|
||||||
|
|
@ -132,5 +137,9 @@ namespace Vanguard.Meta
|
||||||
public async Task<ReplayResponseDto> GetReplayAsync(string matchId, bool includeFrames = true) =>
|
public async Task<ReplayResponseDto> GetReplayAsync(string matchId, bool includeFrames = true) =>
|
||||||
await _api.GetAsync<ReplayResponseDto>(
|
await _api.GetAsync<ReplayResponseDto>(
|
||||||
"/api/replays/" + matchId + "?includeFrames=" + (includeFrames ? "true" : "false"));
|
"/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,
|
out RectTransform content,
|
||||||
float spacing = 8f)
|
float spacing = 8f)
|
||||||
{
|
{
|
||||||
var root = new GameObject(
|
var root = CreateScrollRoot(parent, name);
|
||||||
name,
|
var viewport = CreateViewport(root);
|
||||||
typeof(RectTransform),
|
content = CreateVerticalContent(viewport, spacing);
|
||||||
typeof(Image),
|
ConfigureScroll(root, viewport, content, horizontal: false);
|
||||||
typeof(ScrollRect));
|
return root;
|
||||||
var rootRect = (RectTransform)root.transform;
|
}
|
||||||
rootRect.SetParent(parent, false);
|
|
||||||
Stretch(rootRect);
|
|
||||||
|
|
||||||
var background = root.GetComponent<Image>();
|
public static RectTransform CreateHorizontalScrollView(
|
||||||
background.color = new Color(0f, 0f, 0f, 0.01f);
|
Transform parent,
|
||||||
background.raycastTarget = true;
|
string name,
|
||||||
|
out RectTransform content,
|
||||||
var viewportGo = new GameObject(
|
float spacing = 8f)
|
||||||
"Viewport",
|
{
|
||||||
typeof(RectTransform),
|
var root = CreateScrollRoot(parent, name);
|
||||||
typeof(RectMask2D));
|
var viewport = CreateViewport(root);
|
||||||
var viewport = (RectTransform)viewportGo.transform;
|
|
||||||
viewport.SetParent(rootRect, false);
|
|
||||||
Stretch(viewport);
|
|
||||||
|
|
||||||
var contentGo = new GameObject(
|
var contentGo = new GameObject(
|
||||||
"Content",
|
"Content",
|
||||||
typeof(RectTransform),
|
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));
|
typeof(ContentSizeFitter));
|
||||||
content = (RectTransform)contentGo.transform;
|
content = (RectTransform)contentGo.transform;
|
||||||
content.SetParent(viewport, false);
|
content.SetParent(viewport, false);
|
||||||
|
|
@ -273,9 +308,68 @@ namespace Vanguard.UI
|
||||||
content.anchorMax = new Vector2(1f, 1f);
|
content.anchorMax = new Vector2(1f, 1f);
|
||||||
content.pivot = new Vector2(0.5f, 1f);
|
content.pivot = new Vector2(0.5f, 1f);
|
||||||
content.anchoredPosition = Vector2.zero;
|
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.padding = new RectOffset(4, 4, 4, 4);
|
||||||
layout.spacing = spacing;
|
layout.spacing = spacing;
|
||||||
layout.childAlignment = TextAnchor.UpperCenter;
|
layout.childAlignment = TextAnchor.UpperCenter;
|
||||||
|
|
@ -284,21 +378,27 @@ namespace Vanguard.UI
|
||||||
layout.childForceExpandWidth = true;
|
layout.childForceExpandWidth = true;
|
||||||
layout.childForceExpandHeight = false;
|
layout.childForceExpandHeight = false;
|
||||||
|
|
||||||
var fitter = contentGo.GetComponent<ContentSizeFitter>();
|
var fitter = go.GetComponent<ContentSizeFitter>();
|
||||||
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
|
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
|
||||||
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureScroll(
|
||||||
|
RectTransform root,
|
||||||
|
RectTransform viewport,
|
||||||
|
RectTransform content,
|
||||||
|
bool horizontal)
|
||||||
|
{
|
||||||
var scroll = root.GetComponent<ScrollRect>();
|
var scroll = root.GetComponent<ScrollRect>();
|
||||||
scroll.viewport = viewport;
|
scroll.viewport = viewport;
|
||||||
scroll.content = content;
|
scroll.content = content;
|
||||||
scroll.horizontal = false;
|
scroll.horizontal = horizontal;
|
||||||
scroll.vertical = true;
|
scroll.vertical = !horizontal;
|
||||||
scroll.movementType = ScrollRect.MovementType.Clamped;
|
scroll.movementType = ScrollRect.MovementType.Clamped;
|
||||||
scroll.scrollSensitivity = 30f;
|
scroll.scrollSensitivity = 30f;
|
||||||
scroll.inertia = true;
|
scroll.inertia = true;
|
||||||
scroll.decelerationRate = 0.135f;
|
scroll.decelerationRate = 0.135f;
|
||||||
|
|
||||||
return rootRect;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Stretch(RectTransform rect)
|
public static void Stretch(RectTransform rect)
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ namespace Vanguard.Tests
|
||||||
canvas.transform, auth, meta, () => { }, () => { });
|
canvas.transform, auth, meta, () => { }, () => { });
|
||||||
var collection = new CollectionView(
|
var collection = new CollectionView(
|
||||||
canvas.transform, meta, player, () => { });
|
canvas.transform, meta, player, () => { });
|
||||||
var decks = new DecksView(
|
var decks = new DeckBuilderView(
|
||||||
canvas.transform, meta, player, () => { }, _ => { });
|
canvas.transform, meta, auth, () => { }, _ => { });
|
||||||
var queue = new QueueView(
|
var queue = new QueueView(
|
||||||
canvas.transform, meta, auth, () => { }, _ => { });
|
canvas.transform, meta, auth, () => { }, _ => { });
|
||||||
var store = new StoreView(
|
var store = new StoreView(
|
||||||
|
|
@ -53,7 +53,7 @@ namespace Vanguard.Tests
|
||||||
Assert.IsTrue(Exists("MetaScreen"));
|
Assert.IsTrue(Exists("MetaScreen"));
|
||||||
Assert.IsTrue(Exists("OnboardingScreen"));
|
Assert.IsTrue(Exists("OnboardingScreen"));
|
||||||
Assert.IsTrue(Exists("CollectionScreen"));
|
Assert.IsTrue(Exists("CollectionScreen"));
|
||||||
Assert.IsTrue(Exists("DecksScreen"));
|
Assert.IsTrue(Exists("DeckBuilderScreen"));
|
||||||
Assert.IsTrue(Exists("StoreScreen"));
|
Assert.IsTrue(Exists("StoreScreen"));
|
||||||
Assert.IsTrue(Exists("HistoryScreen"));
|
Assert.IsTrue(Exists("HistoryScreen"));
|
||||||
Assert.IsTrue(Exists("ReplayScreen"));
|
Assert.IsTrue(Exists("ReplayScreen"));
|
||||||
|
|
@ -63,7 +63,6 @@ namespace Vanguard.Tests
|
||||||
// После смены сцены старые views не должны реагировать на PlayerState.
|
// После смены сцены старые views не должны реагировать на PlayerState.
|
||||||
lobby.Dispose();
|
lobby.Dispose();
|
||||||
collection.Dispose();
|
collection.Dispose();
|
||||||
decks.Dispose();
|
|
||||||
store.Dispose();
|
store.Dispose();
|
||||||
player.Apply(new MeResponse
|
player.Apply(new MeResponse
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,8 @@ http://localhost:5080
|
||||||
- Восстановление сессии по refresh-токену при старте.
|
- Восстановление сессии по refresh-токену при старте.
|
||||||
- Онбординг: две триал-деки из `/api/store`, выбор одной, 50 карт и 5 токенов.
|
- Онбординг: две триал-деки из `/api/store`, выбор одной, 50 карт и 5 токенов.
|
||||||
- Коллекция: список карт, балансы, разбор в пыль с подтверждением.
|
- Коллекция: список карт, балансы, разбор в пыль с подтверждением.
|
||||||
- Колоды: список, удаление, автосборка стартовой колоды из выбранной
|
- Колоды: список, удаление, создание/редактирование в декбилдере;
|
||||||
триал-деки.
|
фильтры (клан/грейд/триггер), статистика, текущая колода.
|
||||||
- Магазин в Lobby: бустер за Gold/токен, показ 5 карт, покупка второй
|
- Магазин в Lobby: бустер за Gold/токен, показ 5 карт, покупка второй
|
||||||
триал-деки.
|
триал-деки.
|
||||||
- История матчей: список результатов, награды, MMR; просмотр реплея по
|
- История матчей: список результатов, награды, MMR; просмотр реплея по
|
||||||
|
|
@ -65,14 +65,10 @@ http://localhost:5080
|
||||||
|
|
||||||
## Что дальше
|
## Что дальше
|
||||||
|
|
||||||
1. Разнести gray-box по 3D-сценам **Login / Lobby / Collection /
|
1. Довести боевой UI: арты карт, зоны, анимации, скип/ускорение.
|
||||||
DeckBuilder / Battle** и добавить `SceneFlow`.
|
2. Реконнект на всех шагах и аккуратная обработка таймаутов.
|
||||||
2. Довести боевой UI: арты карт, зоны, анимации, скип/ускорение.
|
3. Декбилдер: drag&drop, live-валидация, редкость/тип/арт, пресеты.
|
||||||
3. Реконнект на всех шагах и аккуратная обработка таймаутов.
|
4. Локализация RU/EN, настройки, DOTween-анимации.
|
||||||
4. Полный декбилдер с фильтрами и счётчиками.
|
|
||||||
5. Магазин и открытие бустера.
|
|
||||||
6. История матчей и реплей.
|
|
||||||
7. Локализация RU/EN, настройки, DOTween-анимации.
|
|
||||||
|
|
||||||
## Плагины
|
## Плагины
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -703,3 +703,22 @@ DeckBuilder / Battle; Boot и общие сервисы остаются пер
|
||||||
останется без способностей; проверять покрытие скриптом.
|
останется без способностей; проверять покрытие скриптом.
|
||||||
- **Тестовые колоды.** `BuildDeck()` в тестах не содержит все карты; для
|
- **Тестовые колоды.** `BuildDeck()` в тестах не содержит все карты; для
|
||||||
конкретной карты использовать `MakeCard()` и класть вручную.
|
конкретной карты использовать `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`,
|
- Сервисы: `ApiClient`, `AuthService`, `MetaService`, `TokenStore`,
|
||||||
`PlayerState`, `GameConnection`.
|
`PlayerState`, `GameConnection`.
|
||||||
- Работает: логин/регистрация/refresh, онбординг, коллекция с разбором,
|
- Работает: логин/регистрация/refresh, онбординг, коллекция с разбором,
|
||||||
список/удаление/автосборка колоды, магазин (бустер/триал-дека),
|
простой декбилдер (коллекция + текущая колода, фильтры в оверлее,
|
||||||
история матчей и реплей, очередь, WebSocket-матч (snapshot, интенты,
|
статистика триггеров/грейдов, создание/редактирование/сохранение),
|
||||||
маллиган, сдача, `GameFinished`), таймер, результат.
|
магазин (бустер/триал-дека), история матчей и реплей, очередь,
|
||||||
- Не готово: полноценный декбилдер, арты/модели/анимации, локализация
|
WebSocket-матч (snapshot, интенты, маллиган, сдача, `GameFinished`),
|
||||||
RU/EN, настройки, DOTween.
|
таймер, результат.
|
||||||
|
- Декбилдер: `DeckBuilderView`; каталог метаданных — `GET /api/cards`
|
||||||
|
(grade, trigger, clan, rarity, printing), клиент объединяет с коллекцией.
|
||||||
|
- Не готово: арты/модели/анимации, локализация RU/EN, настройки, DOTween.
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
|
|
@ -98,7 +101,7 @@ client/Assets/Vanguard/Runtime/App/
|
||||||
BattleSceneController.cs
|
BattleSceneController.cs
|
||||||
client/Assets/Vanguard/Runtime/UI/
|
client/Assets/Vanguard/Runtime/UI/
|
||||||
LoginView.cs MetaView.cs OnboardingView.cs
|
LoginView.cs MetaView.cs OnboardingView.cs
|
||||||
CollectionView.cs DecksView.cs QueueView.cs BattleView.cs
|
CollectionView.cs DeckBuilderView.cs QueueView.cs BattleView.cs
|
||||||
UiKit.cs
|
UiKit.cs
|
||||||
client/Assets/Vanguard/Runtime/Battle/GameConnection.cs
|
client/Assets/Vanguard/Runtime/Battle/GameConnection.cs
|
||||||
client/Assets/Vanguard/Runtime/Api/ApiClient.cs + ApiContracts.cs
|
client/Assets/Vanguard/Runtime/Api/ApiClient.cs + ApiContracts.cs
|
||||||
|
|
@ -123,7 +126,7 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack
|
||||||
## Следующие шаги
|
## Следующие шаги
|
||||||
|
|
||||||
1. Боевой UI: арты, зоны, анимации, скип; реконнект и таймауты.
|
1. Боевой UI: арты, зоны, анимации, скип; реконнект и таймауты.
|
||||||
2. Полный декбилдер: фильтры, счётчики, валидация.
|
2. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты.
|
||||||
3. 3D-наполнение: модели/арт, prefab-экраны, камера.
|
3. 3D-наполнение: модели/арт, prefab-экраны, камера.
|
||||||
4. Локализация RU/EN, настройки, DOTween.
|
4. Локализация RU/EN, настройки, DOTween.
|
||||||
5. Лаунчер WPF.
|
5. Лаунчер WPF.
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,35 @@ POST /api/collection/craft [Bearer]
|
||||||
Оба эндпоинта возвращают актуальные `Balances`; `idempotencyKey` защищает
|
Оба эндпоинта возвращают актуальные `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. Колоды
|
## 5. Колоды
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Vanguard.Rules.Cards;
|
||||||
using Vanguard.Server.Api;
|
using Vanguard.Server.Api;
|
||||||
using Vanguard.Server.Auth;
|
using Vanguard.Server.Auth;
|
||||||
using Vanguard.Server.Configuration;
|
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 ShareResponse(string Code);
|
||||||
public sealed record MatchHistoryResponse(IReadOnlyList<MatchHistoryDto> Items);
|
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 class MetaEndpoints
|
||||||
{
|
{
|
||||||
public static void MapMetaEndpoints(this WebApplication app)
|
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", ListMatches).RequireAuthorization();
|
||||||
app.MapGet("/api/matches/{matchId}", GetMatch).RequireAuthorization();
|
app.MapGet("/api/matches/{matchId}", GetMatch).RequireAuthorization();
|
||||||
app.MapGet("/api/replays/{matchId}", GetReplay).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(
|
private static async Task<IResult> GetCollection(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue