diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 7cefc8d..ce534e9 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -233,12 +233,12 @@ - [x] `ApiClient` (HTTP/JSON), `PlayerState`, таймауты и разбор `ApiError`. - [ ] `GameConnection` (WebSocket + MessagePack), очередь событий, `ClientGameState`. -- [ ] Экраны: логин/регистрация — готово; главное меню — каркас; - профиль, магазин, коллекция, декбилдер, очередь, настройки — дальше. +- [x] Экраны: логин/регистрация, главное меню, онбординг, коллекция, + список колод и автосборка стартовой; магазин/полный декбилдер — дальше. - [ ] Декбилдер: фильтры (клан, грейд, редкость, тип, арт), счётчики, валидация в реальном времени. -- [ ] Коллекция: арты раздельно, «сколько в колодах», разбор в пыль - с подтверждением. +- [x] Базовая коллекция: список, балансы, разбор в пыль с подтверждением; + арты и «сколько в колодах» — дальше. - [ ] Магазин: бустер (анимация открытия), триал-деки, обмен токенов. - [ ] Локализация RU/EN, переключение на лету (пакет подключён). - [ ] Настройки: язык, звук, FPS, мьют. diff --git a/README.md b/README.md index 0a885d3..a1f1650 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ | CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` | | EF-миграции | готово (`InitialCreate`) | | Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` | -| Клиент | каркас: Unity 6.6, логин/регистрация, профиль, VContainer | +| Клиент | каркас: Unity 6.6, логин, онбординг, коллекция, колоды | | Лаунчер | не начато | ## Требования diff --git a/client/Assets/Vanguard/Runtime/Api/ApiClient.cs b/client/Assets/Vanguard/Runtime/Api/ApiClient.cs index 28fbbb2..b52bbae 100644 --- a/client/Assets/Vanguard/Runtime/Api/ApiClient.cs +++ b/client/Assets/Vanguard/Runtime/Api/ApiClient.cs @@ -58,6 +58,9 @@ namespace Vanguard.Api public Task PostAsync(string path, object body, bool authenticated = true) => SendAsync("POST", path, body, authenticated); + public Task DeleteAsync(string path, bool authenticated = true) => + SendAsync("DELETE", path, null, authenticated); + private async Task SendAsync( string method, string path, diff --git a/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs b/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs index 558e895..de3b55e 100644 --- a/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs +++ b/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs @@ -103,4 +103,97 @@ namespace Vanguard.Api.Contracts [JsonProperty("wsUrl")] public string WsUrl { get; set; } = string.Empty; [JsonProperty("expiresAt")] public DateTimeOffset ExpiresAt { get; set; } } + + public sealed class Balances + { + [JsonProperty("gold")] public int Gold { get; set; } + [JsonProperty("dust")] public int Dust { get; set; } + [JsonProperty("tokens")] public int Tokens { get; set; } + } + + public sealed class OnboardingResponse + { + [JsonProperty("balances")] public Balances Balances { get; set; } = new Balances(); + [JsonProperty("deckId")] public string DeckId { get; set; } = string.Empty; + } + + public sealed class OnboardingRequest + { + [JsonProperty("deckId")] public string DeckId { get; set; } = string.Empty; + } + + public sealed class StoreResponse + { + [JsonProperty("balances")] public Balances Balances { get; set; } = new Balances(); + [JsonProperty("boosterPriceGold")] public int BoosterPriceGold { get; set; } + [JsonProperty("trialDecks")] public List TrialDecks { get; set; } = new List(); + } + + public sealed class StoreTrialDeckDto + { + [JsonProperty("id")] public string Id { get; set; } = string.Empty; + [JsonProperty("name")] public string Name { get; set; } = string.Empty; + [JsonProperty("clan")] public string Clan { get; set; } + [JsonProperty("cardCount")] public int CardCount { get; set; } + [JsonProperty("owned")] public bool Owned { get; set; } + [JsonProperty("priceGold")] public int PriceGold { get; set; } + [JsonProperty("cards")] public List Cards { get; set; } = new List(); + } + + public sealed class TrialDeckCardDto + { + [JsonProperty("printingId")] public string PrintingId { get; set; } = string.Empty; + [JsonProperty("count")] public int Count { get; set; } + } + + public sealed class CollectionResponse + { + [JsonProperty("items")] public List Items { get; set; } = new List(); + [JsonProperty("balances")] public Balances Balances { get; set; } = new Balances(); + } + + public sealed class CollectionItemDto + { + [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("count")] public int Count { get; set; } + } + + public sealed class DismantleRequest + { + [JsonProperty("printingId")] public string PrintingId { get; set; } = string.Empty; + [JsonProperty("count")] public int Count { get; set; } + [JsonProperty("idempotencyKey")] public string IdempotencyKey { get; set; } = string.Empty; + } + + public sealed class DeckDto + { + [JsonProperty("id")] public Guid Id { get; set; } + [JsonProperty("name")] public string Name { get; set; } = string.Empty; + [JsonProperty("format")] public string Format { get; set; } = string.Empty; + [JsonProperty("cards")] public List Cards { get; set; } = new List(); + [JsonProperty("createdAt")] public DateTimeOffset CreatedAt { get; set; } + [JsonProperty("updatedAt")] public DateTimeOffset UpdatedAt { get; set; } + [JsonProperty("shareCode")] public string ShareCode { get; set; } + } + + public sealed class DeckRequest + { + [JsonProperty("name")] public string Name { get; set; } = string.Empty; + [JsonProperty("cards")] public List Cards { get; set; } = new List(); + } + + public sealed class DeckValidationResponse + { + [JsonProperty("valid")] public bool Valid { get; set; } + [JsonProperty("violations")] public List Violations { get; set; } = new List(); + } + + public sealed class DeckViolationDto + { + [JsonProperty("code")] public string Code { get; set; } = string.Empty; + [JsonProperty("message")] public string Message { get; set; } = string.Empty; + } } diff --git a/client/Assets/Vanguard/Runtime/App/VanguardApp.cs b/client/Assets/Vanguard/Runtime/App/VanguardApp.cs index c15572e..35968c9 100644 --- a/client/Assets/Vanguard/Runtime/App/VanguardApp.cs +++ b/client/Assets/Vanguard/Runtime/App/VanguardApp.cs @@ -4,6 +4,7 @@ using UnityEngine; using VContainer; using Vanguard.Api; using Vanguard.Auth; +using Vanguard.Meta; using Vanguard.State; using Vanguard.UI; @@ -15,30 +16,41 @@ namespace Vanguard.App /// public sealed class VanguardApp : MonoBehaviour { + public static string LastInitializationError { get; private set; } + private IObjectResolver _container; private AppRootView _root; private void Awake() { - DontDestroyOnLoad(gameObject); - Application.targetFrameRate = 60; + try + { + DontDestroyOnLoad(gameObject); + Application.targetFrameRate = 60; - var config = new AppConfig(); - config.Load(); + var config = new AppConfig(); + config.Load(); - var builder = new ContainerBuilder(); - builder.RegisterInstance(config); - builder.Register(Lifetime.Singleton); - builder.Register(Lifetime.Singleton); - builder.Register(Lifetime.Singleton); - builder.Register(Lifetime.Singleton); - builder.Register(Lifetime.Singleton); + var builder = new ContainerBuilder(); + builder.RegisterInstance(config); + builder.Register(Lifetime.Singleton); + builder.Register(Lifetime.Singleton); + builder.Register(Lifetime.Singleton); + builder.Register(Lifetime.Singleton); + builder.Register(Lifetime.Singleton); + builder.Register(Lifetime.Singleton); - _container = builder.Build(); - _root = _container.Resolve(); - _root.Initialize(); + _container = builder.Build(); + _root = _container.Resolve(); + _root.Initialize(); - _ = RestoreSessionAsync(); + _ = RestoreSessionAsync(); + } + catch (Exception exception) + { + LastInitializationError = exception.ToString(); + Debug.LogException(exception); + } } private async Task RestoreSessionAsync() @@ -49,7 +61,7 @@ namespace Vanguard.App { if (await auth.TryRestoreAsync()) { - _root.ShowMeta(); + _root.ShowAfterLogin(); return; } } diff --git a/client/Assets/Vanguard/Runtime/Meta.meta b/client/Assets/Vanguard/Runtime/Meta.meta new file mode 100644 index 0000000..9c7dae4 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Meta.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f0c456d495296174e8f2184b20fc35fe +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/Meta/MetaService.cs b/client/Assets/Vanguard/Runtime/Meta/MetaService.cs new file mode 100644 index 0000000..32e469c --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Meta/MetaService.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Vanguard.Api; +using Vanguard.Api.Contracts; +using Vanguard.State; + +namespace Vanguard.Meta +{ + /// Обёртка над мета-эндпоинтами: магазин, коллекция, колоды. + public sealed class MetaService + { + private readonly ApiClient _api; + private readonly PlayerState _player; + + public MetaService(ApiClient api, PlayerState player) + { + _api = api; + _player = player; + } + + public async Task GetStoreAsync() + { + var store = await _api.GetAsync("/api/store"); + _player.ApplyBalances(store.Balances); + return store; + } + + public async Task OnboardAsync(string deckId) + { + var response = await _api.PostAsync( + "/api/meta/onboarding", + new OnboardingRequest { DeckId = deckId }); + + _player.ApplyBalances(response.Balances); + _player.MarkOnboarded(response.DeckId); + return response; + } + + public async Task GetCollectionAsync() + { + var collection = await _api.GetAsync("/api/collection"); + _player.ApplyBalances(collection.Balances); + return collection; + } + + public async Task DismantleAsync(string printingId, int count) + { + var balances = await _api.PostAsync( + "/api/collection/dismantle", + new DismantleRequest + { + PrintingId = printingId, + Count = count, + IdempotencyKey = Guid.NewGuid().ToString("N"), + }); + + _player.ApplyBalances(balances); + return balances; + } + + public async Task> GetDecksAsync() + { + var decks = await _api.GetAsync>("/api/decks"); + return decks ?? new List(); + } + + public async Task CreateDeckAsync(string name, List cards) => + await _api.PostAsync( + "/api/decks", + new DeckRequest { Name = name, Cards = cards }); + + public Task DeleteDeckAsync(Guid deckId) => + _api.DeleteAsync("/api/decks/" + deckId.ToString("D")); + + public async Task ValidateDeckAsync(Guid deckId, List cards) => + await _api.PostAsync( + "/api/decks/" + deckId.ToString("D") + "/validate", + new DeckRequest { Cards = cards }); + } +} diff --git a/client/Assets/Vanguard/Runtime/Meta/MetaService.cs.meta b/client/Assets/Vanguard/Runtime/Meta/MetaService.cs.meta new file mode 100644 index 0000000..889d713 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Meta/MetaService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3391a8289838a8841b8e71f1ac76e6f6 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/State/PlayerState.cs b/client/Assets/Vanguard/Runtime/State/PlayerState.cs index b52a8c6..d6a2c33 100644 --- a/client/Assets/Vanguard/Runtime/State/PlayerState.cs +++ b/client/Assets/Vanguard/Runtime/State/PlayerState.cs @@ -63,6 +63,24 @@ namespace Vanguard.State NotifyChanged(); } + public void MarkOnboarded(string deckId) + { + OnboardingCompleted = true; + SelectedTrialDeckId = deckId; + NotifyChanged(); + } + + public void ApplyBalances(Balances balances) + { + if (balances == null) + return; + + Gold = balances.Gold; + Dust = balances.Dust; + Tokens = balances.Tokens; + NotifyChanged(); + } + private void NotifyChanged() { var handler = Changed; diff --git a/client/Assets/Vanguard/Runtime/UI/AppRootView.cs b/client/Assets/Vanguard/Runtime/UI/AppRootView.cs index 0a0440d..9da52ea 100644 --- a/client/Assets/Vanguard/Runtime/UI/AppRootView.cs +++ b/client/Assets/Vanguard/Runtime/UI/AppRootView.cs @@ -1,6 +1,7 @@ using UnityEngine; using Vanguard.App; using Vanguard.Auth; +using Vanguard.Meta; using Vanguard.State; namespace Vanguard.UI @@ -11,40 +12,109 @@ namespace Vanguard.UI private readonly AppConfig _config; private readonly AuthService _auth; private readonly PlayerState _player; + private readonly MetaService _metaService; private LoginView _login; - private MetaView _meta; + private MetaView _metaView; + private OnboardingView _onboarding; + private CollectionView _collection; + private DecksView _decks; - public AppRootView(AppConfig config, AuthService auth, PlayerState player) + public AppRootView( + AppConfig config, + AuthService auth, + PlayerState player, + MetaService metaService) { _config = config; _auth = auth; _player = player; + _metaService = metaService; } public void Initialize() { var canvas = UiKit.CreateCanvas("Vanguard Canvas"); + _login = new LoginView(canvas.transform, _config, _auth, OnAuthenticated); - _meta = new MetaView(canvas.transform, _auth, _player, OnLoggedOut); + _metaView = new MetaView( + canvas.transform, + _auth, + _player, + OnLoggedOut, + ShowCollection, + ShowDecks, + ShowDecks, + ShowOnboarding); + _onboarding = new OnboardingView( + canvas.transform, _auth, _metaService, OnOnboarded, OnLoggedOut); + _collection = new CollectionView( + canvas.transform, _metaService, _player, ShowMeta); + _decks = new DecksView( + canvas.transform, _metaService, _player, ShowMeta); + ShowLogin("Проверяем сессию…"); } public void ShowLogin(string status = null) { - if (_meta != null) - _meta.Hide(); - + _metaView.Hide(); + _onboarding.Hide(); + _collection.Hide(); + _decks.Hide(); _login.Show(status); } + public void ShowAfterLogin() + { + if (_player.OnboardingCompleted) + ShowMeta(); + else + ShowOnboarding(); + } + public void ShowMeta() { _login.Hide(); - _meta.Show(); + _onboarding.Hide(); + _collection.Hide(); + _decks.Hide(); + _metaView.Show(); + } + + public void ShowOnboarding() + { + _login.Hide(); + _metaView.Hide(); + _collection.Hide(); + _decks.Hide(); + _onboarding.Show(); + } + + public void ShowCollection() + { + _login.Hide(); + _metaView.Hide(); + _onboarding.Hide(); + _decks.Hide(); + _collection.Show(); + } + + public void ShowDecks() + { + _login.Hide(); + _metaView.Hide(); + _onboarding.Hide(); + _collection.Hide(); + _decks.Show(); } private void OnAuthenticated() + { + ShowAfterLogin(); + } + + private void OnOnboarded() { ShowMeta(); } diff --git a/client/Assets/Vanguard/Runtime/UI/CollectionView.cs b/client/Assets/Vanguard/Runtime/UI/CollectionView.cs new file mode 100644 index 0000000..26f157d --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/CollectionView.cs @@ -0,0 +1,299 @@ +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.Meta; +using Vanguard.State; + +namespace Vanguard.UI +{ + /// Коллекция игрока и разбор карт в пыль. + public sealed class CollectionView + { + private readonly MetaService _meta; + private readonly PlayerState _player; + private readonly Action _onBack; + + private readonly RectTransform _root; + private readonly Text _balances; + private readonly Text _status; + private readonly RectTransform _confirm; + private Text _confirmText; + private RectTransform _content; + private CollectionItemDto _pending; + private bool _loading; + + public CollectionView( + Transform parent, + MetaService meta, + PlayerState player, + Action onBack) + { + _meta = meta; + _player = player; + _onBack = onBack; + + _root = UiKit.CreatePanel(parent, "CollectionScreen", new Color(0.035f, 0.045f, 0.065f, 1f)); + + var top = UiKit.CreatePanel(_root, "TopBar", new Color(0.07f, 0.085f, 0.115f, 1f)); + top.anchorMin = new Vector2(0f, 1f); + top.anchorMax = new Vector2(1f, 1f); + top.pivot = new Vector2(0.5f, 1f); + top.anchoredPosition = Vector2.zero; + top.sizeDelta = new Vector2(0f, 88f); + UiKit.AddHorizontalLayout(top, 22, 16); + + var back = UiKit.CreateButton(top, "Back", "← Назад", () => _onBack(), 18); + UiKit.SetPreferredHeight(back, 44f, 0f); + + var title = UiKit.CreateText(top, "Title", "Коллекция", 32, Color.white, TextAnchor.MiddleLeft); + title.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(title, 46f); + + _balances = UiKit.CreateText( + top, "Balances", string.Empty, 18, + new Color(0.70f, 0.76f, 0.90f), TextAnchor.MiddleRight); + UiKit.SetPreferredHeight(_balances, 36f); + + var body = UiKit.CreatePanel(_root, "Body", new Color(0.035f, 0.045f, 0.065f, 0f)); + body.anchorMin = Vector2.zero; + body.anchorMax = Vector2.one; + body.offsetMin = new Vector2(40f, 28f); + body.offsetMax = new Vector2(-40f, -110f); + UiKit.AddVerticalLayout(body, 8, 10); + + var hint = UiKit.CreateText( + body, + "Hint", + "Разбор даёт пыль: C 5 · R 10 · RR 25 · RRR 100. SP не разбирается.", + 17, + new Color(0.58f, 0.65f, 0.78f), + TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(hint, 26f); + + var scroll = UiKit.CreateScrollView(body, "CollectionScroll", out _content, 8f); + var scrollElement = scroll.gameObject.AddComponent(); + scrollElement.flexibleHeight = 1f; + scrollElement.minHeight = 200f; + + _status = UiKit.CreateText( + body, "Status", string.Empty, 18, + new Color(0.62f, 0.68f, 0.82f), TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(_status, 34f); + + _confirm = BuildConfirm(parent); + _confirm.gameObject.SetActive(false); + + _root.gameObject.SetActive(false); + _player.Changed += OnPlayerChanged; + } + + public void Show() + { + UiKit.SetActive(_root, true); + _confirm.gameObject.SetActive(false); + OnPlayerChanged(); + _ = LoadAsync(); + } + + public void Hide() + { + UiKit.SetActive(_root, false); + _confirm.gameObject.SetActive(false); + } + + private RectTransform BuildConfirm(Transform parent) + { + var overlay = UiKit.CreatePanel(parent, "DismantleConfirm", new Color(0f, 0f, 0f, 0.72f)); + var card = UiKit.CreateCenteredCard( + overlay, "ConfirmCard", new Vector2(620f, 300f), + new Color(0.08f, 0.10f, 0.14f, 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), + TextAnchor.MiddleCenter); + UiKit.SetPreferredHeight(_confirmText, 110f); + + var yes = UiKit.CreateButton(card, "Yes", "Разобрать", OnConfirmDismantle, 20); + UiKit.SetPreferredHeight(yes, 48f); + + var no = UiKit.CreateButton(card, "No", "Отмена", HideConfirm, 20); + UiKit.SetPreferredHeight(no, 44f); + + overlay.gameObject.SetActive(false); + return overlay; + } + + private async Task LoadAsync() + { + if (_loading) + return; + + _loading = true; + SetStatus("Загружаем коллекцию…", new Color(0.62f, 0.68f, 0.82f)); + + try + { + var collection = await _meta.GetCollectionAsync(); + Rebuild(collection.Items ?? new List()); + SetStatus( + collection.Items == null || collection.Items.Count == 0 + ? "Коллекция пуста. Пройдите онбординг." + : "Карт в коллекции: " + collection.Items.Sum(item => item.Count) + ".", + new Color(0.62f, 0.68f, 0.82f)); + } + catch (ApiException exception) + { + SetStatus(exception.Message, new Color(1f, 0.55f, 0.45f)); + } + catch (Exception exception) + { + Debug.LogException(exception); + SetStatus("Не удалось загрузить коллекцию.", new Color(1f, 0.55f, 0.45f)); + } + finally + { + _loading = false; + } + } + + private void Rebuild(IReadOnlyList items) + { + for (var i = _content.childCount - 1; i >= 0; i--) + UnityEngine.Object.Destroy(_content.GetChild(i).gameObject); + + if (items.Count == 0) + return; + + foreach (var item in items) + AddRow(item); + } + + private void AddRow(CollectionItemDto item) + { + var row = UiKit.CreatePanel(_content, "Item_" + item.PrintingId, new Color(0.07f, 0.085f, 0.115f, 1f)); + var rowElement = row.gameObject.AddComponent(); + rowElement.preferredHeight = 64f; + UiKit.AddHorizontalLayout(row, 10, 10); + + var name = UiKit.CreateText( + row, "Name", item.NameEn, 19, Color.white, TextAnchor.MiddleLeft); + var nameElement = name.gameObject.AddComponent(); + nameElement.flexibleWidth = 1f; + nameElement.preferredHeight = 40f; + + var printing = UiKit.CreateText( + row, "Printing", item.PrintingId, 16, + new Color(0.58f, 0.65f, 0.78f), TextAnchor.MiddleLeft); + var printingElement = printing.gameObject.AddComponent(); + printingElement.preferredWidth = 190f; + printingElement.preferredHeight = 40f; + + var rarity = UiKit.CreateText( + row, "Rarity", item.Rarity, 17, + new Color(0.78f, 0.82f, 0.95f)); + var rarityElement = rarity.gameObject.AddComponent(); + rarityElement.preferredWidth = 64f; + rarityElement.preferredHeight = 40f; + + var count = UiKit.CreateText( + row, "Count", "×" + item.Count, 18, + new Color(0.78f, 0.82f, 0.95f)); + var countElement = count.gameObject.AddComponent(); + countElement.preferredWidth = 54f; + countElement.preferredHeight = 40f; + + var canDismantle = item.Rarity != "SP" && item.Count > 0; + var dismantle = UiKit.CreateButton( + row, + "Dismantle", + canDismantle ? "Разобрать 1" : "SP", + () => ShowConfirm(item), + 16); + dismantle.interactable = canDismantle; + var dismantleElement = dismantle.gameObject.AddComponent(); + dismantleElement.preferredWidth = 150f; + dismantleElement.preferredHeight = 44f; + dismantleElement.flexibleWidth = 0f; + } + + private void ShowConfirm(CollectionItemDto item) + { + _pending = item; + _confirmText.text = "«" + item.NameEn + "» (" + item.PrintingId + ")\n" + + "Вы получите " + DustFor(item.Rarity) + " пыли. Осталось: " + item.Count + "."; + _confirm.SetAsLastSibling(); + _confirm.gameObject.SetActive(true); + } + + private void HideConfirm() + { + _pending = null; + _confirm.gameObject.SetActive(false); + } + + private async void OnConfirmDismantle() + { + if (_pending == null) + return; + + var item = _pending; + _confirm.gameObject.SetActive(false); + SetStatus("Разбираем " + item.PrintingId + "…", new Color(0.62f, 0.68f, 0.82f)); + + try + { + await _meta.DismantleAsync(item.PrintingId, 1); + SetStatus("Разобрано: " + item.PrintingId + ".", new Color(0.45f, 0.85f, 0.55f)); + await LoadAsync(); + } + catch (ApiException exception) + { + SetStatus(exception.Message, new Color(1f, 0.55f, 0.45f)); + } + catch (Exception exception) + { + Debug.LogException(exception); + SetStatus("Не удалось разобрать карту.", new Color(1f, 0.55f, 0.45f)); + } + finally + { + _pending = null; + } + } + + private static int DustFor(string rarity) + { + switch (rarity) + { + case "C": return 5; + case "R": return 10; + case "RR": return 25; + case "RRR": return 100; + default: return 0; + } + } + + private void OnPlayerChanged() + { + _balances.text = "Gold " + _player.Gold + " · Пыль " + _player.Dust + + " · Токены " + _player.Tokens; + } + + private void SetStatus(string text, Color color) + { + _status.text = text ?? string.Empty; + _status.color = color; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/UI/CollectionView.cs.meta b/client/Assets/Vanguard/Runtime/UI/CollectionView.cs.meta new file mode 100644 index 0000000..db8d2be --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/CollectionView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 117359b61c1e3454d8dcaebc08bfa85f \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/UI/DecksView.cs b/client/Assets/Vanguard/Runtime/UI/DecksView.cs new file mode 100644 index 0000000..d860293 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/DecksView.cs @@ -0,0 +1,334 @@ +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.Meta; +using Vanguard.State; + +namespace Vanguard.UI +{ + /// Список колод и быстрый сборщик стартовой колоды из триал-деки. + public sealed class DecksView + { + private readonly MetaService _meta; + private readonly PlayerState _player; + private readonly Action _onBack; + + private readonly RectTransform _root; + private RectTransform _content; + private readonly Text _balances; + private readonly Text _status; + private readonly Button _createButton; + private readonly RectTransform _confirm; + private Text _confirmText; + private DeckDto _pendingDelete; + private bool _loading; + + public DecksView( + Transform parent, + MetaService meta, + PlayerState player, + Action onBack) + { + _meta = meta; + _player = player; + _onBack = onBack; + + _root = UiKit.CreatePanel(parent, "DecksScreen", new Color(0.035f, 0.045f, 0.065f, 1f)); + + var top = UiKit.CreatePanel(_root, "TopBar", new Color(0.07f, 0.085f, 0.115f, 1f)); + top.anchorMin = new Vector2(0f, 1f); + top.anchorMax = new Vector2(1f, 1f); + top.pivot = new Vector2(0.5f, 1f); + top.anchoredPosition = Vector2.zero; + top.sizeDelta = new Vector2(0f, 88f); + UiKit.AddHorizontalLayout(top, 22, 16); + + var back = UiKit.CreateButton(top, "Back", "← Назад", () => _onBack(), 18); + UiKit.SetPreferredHeight(back, 44f, 0f); + + var title = UiKit.CreateText(top, "Title", "Колоды", 32, Color.white, TextAnchor.MiddleLeft); + title.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(title, 46f); + + _balances = UiKit.CreateText( + top, "Balances", string.Empty, 18, + new Color(0.70f, 0.76f, 0.90f), TextAnchor.MiddleRight); + UiKit.SetPreferredHeight(_balances, 36f); + + var body = UiKit.CreatePanel(_root, "Body", new Color(0.035f, 0.045f, 0.065f, 0f)); + body.anchorMin = Vector2.zero; + body.anchorMax = Vector2.one; + body.offsetMin = new Vector2(40f, 28f); + body.offsetMax = new Vector2(-40f, -110f); + UiKit.AddVerticalLayout(body, 8, 10); + + var hint = UiKit.CreateText( + body, + "Hint", + "Стартовая колода собирается из выбранной триал-деки: 50 карт, состав уже проверен сервером.", + 17, + new Color(0.58f, 0.65f, 0.78f), + TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(hint, 26f); + + var scroll = UiKit.CreateScrollView(body, "DecksScroll", out _content, 8f); + var scrollElement = scroll.gameObject.AddComponent(); + scrollElement.flexibleHeight = 1f; + scrollElement.minHeight = 180f; + + var actions = UiKit.CreatePanel(body, "Actions", new Color(0.05f, 0.06f, 0.085f, 0f)); + UiKit.SetPreferredHeight(actions, 56f); + UiKit.AddHorizontalLayout(actions, 0, 12); + + _createButton = UiKit.CreateButton( + actions, + "CreateFromTrial", + "Собрать из триал-деки", + OnCreateFromTrialClicked, + 19); + UiKit.SetPreferredHeight(_createButton, 52f); + + _status = UiKit.CreateText( + body, "Status", string.Empty, 18, + new Color(0.62f, 0.68f, 0.82f), TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(_status, 34f); + + _confirm = BuildConfirm(parent); + _confirm.gameObject.SetActive(false); + + _root.gameObject.SetActive(false); + _player.Changed += OnPlayerChanged; + } + + public void Show() + { + UiKit.SetActive(_root, true); + _confirm.gameObject.SetActive(false); + OnPlayerChanged(); + _ = LoadAsync(); + } + + public void Hide() + { + UiKit.SetActive(_root, false); + _confirm.gameObject.SetActive(false); + } + + private RectTransform BuildConfirm(Transform parent) + { + var overlay = UiKit.CreatePanel(parent, "DeleteDeckConfirm", new Color(0f, 0f, 0f, 0.72f)); + var card = UiKit.CreateCenteredCard( + overlay, "ConfirmCard", new Vector2(620f, 280f), + new Color(0.08f, 0.10f, 0.14f, 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), + TextAnchor.MiddleCenter); + UiKit.SetPreferredHeight(_confirmText, 80f); + + var yes = UiKit.CreateButton(card, "Yes", "Удалить", OnConfirmDelete, 20); + UiKit.SetPreferredHeight(yes, 48f); + + var no = UiKit.CreateButton(card, "No", "Отмена", HideConfirm, 20); + UiKit.SetPreferredHeight(no, 44f); + + overlay.gameObject.SetActive(false); + return overlay; + } + + private async Task LoadAsync() + { + if (_loading) + return; + + _loading = true; + SetStatus("Загружаем колоды…", new Color(0.62f, 0.68f, 0.82f)); + + try + { + var decks = await _meta.GetDecksAsync(); + Rebuild(decks); + SetStatus( + decks.Count == 0 ? "Колод пока нет." : "Колод: " + decks.Count + ".", + new Color(0.62f, 0.68f, 0.82f)); + } + catch (ApiException exception) + { + SetStatus(exception.Message, new Color(1f, 0.55f, 0.45f)); + } + catch (Exception exception) + { + Debug.LogException(exception); + SetStatus("Не удалось загрузить колоды.", new Color(1f, 0.55f, 0.45f)); + } + finally + { + _loading = false; + } + } + + private void Rebuild(IReadOnlyList decks) + { + for (var i = _content.childCount - 1; i >= 0; i--) + UnityEngine.Object.Destroy(_content.GetChild(i).gameObject); + + foreach (var deck in decks) + AddRow(deck); + } + + private void AddRow(DeckDto deck) + { + var row = UiKit.CreatePanel(_content, "Deck_" + deck.Id, new Color(0.07f, 0.085f, 0.115f, 1f)); + var rowElement = row.gameObject.AddComponent(); + rowElement.preferredHeight = 72f; + UiKit.AddHorizontalLayout(row, 12, 12); + + var name = UiKit.CreateText( + row, "Name", deck.Name, 21, Color.white, TextAnchor.MiddleLeft); + name.fontStyle = FontStyle.Bold; + var nameElement = name.gameObject.AddComponent(); + nameElement.flexibleWidth = 1f; + nameElement.preferredHeight = 44f; + + var info = UiKit.CreateText( + row, + "Info", + deck.Cards.Count + " карт · " + deck.Format, + 16, + new Color(0.58f, 0.65f, 0.78f), + TextAnchor.MiddleLeft); + var infoElement = info.gameObject.AddComponent(); + infoElement.preferredWidth = 230f; + infoElement.preferredHeight = 40f; + + var play = UiKit.CreateButton(row, "Play", "В бой", () => OnPlayClicked(deck), 17); + var playElement = play.gameObject.AddComponent(); + playElement.preferredWidth = 120f; + playElement.preferredHeight = 46f; + playElement.flexibleWidth = 0f; + + var delete = UiKit.CreateButton(row, "Delete", "Удалить", () => ShowConfirm(deck), 16); + var deleteElement = delete.gameObject.AddComponent(); + deleteElement.preferredWidth = 110f; + deleteElement.preferredHeight = 46f; + deleteElement.flexibleWidth = 0f; + } + + private async void OnCreateFromTrialClicked() + { + if (_loading) + return; + + _loading = true; + _createButton.interactable = false; + SetStatus("Ищем выбранную триал-деку…", new Color(0.62f, 0.68f, 0.82f)); + + try + { + var store = await _meta.GetStoreAsync(); + var trial = store.TrialDecks.FirstOrDefault(deck => deck.Owned); + + if (trial == null) + { + SetStatus("Сначала завершите онбординг и выберите триал-деку.", new Color(1f, 0.60f, 0.45f)); + return; + } + + var cards = trial.Cards + .SelectMany(card => Enumerable.Repeat(card.PrintingId, card.Count)) + .ToList(); + + var created = await _meta.CreateDeckAsync(trial.Name + " (старт)", cards); + SetStatus("Колода «" + created.Name + "» собрана: " + created.Cards.Count + " карт.", new Color(0.45f, 0.85f, 0.55f)); + _loading = false; + await LoadAsync(); + } + catch (ApiException exception) + { + SetStatus(exception.Message, new Color(1f, 0.55f, 0.45f)); + } + catch (Exception exception) + { + Debug.LogException(exception); + SetStatus("Не удалось собрать колоду.", new Color(1f, 0.55f, 0.45f)); + } + finally + { + _loading = false; + _createButton.interactable = true; + } + } + + private void OnPlayClicked(DeckDto deck) + { + SetStatus("Очередь и бой подключим следующим шагом. Колода: " + deck.Name + ".", new Color(1f, 0.76f, 0.38f)); + } + + private void ShowConfirm(DeckDto deck) + { + _pendingDelete = deck; + _confirmText.text = "«" + deck.Name + "» будет удалена без возможности восстановления."; + _confirm.SetAsLastSibling(); + _confirm.gameObject.SetActive(true); + } + + private void HideConfirm() + { + _pendingDelete = null; + _confirm.gameObject.SetActive(false); + } + + private async void OnConfirmDelete() + { + if (_pendingDelete == null) + return; + + var deck = _pendingDelete; + _confirm.gameObject.SetActive(false); + SetStatus("Удаляем «" + deck.Name + "»…", new Color(0.62f, 0.68f, 0.82f)); + + try + { + await _meta.DeleteDeckAsync(deck.Id); + SetStatus("Колода удалена.", new Color(0.45f, 0.85f, 0.55f)); + await LoadAsync(); + } + catch (ApiException exception) + { + SetStatus(exception.Message, new Color(1f, 0.55f, 0.45f)); + } + catch (Exception exception) + { + Debug.LogException(exception); + SetStatus("Не удалось удалить колоду.", new Color(1f, 0.55f, 0.45f)); + } + finally + { + _pendingDelete = null; + } + } + + private void OnPlayerChanged() + { + _balances.text = "Gold " + _player.Gold + " · Пыль " + _player.Dust + + " · Токены " + _player.Tokens; + } + + private void SetStatus(string text, Color color) + { + _status.text = text ?? string.Empty; + _status.color = color; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/UI/DecksView.cs.meta b/client/Assets/Vanguard/Runtime/UI/DecksView.cs.meta new file mode 100644 index 0000000..898f98a --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/DecksView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f00da70463bd90b45bdbf542ddb33bb1 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/UI/MetaView.cs b/client/Assets/Vanguard/Runtime/UI/MetaView.cs index d6ed750..3b6ea72 100644 --- a/client/Assets/Vanguard/Runtime/UI/MetaView.cs +++ b/client/Assets/Vanguard/Runtime/UI/MetaView.cs @@ -14,6 +14,10 @@ namespace Vanguard.UI private readonly AuthService _auth; private readonly PlayerState _player; private readonly Action _onLoggedOut; + private readonly Action _onCollection; + private readonly Action _onDecks; + private readonly Action _onPlay; + private readonly Action _onOnboarding; private readonly RectTransform _root; private readonly Text _playerName; @@ -33,11 +37,19 @@ namespace Vanguard.UI Transform parent, AuthService auth, PlayerState player, - Action onLoggedOut) + Action onLoggedOut, + Action onCollection, + Action onDecks, + Action onPlay, + Action onOnboarding) { _auth = auth; _player = player; _onLoggedOut = onLoggedOut; + _onCollection = onCollection; + _onDecks = onDecks; + _onPlay = onPlay; + _onOnboarding = onOnboarding; _root = UiKit.CreatePanel(parent, "MetaScreen", new Color(0.035f, 0.045f, 0.065f, 1f)); @@ -93,10 +105,10 @@ namespace Vanguard.UI UiKit.SetPreferredHeight(actions, 96f); UiKit.AddHorizontalLayout(actions, 18, 14); - AddAction(actions, "Коллекция", OnCollectionClicked); - AddAction(actions, "Колоды", OnDecksClicked); + AddAction(actions, "Коллекция", _onCollection); + AddAction(actions, "Колоды", _onDecks); AddAction(actions, "Магазин", OnStoreClicked); - AddAction(actions, "В бой", OnPlayClicked); + AddAction(actions, "В бой", _onPlay); var lower = UiKit.CreatePanel(content, "Lower", new Color(0.07f, 0.085f, 0.115f, 1f)); UiKit.SetPreferredHeight(lower, 220f, 1f); @@ -113,7 +125,7 @@ namespace Vanguard.UI UiKit.SetPreferredHeight(_status, 70f); _onboardingButton = UiKit.CreateButton( - lower, "OnboardingButton", "Выбрать триал-деку", OnOnboardingClicked, 20); + lower, "OnboardingButton", "Выбрать триал-деку", () => _onOnboarding(), 20); UiKit.SetPreferredHeight(_onboardingButton, 46f); _player.Changed += UpdateTexts; @@ -213,27 +225,9 @@ namespace Vanguard.UI _status.color = color; } - private void OnCollectionClicked() => - SetStatus("Коллекция и разбор карт — следующий экран.", new Color(0.70f, 0.76f, 0.90f)); - - private void OnDecksClicked() => - SetStatus("Декбилдер и список колод — следующий экран.", new Color(0.70f, 0.76f, 0.90f)); - private void OnStoreClicked() => SetStatus("Магазин и открытие бустеров — следующий экран.", new Color(0.70f, 0.76f, 0.90f)); - private void OnPlayClicked() - { - SetStatus( - _player.OnboardingCompleted - ? "Очередь подключим после выбора колоды." - : "Сначала завершите онбординг.", - new Color(1f, 0.76f, 0.38f)); - } - - private void OnOnboardingClicked() => - SetStatus("Выбор триал-деки подключим следующим шагом.", new Color(1f, 0.76f, 0.38f)); - private async void OnLogoutClicked() { _logout.interactable = false; diff --git a/client/Assets/Vanguard/Runtime/UI/OnboardingView.cs b/client/Assets/Vanguard/Runtime/UI/OnboardingView.cs new file mode 100644 index 0000000..15667d7 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/OnboardingView.cs @@ -0,0 +1,257 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using UnityEngine; +using UnityEngine.UI; +using Vanguard.Api; +using Vanguard.Api.Contracts; +using Vanguard.Auth; +using Vanguard.Meta; + +namespace Vanguard.UI +{ + /// Первый вход: выбор одной из двух бесплатных триал-дек. + public sealed class OnboardingView + { + private readonly AuthService _auth; + private readonly MetaService _meta; + private readonly Action _onCompleted; + private readonly Action _onLoggedOut; + + private readonly RectTransform _root; + private readonly RectTransform _deckRow; + private readonly Text _status; + private readonly Button _retry; + private readonly Button _logout; + private bool _loading; + + public OnboardingView( + Transform parent, + AuthService auth, + MetaService meta, + Action onCompleted, + Action onLoggedOut) + { + _auth = auth; + _meta = meta; + _onCompleted = onCompleted; + _onLoggedOut = onLoggedOut; + + _root = UiKit.CreatePanel(parent, "OnboardingScreen", new Color(0.035f, 0.045f, 0.065f, 1f)); + var card = UiKit.CreateCenteredCard( + _root, + "OnboardingCard", + new Vector2(1320f, 900f), + new Color(0.07f, 0.085f, 0.115f, 1f)); + UiKit.AddVerticalLayout(card, 34, 12); + + var title = UiKit.CreateText( + card, "Title", "Стартовый набор", 46, Color.white); + title.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(title, 58f); + + var subtitle = UiKit.CreateText( + card, + "Subtitle", + "Выберите одну триал-деку. 50 карт попадут в коллекцию, плюс 5 токенов на бустеры.", + 20, + new Color(0.68f, 0.74f, 0.88f)); + UiKit.SetPreferredHeight(subtitle, 54f); + + _deckRow = UiKit.CreatePanel( + card, + "DeckRow", + new Color(0.05f, 0.06f, 0.085f, 1f)); + UiKit.SetPreferredHeight(_deckRow, 500f); + UiKit.AddHorizontalLayout(_deckRow, 16, 18); + + _status = UiKit.CreateText( + card, "Status", string.Empty, 18, + new Color(0.68f, 0.74f, 0.88f), + TextAnchor.MiddleCenter); + UiKit.SetPreferredHeight(_status, 38f); + + _retry = UiKit.CreateButton(card, "Retry", "Повторить загрузку", OnRetryClicked, 18); + UiKit.SetPreferredHeight(_retry, 42f); + _retry.gameObject.SetActive(false); + + _logout = UiKit.CreateButton(card, "Logout", "Выйти из аккаунта", OnLogoutClicked, 18); + UiKit.SetPreferredHeight(_logout, 42f); + + _root.gameObject.SetActive(false); + } + + public void Show() + { + UiKit.SetActive(_root, true); + _ = LoadAsync(); + } + + public void Hide() => UiKit.SetActive(_root, false); + + private async Task LoadAsync() + { + if (_loading) + return; + + _loading = true; + _retry.gameObject.SetActive(false); + _logout.interactable = false; + ClearDeckRow(); + SetStatus("Загружаем триал-деки…", new Color(0.68f, 0.74f, 0.88f)); + + try + { + var store = await _meta.GetStoreAsync(); + BuildDecks(store); + SetStatus("Выберите деку — выбор делается один раз.", new Color(0.68f, 0.74f, 0.88f)); + } + catch (ApiException exception) + { + SetStatus(exception.Message, new Color(1f, 0.55f, 0.45f)); + _retry.gameObject.SetActive(true); + } + catch (Exception exception) + { + Debug.LogException(exception); + SetStatus("Не удалось загрузить триал-деки. Сервер недоступен?", new Color(1f, 0.55f, 0.45f)); + _retry.gameObject.SetActive(true); + } + finally + { + _loading = false; + _logout.interactable = true; + } + } + + private void BuildDecks(StoreResponse store) + { + if (store.TrialDecks == null || store.TrialDecks.Count == 0) + { + SetStatus("Сервер не вернул ни одной триал-деки.", new Color(1f, 0.55f, 0.45f)); + return; + } + + foreach (var deck in store.TrialDecks) + BuildDeck(deck); + } + + private void BuildDeck(StoreTrialDeckDto deck) + { + var card = UiKit.CreatePanel( + _deckRow, + "Trial_" + deck.Id, + new Color(0.09f, 0.11f, 0.15f, 1f)); + var element = card.gameObject.AddComponent(); + element.flexibleWidth = 1f; + element.preferredHeight = 460f; + UiKit.AddVerticalLayout(card, 24, 10); + + var name = UiKit.CreateText(card, "Name", deck.Name, 30, Color.white, TextAnchor.MiddleLeft); + name.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(name, 42f); + + var clan = UiKit.CreateText( + card, "Clan", "Клан: " + (string.IsNullOrEmpty(deck.Clan) ? "—" : deck.Clan), + 20, new Color(0.68f, 0.74f, 0.88f), TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(clan, 30f); + + var count = UiKit.CreateText( + card, "Count", deck.CardCount + " карт · " + deck.Cards.Count + " уникальных", + 18, new Color(0.60f, 0.67f, 0.80f), TextAnchor.MiddleLeft); + UiKit.SetPreferredHeight(count, 28f); + + var preview = UiKit.CreateText( + card, + "Preview", + BuildPreview(deck), + 16, + new Color(0.52f, 0.60f, 0.74f), + TextAnchor.UpperLeft); + preview.horizontalOverflow = HorizontalWrapMode.Wrap; + UiKit.SetPreferredHeight(preview, 150f, 1f); + + if (deck.Owned) + { + var owned = UiKit.CreateText( + card, "Owned", "Уже в коллекции", 18, + new Color(0.45f, 0.85f, 0.55f)); + UiKit.SetPreferredHeight(owned, 28f); + } + + var choose = UiKit.CreateButton( + card, + "Choose", + deck.Owned ? "Дека уже получена" : "Выбрать бесплатно", + () => OnChooseClicked(deck), + 21); + choose.interactable = !deck.Owned; + UiKit.SetPreferredHeight(choose, 54f); + } + + private static string BuildPreview(StoreTrialDeckDto deck) + { + if (deck.Cards == null || deck.Cards.Count == 0) + return "Состав появится после выбора."; + + var lines = deck.Cards + .Take(8) + .Select(card => "• " + card.PrintingId + " ×" + card.Count); + + return string.Join("\n", lines); + } + + private async void OnChooseClicked(StoreTrialDeckDto deck) + { + if (_loading) + return; + + _loading = true; + _retry.gameObject.SetActive(false); + SetStatus("Активируем " + deck.Name + "…", new Color(0.68f, 0.74f, 0.88f)); + + try + { + await _meta.OnboardAsync(deck.Id); + _onCompleted(); + } + catch (ApiException exception) + { + SetStatus(exception.Message, new Color(1f, 0.55f, 0.45f)); + } + catch (Exception exception) + { + Debug.LogException(exception); + SetStatus("Не удалось завершить онбординг.", new Color(1f, 0.55f, 0.45f)); + } + finally + { + _loading = false; + } + } + + private void OnRetryClicked() + { + _ = LoadAsync(); + } + + private async void OnLogoutClicked() + { + _logout.interactable = false; + await _auth.LogoutAsync(); + _onLoggedOut(); + } + + private void ClearDeckRow() + { + for (var i = _deckRow.childCount - 1; i >= 0; i--) + UnityEngine.Object.Destroy(_deckRow.GetChild(i).gameObject); + } + + private void SetStatus(string text, Color color) + { + _status.text = text ?? string.Empty; + _status.color = color; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/UI/OnboardingView.cs.meta b/client/Assets/Vanguard/Runtime/UI/OnboardingView.cs.meta new file mode 100644 index 0000000..84d61aa --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/OnboardingView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eeb467af94c7f9a428bd9199668ca2c7 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/UI/UiKit.cs b/client/Assets/Vanguard/Runtime/UI/UiKit.cs index 578d1fd..51a2fa0 100644 --- a/client/Assets/Vanguard/Runtime/UI/UiKit.cs +++ b/client/Assets/Vanguard/Runtime/UI/UiKit.cs @@ -235,6 +235,72 @@ namespace Vanguard.UI return element; } + public static RectTransform CreateScrollView( + Transform parent, + string name, + 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 background = root.GetComponent(); + 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); + + var contentGo = new GameObject( + "Content", + typeof(RectTransform), + typeof(VerticalLayoutGroup), + typeof(ContentSizeFitter)); + content = (RectTransform)contentGo.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 = new Vector2(0f, 0f); + + var layout = contentGo.GetComponent(); + layout.padding = new RectOffset(4, 4, 4, 4); + layout.spacing = spacing; + layout.childAlignment = TextAnchor.UpperCenter; + layout.childControlWidth = true; + layout.childControlHeight = true; + layout.childForceExpandWidth = true; + layout.childForceExpandHeight = false; + + var fitter = contentGo.GetComponent(); + fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained; + fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize; + + var scroll = root.GetComponent(); + scroll.viewport = viewport; + scroll.content = content; + scroll.horizontal = false; + scroll.vertical = true; + scroll.movementType = ScrollRect.MovementType.Clamped; + scroll.scrollSensitivity = 30f; + scroll.inertia = true; + scroll.decelerationRate = 0.135f; + + return rootRect; + } + public static void Stretch(RectTransform rect) { rect.anchorMin = Vector2.zero; diff --git a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs index cd03e6a..449244b 100644 --- a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs @@ -5,6 +5,7 @@ using UnityEngine.EventSystems; using Vanguard.Api; using Vanguard.App; using Vanguard.Auth; +using Vanguard.Meta; using Vanguard.State; using Vanguard.UI; @@ -22,7 +23,8 @@ namespace Vanguard.Tests var api = new ApiClient(config, tokens); var player = new PlayerState(); var auth = new AuthService(api, tokens, player); - var root = new AppRootView(config, auth, player); + var meta = new MetaService(api, player); + var root = new AppRootView(config, auth, player, meta); root.Initialize(); root.ShowLogin("smoke"); @@ -30,6 +32,9 @@ namespace Vanguard.Tests Assert.IsNotNull(GameObject.Find("Vanguard Canvas")); Assert.IsNotNull(GameObject.Find("LoginScreen")); Assert.IsTrue(Resources.FindObjectsOfTypeAll().Any(go => go.name == "MetaScreen")); + Assert.IsTrue(Resources.FindObjectsOfTypeAll().Any(go => go.name == "OnboardingScreen")); + Assert.IsTrue(Resources.FindObjectsOfTypeAll().Any(go => go.name == "CollectionScreen")); + Assert.IsTrue(Resources.FindObjectsOfTypeAll().Any(go => go.name == "DecksScreen")); Cleanup(); } diff --git a/client/Assets/Vanguard/Tests/PlayMode/AppBootstrapPlayModeTests.cs b/client/Assets/Vanguard/Tests/PlayMode/AppBootstrapPlayModeTests.cs index 08a4d9b..c5c28ba 100644 --- a/client/Assets/Vanguard/Tests/PlayMode/AppBootstrapPlayModeTests.cs +++ b/client/Assets/Vanguard/Tests/PlayMode/AppBootstrapPlayModeTests.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Linq; using NUnit.Framework; using UnityEngine; using UnityEngine.EventSystems; @@ -10,7 +11,7 @@ namespace Vanguard.Tests public sealed class AppBootstrapPlayModeTests { [UnityTest] - public IEnumerator App_Bootstraps_And_Shows_Login() + public IEnumerator App_Bootstraps_And_Shows_A_Start_Screen() { if (Object.FindAnyObjectByType() == null) { @@ -23,7 +24,24 @@ namespace Vanguard.Tests Assert.IsNotNull(Object.FindAnyObjectByType()); Assert.IsNotNull(GameObject.Find("Vanguard Canvas")); - Assert.IsNotNull(GameObject.Find("LoginScreen")); + + var login = Resources.FindObjectsOfTypeAll() + .FirstOrDefault(go => go.name == "LoginScreen"); + var onboarding = Resources.FindObjectsOfTypeAll() + .FirstOrDefault(go => go.name == "OnboardingScreen"); + var hierarchy = string.Join( + "\n", + Resources.FindObjectsOfTypeAll() + .Where(go => go.name.EndsWith("Screen") || go.name == "Vanguard Canvas") + .Select(go => go.name + ": activeSelf=" + go.activeSelf + ", activeInHierarchy=" + go.activeInHierarchy)); + + var hasStartScreen = (login != null && login.activeInHierarchy) + || (onboarding != null && onboarding.activeInHierarchy); + + Assert.IsTrue( + hasStartScreen, + "Ни LoginScreen, ни OnboardingScreen не активны.\nInitError: " + + VanguardApp.LastInitializationError + "\n" + hierarchy); Cleanup(); yield return null; diff --git a/client/ProjectSettings/PackageManagerSettings.asset b/client/ProjectSettings/PackageManagerSettings.asset index 2114818..010f53d 100644 --- a/client/ProjectSettings/PackageManagerSettings.asset +++ b/client/ProjectSettings/PackageManagerSettings.asset @@ -31,14 +31,26 @@ MonoBehaviour: m_Compliance: m_Status: 0 m_Violations: [] - m_ScopedRegistries: [] - m_UserSelectedRegistryName: + m_ScopedRegistries: + - m_Id: scoped:project:package.openupm.com + m_Name: package.openupm.com + m_Url: https://package.openupm.com + m_Scopes: + - jp.hadashikick.vcontainer + m_IsDefault: 0 + m_IsUnityRegistry: 0 + m_Capabilities: 0 + m_ConfigSource: 4 + m_Compliance: + m_Status: 0 + m_Violations: [] + m_UserSelectedRegistryName: package.openupm.com m_UserAddingNewScopedRegistry: 0 m_RegistryInfoDraft: m_Modified: 0 m_ErrorMessage: m_UserModificationsEntityId: - m_rawData: 568105584918792958 + m_rawData: 1099511629233 m_OriginalEntityId: - m_rawData: 568105584918792960 + m_rawData: 1099511629234 m_LoadAssets: 0 diff --git a/client/README.md b/client/README.md index 3f18498..628264c 100644 --- a/client/README.md +++ b/client/README.md @@ -36,22 +36,24 @@ http://localhost:5080 - `ClientBootstrap` — запуск без привязки к сцене. - DI на VContainer: `AppConfig`, `TokenStore`, `ApiClient`, `AuthService`, - `PlayerState`, `AppRootView`. + `MetaService`, `PlayerState`, `AppRootView`. - HTTP-клиент на `UnityWebRequest` + Newtonsoft.Json, разбор `ApiError`. - Логин, регистрация, refresh, `/me`, выход. - Восстановление сессии по refresh-токену при старте. -- Экран входа и главный экран с балансами, MMR и заглушками разделов. +- Онбординг: две триал-деки из `/api/store`, выбор одной, 50 карт и 5 токенов. +- Коллекция: список карт, балансы, разбор в пыль с подтверждением. +- Колоды: список, удаление, автосборка стартовой колоды из выбранной + триал-деки. +- Экран входа и главный экран с балансами, MMR и переходами в разделы. ## Что дальше -1. Онбординг: выбор триал-деки (`POST /api/meta/onboarding`). -2. Коллекция, разбор/крафт. -3. Декбилдер и список колод. +1. Очередь и экран ожидания → тикет → WebSocket (`GameConnection`). +2. Боевая сцена: `MatchView`, проекция событий, анимации. +3. Полный декбилдер с фильтрами и счётчиками. 4. Магазин и открытие бустера. -5. Очередь и экран ожидания → тикет → WebSocket. -6. Боевая сцена: `MatchView`, `GameConnection`, проекция событий. -7. История матчей и реплей. -8. Локализация RU/EN, настройки, DOTween-анимации. +5. История матчей и реплей. +6. Локализация RU/EN, настройки, DOTween-анимации. ## Плагины diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 3a186b0..e6885e7 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -518,21 +518,27 @@ CI подключён и проходит; серверный блок закр сохранение токенов в `PlayerPrefs` (dev); - экран входа с выбором адреса сервера (стенд по умолчанию `http://192.168.31.158:18080`) и главный экран с балансами, MMR и - заглушками разделов; + переходами в разделы; +- онбординг: список триал-дек из `/api/store`, выбор одной, 50 карт + 5 + токенов, обновление профиля; +- коллекция: `/api/collection`, балансы, разбор в пыль с подтверждением; +- колоды: `/api/decks`, удаление и автосборка стартовой колоды из + выбранной триал-деки (50 карт); - `Vanguard.Shared.dll` (netstandard2.1) и MessagePack 3.1.12 подключены как плагины; `client/README.md` описывает запуск и ограничения; - EditMode и PlayMode smoke-тесты `Vanguard.Tests.Editor` / - `Vanguard.Tests.PlayMode` собирают оба экрана и проверяют запуск приложения. + `Vanguard.Tests.PlayMode` собирают все экраны и проверяют запуск + приложения; live-probe (не входит в набор) проверил онбординг, + коллекцию, создание и удаление колоды против стенда. Дальше по клиенту: -1. Онбординг: `POST /api/meta/onboarding`, выбор триал-деки. -2. Коллекция, разбор/крафт. -3. Декбилдер, список колод, валидация. +1. Очередь → тикет → `GameConnection` (WebSocket + MessagePack). +2. Боевая сцена: `MatchView`, проекция событий, анимации. +3. Полный декбилдер с фильтрами и счётчиками. 4. Магазин и открытие бустера. -5. Очередь → тикет → `GameConnection` (WebSocket + MessagePack). -6. Боевая сцена и реплей. -7. Локализация RU/EN на лету, настройки, DOTween-анимации. +5. История матчей и реплей. +6. Локализация RU/EN на лету, настройки, DOTween-анимации. Старый план экранов остаётся в силе: