client: DeckBuilder — один экран, мультифильтры и список колоды
CI / test (push) Successful in 6m8s
Details
CI / test (push) Successful in 6m8s
Details
- Один экран: слева коллекция, справа панель «Мои колоды» ⇄ «Колода»; отдельные DeckList/DeckEditor удалены. - Мультифильтры клана, грейда и триггера в оверлее: чипы с подсветкой, черновик, «Сбросить»/«Отмена»/«Применить», сводка в топбаре. - Список колод: 50/50, T 16/16, Classic, G0–G3, бейдж валидности, подсветка открытой. - Список карт колоды: группировка по nameId, сортировка грейд → имя, превью-иллюстрация (верхние 59% карты) без искажений, имя, ×N, G. - Live-валидация 50/16/4; «Сохранить», «В бой», «С ботом» активны только для валидной сохранённой колоды. - CardImageService.ApplyIllustration; DeckListFilter/DeckListModel; 5 тестов DeckBuilderTests. - Проверки: EditMode 67/67, SCENE_SMOKE success=True, PLAYMAT_SMOKE success=True, standalone собран.
This commit is contained in:
parent
1999ccb3d8
commit
ecb7314bb6
|
|
@ -15,6 +15,11 @@ namespace Vanguard.UI
|
||||||
private const string ResourceFolder = "CardArt/";
|
private const string ResourceFolder = "CardArt/";
|
||||||
private static readonly Dictionary<string, Sprite> Cache =
|
private static readonly Dictionary<string, Sprite> Cache =
|
||||||
new Dictionary<string, Sprite>();
|
new Dictionary<string, Sprite>();
|
||||||
|
private static readonly Dictionary<string, Sprite> IllustrationCache =
|
||||||
|
new Dictionary<string, Sprite>();
|
||||||
|
|
||||||
|
/// <summary>Доля высоты карты, занятая иллюстрацией (до полосы флейвора).</summary>
|
||||||
|
public const float IllustrationHeight = 0.59f;
|
||||||
|
|
||||||
public static void Apply(Image target, string imageKey, Sprite fallback)
|
public static void Apply(Image target, string imageKey, Sprite fallback)
|
||||||
{
|
{
|
||||||
|
|
@ -27,6 +32,48 @@ namespace Vanguard.UI
|
||||||
target.preserveAspect = false;
|
target.preserveAspect = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Превью только иллюстрации: верхняя часть карты без рамки, имени и
|
||||||
|
/// текста способности. Пропорции сохраняются.
|
||||||
|
/// </summary>
|
||||||
|
public static void ApplyIllustration(Image target, string imageKey, Sprite fallback)
|
||||||
|
{
|
||||||
|
if (target == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var sprite = ResolveIllustration(imageKey, fallback);
|
||||||
|
target.sprite = sprite;
|
||||||
|
target.color = Color.white;
|
||||||
|
target.preserveAspect = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Спрайт иллюстрации (обрезанная верхняя часть карты).</summary>
|
||||||
|
public static Sprite ResolveIllustration(string imageKey, Sprite fallback)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(imageKey))
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
if (IllustrationCache.TryGetValue(imageKey, out var cached))
|
||||||
|
return cached != null ? cached : fallback;
|
||||||
|
|
||||||
|
var texture = Resources.Load<Texture2D>(ResourceFolder + imageKey);
|
||||||
|
if (texture == null)
|
||||||
|
{
|
||||||
|
IllustrationCache[imageKey] = null;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
var height = Mathf.Max(1, Mathf.RoundToInt(texture.height * IllustrationHeight));
|
||||||
|
var sprite = Sprite.Create(
|
||||||
|
texture,
|
||||||
|
new Rect(0f, texture.height - height, texture.width, height),
|
||||||
|
new Vector2(0.5f, 0.5f),
|
||||||
|
100f);
|
||||||
|
sprite.name = imageKey + "_illustration";
|
||||||
|
IllustrationCache[imageKey] = sprite;
|
||||||
|
return sprite;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Спрайт реального арта или процедурный fallback.</summary>
|
/// <summary>Спрайт реального арта или процедурный fallback.</summary>
|
||||||
public static Sprite Resolve(string imageKey, Sprite fallback)
|
public static Sprite Resolve(string imageKey, Sprite fallback)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,159 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Vanguard.Api.Contracts;
|
||||||
|
|
||||||
|
namespace Vanguard.UI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Состояние мультифильтров коллекции. Пустой набор означает «все
|
||||||
|
/// значения»; активные наборы объединяются по И.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeckFilter
|
||||||
|
{
|
||||||
|
public HashSet<string> Clans { get; } = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
public HashSet<int> Grades { get; } = new HashSet<int>();
|
||||||
|
public HashSet<string> Triggers { get; } = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public bool IsEmpty => Clans.Count == 0 && Grades.Count == 0 && Triggers.Count == 0;
|
||||||
|
|
||||||
|
public bool Matches(CardCatalogItemDto meta)
|
||||||
|
{
|
||||||
|
if (meta == null)
|
||||||
|
return IsEmpty;
|
||||||
|
|
||||||
|
if (Clans.Count > 0 && !Clans.Contains(meta.Clan))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (Grades.Count > 0 && !Grades.Contains(meta.Grade))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (Triggers.Count > 0 && !Triggers.Contains(meta.Trigger ?? "None"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
Clans.Clear();
|
||||||
|
Grades.Clear();
|
||||||
|
Triggers.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CopyFrom(DeckFilter other)
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
if (other == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Clans.UnionWith(other.Clans);
|
||||||
|
Grades.UnionWith(other.Grades);
|
||||||
|
Triggers.UnionWith(other.Triggers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeckFilter Clone()
|
||||||
|
{
|
||||||
|
var copy = new DeckFilter();
|
||||||
|
copy.CopyFrom(this);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Одна строка списка колоды: карта по названию и её количество.</summary>
|
||||||
|
public sealed class DeckCardGroup
|
||||||
|
{
|
||||||
|
public string NameId { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public int Grade { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
public CardCatalogItemDto Meta { get; set; }
|
||||||
|
public List<string> PrintingIds { get; } = new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Локальная проверка колоды до сохранения.</summary>
|
||||||
|
public struct DeckValidation
|
||||||
|
{
|
||||||
|
public int Count;
|
||||||
|
public int Triggers;
|
||||||
|
public bool MaxCopiesOk;
|
||||||
|
|
||||||
|
public bool IsValid => Count == 50 && Triggers == 16 && MaxCopiesOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Чистые правила фильтрации, группировки и проверки колоды.</summary>
|
||||||
|
public static class DeckListModel
|
||||||
|
{
|
||||||
|
public static List<DeckCardGroup> GroupDeck(
|
||||||
|
IEnumerable<string> cards,
|
||||||
|
IReadOnlyDictionary<string, CardCatalogItemDto> catalog)
|
||||||
|
{
|
||||||
|
var groups = new Dictionary<string, DeckCardGroup>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var printingId in cards)
|
||||||
|
{
|
||||||
|
var meta = catalog != null && catalog.TryGetValue(printingId, out var found)
|
||||||
|
? found
|
||||||
|
: null;
|
||||||
|
var key = meta != null && !string.IsNullOrEmpty(meta.NameId)
|
||||||
|
? meta.NameId
|
||||||
|
: printingId;
|
||||||
|
|
||||||
|
if (!groups.TryGetValue(key, out var group))
|
||||||
|
{
|
||||||
|
group = new DeckCardGroup
|
||||||
|
{
|
||||||
|
NameId = key,
|
||||||
|
Name = meta != null ? Loc.CardName(meta) : key,
|
||||||
|
Grade = meta != null ? meta.Grade : int.MaxValue,
|
||||||
|
Meta = meta,
|
||||||
|
};
|
||||||
|
groups[key] = group;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.Count++;
|
||||||
|
group.PrintingIds.Add(printingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.Values
|
||||||
|
.OrderBy(group => group.Grade)
|
||||||
|
.ThenBy(group => group.Name, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DeckValidation Validate(
|
||||||
|
IEnumerable<string> cards,
|
||||||
|
IReadOnlyDictionary<string, CardCatalogItemDto> catalog)
|
||||||
|
{
|
||||||
|
var result = new DeckValidation { MaxCopiesOk = true };
|
||||||
|
var byName = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var printingId in cards)
|
||||||
|
{
|
||||||
|
result.Count++;
|
||||||
|
var meta = catalog != null && catalog.TryGetValue(printingId, out var found)
|
||||||
|
? found
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var nameId = meta != null && !string.IsNullOrEmpty(meta.NameId)
|
||||||
|
? meta.NameId
|
||||||
|
: printingId;
|
||||||
|
|
||||||
|
byName.TryGetValue(nameId, out var used);
|
||||||
|
used++;
|
||||||
|
byName[nameId] = used;
|
||||||
|
if (used > 4)
|
||||||
|
result.MaxCopiesOk = false;
|
||||||
|
|
||||||
|
if (meta != null && IsTrigger(meta.Trigger))
|
||||||
|
result.Triggers++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsTrigger(string trigger) =>
|
||||||
|
!string.IsNullOrEmpty(trigger)
|
||||||
|
&& !string.Equals(trigger, "None", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0bdfff22a21c12a4ea9036711e5164c7
|
||||||
|
|
@ -415,6 +415,24 @@ namespace Vanguard.UI
|
||||||
"Bug and improvement reports will arrive later: the server needs a ticket section.",
|
"Bug and improvement reports will arrive later: the server needs a ticket section.",
|
||||||
["Топ игроков по рейтингу появится позже: нужен серверный лидерборд."] =
|
["Топ игроков по рейтингу появится позже: нужен серверный лидерборд."] =
|
||||||
"The rating leaderboard will appear later: the server needs a leaderboard endpoint.",
|
"The rating leaderboard will appear later: the server needs a leaderboard endpoint.",
|
||||||
|
["Создать"] = "Create",
|
||||||
|
["← К списку колод"] = "← Back to decks",
|
||||||
|
["Готова"] = "Ready",
|
||||||
|
["Не готова"] = "Not ready",
|
||||||
|
["Без триггера"] = "No trigger",
|
||||||
|
["Критический"] = "Critical",
|
||||||
|
["Добор"] = "Draw",
|
||||||
|
["Стойка"] = "Stand",
|
||||||
|
["Лечение"] = "Heal",
|
||||||
|
["Сбросить"] = "Reset",
|
||||||
|
["Применить"] = "Apply",
|
||||||
|
["Удалить колоду"] = "Delete deck",
|
||||||
|
["не сохранено"] = "not saved",
|
||||||
|
["Сохранено"] = "Saved",
|
||||||
|
["Кланы появятся после загрузки коллекции."] =
|
||||||
|
"Clans will appear after the collection loads.",
|
||||||
|
["Нужно ровно 50 карт, 16 триггеров и не больше 4 копий."] =
|
||||||
|
"Exactly 50 cards, 16 triggers and no more than 4 copies are required.",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly KeyValuePair<string, string>[] Phrases = Exact
|
private static readonly KeyValuePair<string, string>[] Phrases = Exact
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,9 @@ namespace Vanguard.Tests
|
||||||
Assert.IsTrue(Exists("LobbyScreen"));
|
Assert.IsTrue(Exists("LobbyScreen"));
|
||||||
Assert.IsTrue(Exists("OnboardingScreen"));
|
Assert.IsTrue(Exists("OnboardingScreen"));
|
||||||
Assert.IsTrue(Exists("DeckBuilderScreen"));
|
Assert.IsTrue(Exists("DeckBuilderScreen"));
|
||||||
|
Assert.IsTrue(Exists("DeckListPanel"), "Нет панели списка колод");
|
||||||
|
Assert.IsTrue(Exists("DeckPanel"), "Нет панели колоды");
|
||||||
|
Assert.IsTrue(Exists("FilterOverlay"), "Нет оверлея фильтров");
|
||||||
Assert.IsTrue(Exists("StoreScreen"));
|
Assert.IsTrue(Exists("StoreScreen"));
|
||||||
Assert.IsTrue(Exists("HistoryScreen"));
|
Assert.IsTrue(Exists("HistoryScreen"));
|
||||||
Assert.IsTrue(Exists("ReplayScreen"));
|
Assert.IsTrue(Exists("ReplayScreen"));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using Vanguard.Api.Contracts;
|
||||||
|
using Vanguard.UI;
|
||||||
|
|
||||||
|
namespace Vanguard.Tests
|
||||||
|
{
|
||||||
|
public sealed class DeckBuilderTests
|
||||||
|
{
|
||||||
|
private static CardCatalogItemDto Card(
|
||||||
|
string printingId,
|
||||||
|
string nameId,
|
||||||
|
int grade,
|
||||||
|
string clan,
|
||||||
|
string trigger = "None")
|
||||||
|
{
|
||||||
|
return new CardCatalogItemDto
|
||||||
|
{
|
||||||
|
PrintingId = printingId,
|
||||||
|
NameId = nameId,
|
||||||
|
NameEn = nameId,
|
||||||
|
NameRu = nameId,
|
||||||
|
Grade = grade,
|
||||||
|
Clan = clan,
|
||||||
|
Trigger = trigger,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, CardCatalogItemDto> Catalog(
|
||||||
|
params CardCatalogItemDto[] cards)
|
||||||
|
{
|
||||||
|
return cards.ToDictionary(card => card.PrintingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Empty_Filter_Matches_Every_Card()
|
||||||
|
{
|
||||||
|
var filter = new DeckFilter();
|
||||||
|
Assert.IsTrue(filter.IsEmpty);
|
||||||
|
Assert.IsTrue(filter.Matches(Card("p1", "A", 0, "RoyalPaladin")));
|
||||||
|
Assert.IsTrue(filter.Matches(Card("p2", "B", 3, "Kagero", "Heal")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Multi_Filter_Combines_Clan_Grade_And_Trigger()
|
||||||
|
{
|
||||||
|
var filter = new DeckFilter();
|
||||||
|
filter.Clans.Add("RoyalPaladin");
|
||||||
|
filter.Clans.Add("Kagero");
|
||||||
|
filter.Grades.Add(1);
|
||||||
|
filter.Grades.Add(2);
|
||||||
|
filter.Triggers.Add("Heal");
|
||||||
|
|
||||||
|
Assert.IsTrue(filter.Matches(Card("p1", "A", 1, "Kagero", "Heal")));
|
||||||
|
Assert.IsFalse(filter.Matches(Card("p2", "B", 1, "Kagero", "Draw")));
|
||||||
|
Assert.IsFalse(filter.Matches(Card("p3", "C", 0, "Kagero", "Heal")));
|
||||||
|
Assert.IsFalse(filter.Matches(Card("p4", "D", 1, "NovaGrappler", "Heal")));
|
||||||
|
Assert.IsFalse(filter.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GroupDeck_Sums_Printings_And_Sorts_By_Grade_Then_Name()
|
||||||
|
{
|
||||||
|
Loc.SetLanguage(UiLanguage.Ru);
|
||||||
|
var catalog = Catalog(
|
||||||
|
Card("p1", "Zeta", 2, "Kagero"),
|
||||||
|
Card("p2", "Alpha", 2, "Kagero"),
|
||||||
|
Card("p3", "Alpha", 2, "Kagero"),
|
||||||
|
Card("p4", "Beta", 0, "Kagero"));
|
||||||
|
|
||||||
|
var groups = DeckListModel.GroupDeck(
|
||||||
|
new[] { "p1", "p2", "p3", "p4" },
|
||||||
|
catalog);
|
||||||
|
|
||||||
|
Assert.AreEqual(3, groups.Count);
|
||||||
|
Assert.AreEqual("Beta", groups[0].NameId);
|
||||||
|
Assert.AreEqual("Alpha", groups[1].NameId);
|
||||||
|
Assert.AreEqual(2, groups[1].Count);
|
||||||
|
Assert.AreEqual("Zeta", groups[2].NameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GroupDeck_Keeps_Unknown_Printing_Last()
|
||||||
|
{
|
||||||
|
var catalog = Catalog(Card("p1", "Alpha", 0, "Kagero"));
|
||||||
|
var groups = DeckListModel.GroupDeck(new[] { "missing", "p1" }, catalog);
|
||||||
|
|
||||||
|
Assert.AreEqual(2, groups.Count);
|
||||||
|
Assert.AreEqual("Alpha", groups[0].NameId);
|
||||||
|
Assert.AreEqual("missing", groups[1].NameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Validate_Requires_Count_Triggers_And_Copy_Limit()
|
||||||
|
{
|
||||||
|
var catalog = new Dictionary<string, CardCatalogItemDto>();
|
||||||
|
var cards = new List<string>();
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
{
|
||||||
|
var printingId = "p" + i;
|
||||||
|
catalog[printingId] = Card(
|
||||||
|
printingId,
|
||||||
|
"card" + i,
|
||||||
|
i % 4,
|
||||||
|
"RoyalPaladin",
|
||||||
|
i < 16 ? "Critical" : "None");
|
||||||
|
cards.Add(printingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var valid = DeckListModel.Validate(cards, catalog);
|
||||||
|
Assert.AreEqual(50, valid.Count);
|
||||||
|
Assert.AreEqual(16, valid.Triggers);
|
||||||
|
Assert.IsTrue(valid.IsValid);
|
||||||
|
|
||||||
|
var shortDeck = DeckListModel.Validate(cards.Take(49), catalog);
|
||||||
|
Assert.IsFalse(shortDeck.IsValid);
|
||||||
|
|
||||||
|
var copyDeck = new List<string>
|
||||||
|
{
|
||||||
|
"p0", "p0", "p0", "p0", "p0",
|
||||||
|
};
|
||||||
|
var copyValidation = DeckListModel.Validate(copyDeck, catalog);
|
||||||
|
Assert.IsFalse(copyValidation.MaxCopiesOk);
|
||||||
|
Assert.IsFalse(copyValidation.IsValid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 97caa8ca43fbef44fb292c3a777eb5f1
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
| TASK-2026-020 | `[x]` | Парсер карт и артов с официального сайта | DeepSeek: анализ и реализация | идемпотентность 0 diff; `validate` не запускался |
|
| TASK-2026-020 | `[x]` | Парсер карт и артов с официального сайта | DeepSeek: анализ и реализация | идемпотентность 0 diff; `validate` не запускался |
|
||||||
| TASK-2026-021 | `[!]` | Login по референсу: 2D-арт и неоновый UI | DeepSeek: C1–C9, Tab/Enter/Esc; ждёт приёмки | EditMode 60/60; `SCENE_SMOKE success=True`; standalone собран; ручная — пользователь |
|
| TASK-2026-021 | `[!]` | Login по референсу: 2D-арт и неоновый UI | DeepSeek: C1–C9, Tab/Enter/Esc; ждёт приёмки | EditMode 60/60; `SCENE_SMOKE success=True`; standalone собран; ручная — пользователь |
|
||||||
| TASK-2026-022 | `[!]` | Lobby по референсу: дашборд, заглушки, сцена Community | DeepSeek: C1–C11 реализованы; ждёт ручной приёмки | EditMode 62/62; `SCENE_SMOKE`, `PLAYMAT_SMOKE`, standalone; ручная — пользователь |
|
| TASK-2026-022 | `[!]` | Lobby по референсу: дашборд, заглушки, сцена Community | DeepSeek: C1–C11 реализованы; ждёт ручной приёмки | EditMode 62/62; `SCENE_SMOKE`, `PLAYMAT_SMOKE`, standalone; ручная — пользователь |
|
||||||
| TASK-2026-023 | `[ ]` | DeckBuilder: один экран, мультифильтры и список колоды | DeepSeek: анализ и план готовы; реализация ожидает | не выполнена |
|
| TASK-2026-023 | `[!]` | DeckBuilder: один экран, мультифильтры и список колоды | DeepSeek: C1–C11 реализованы; ждёт ручной приёмки | EditMode 67/67; `SCENE_SMOKE`, `PLAYMAT_SMOKE`; ручная — пользователь |
|
||||||
|
|
||||||
Новые задачи добавляются в конец файла отдельными разделами. Не используй один
|
Новые задачи добавляются в конец файла отдельными разделами. Не используй один
|
||||||
раздел для нескольких независимых задач.
|
раздел для нескольких независимых задач.
|
||||||
|
|
@ -2348,11 +2348,11 @@ HUD с номером хода и фазовой шкалой, компактн
|
||||||
|
|
||||||
### TASK-2026-023 — DeckBuilder: один экран, мультифильтры и список колоды
|
### TASK-2026-023 — DeckBuilder: один экран, мультифильтры и список колоды
|
||||||
|
|
||||||
- **Статус:** `[ ]`
|
- **Статус:** `[!]`
|
||||||
- **Создано:** 2026-10-31
|
- **Создано:** 2026-10-31
|
||||||
- **Анализ:** DeepSeek выполнен
|
- **Анализ:** DeepSeek выполнен
|
||||||
- **Реализация:** не начата
|
- **Реализация:** DeepSeek: C1–C11 выполнены
|
||||||
- **Проверка:** не выполнена
|
- **Проверка:** EditMode 67/67; `SCENE_SMOKE`, `PLAYMAT_SMOKE`; ручная приёмка — за пользователем
|
||||||
- **Затронутые системы:** `Runtime/UI/DeckBuilderView.cs`,
|
- **Затронутые системы:** `Runtime/UI/DeckBuilderView.cs`,
|
||||||
`Runtime/UI/CardImageService.cs`, `Localization.cs`, EditMode-тесты.
|
`Runtime/UI/CardImageService.cs`, `Localization.cs`, EditMode-тесты.
|
||||||
Сервер, правила, протокол и YAML не меняются.
|
Сервер, правила, протокол и YAML не меняются.
|
||||||
|
|
@ -2417,106 +2417,90 @@ HUD с номером хода и фазовой шкалой, компактн
|
||||||
|
|
||||||
#### C. Чек-лист изменений
|
#### C. Чек-лист изменений
|
||||||
|
|
||||||
- [ ] **C1. `MODIFY` `Runtime/UI/CardImageService.cs`** — превью
|
- [x] **C1. `MODIFY` `Runtime/UI/CardImageService.cs`** — превью
|
||||||
иллюстрации.
|
иллюстрации.
|
||||||
- `ApplyIllustration(Image, imageKey, fallback)`: спрайт из верхней части
|
- `ApplyIllustration(Image, imageKey, fallback)`: спрайт из верхней части
|
||||||
карты — `Rect(0, h * 0.41, w, h * 0.59)` для 350×511 (иллюстрация до
|
карты — `Rect(0, h * 0.41, w, h * 0.59)` (350×511), с отдельным кешем.
|
||||||
полосы флейвора), с кешем по `imageKey`.
|
- `preserveAspect = true`; при отсутствии арта — процедурный fallback.
|
||||||
- `preserveAspect = true`; выравнивание — центральное; при отсутствии
|
- Полный арт (`Apply`) не изменён.
|
||||||
арта — процедурный fallback без обрезки.
|
- Реализация: выполнена. Проверка: EditMode 67/67, ручная — позже.
|
||||||
- Полный арт (`Apply`) не меняется: он нужен карточке и 3D-картам.
|
|
||||||
- Реализация: не выполнена. Проверка: не выполнена.
|
|
||||||
- Зависит: нет.
|
- Зависит: нет.
|
||||||
|
|
||||||
- [ ] **C2. `MODIFY` `Runtime/UI/DeckBuilderView.cs`** — один экран.
|
- [x] **C2. `MODIFY` `Runtime/UI/DeckBuilderView.cs`** — один экран.
|
||||||
- Убрать `_listView`/`_editorView` и `BackToList`; оставить один корень
|
- Убраны `_listView`/`_editorView`; один корень `DeckBuilderScreen`:
|
||||||
`DeckBuilderScreen`: топбар (назад в Lobby, заголовок, фильтры,
|
топбар (назад в Lobby, заголовок, сводка фильтров, фильтры, сброс) и
|
||||||
сброс, статус) и тело (коллекция + правая панель).
|
тело (коллекция + правая панель 380 px).
|
||||||
- Правая панель: `DeckListPanel` и `DeckPanel` (карты колоды), видимость
|
- Правая панель: `DeckListPanel` и `DeckPanel`; переключение без смены
|
||||||
переключается; при открытии колоды — `DeckPanel`, кнопка «К списку
|
сцены; статус — под коллекцией.
|
||||||
колод» возвращает `DeckListPanel`.
|
- Реализация: выполнена. Проверка: EditMode 67/67, панели в
|
||||||
- Ширина правой панели 380 px; содержимое — без пересоздания сцены.
|
`Client_Views_Build`.
|
||||||
- Реализация: не выполнена. Проверка: не выполнена.
|
|
||||||
- Зависит: C1, C3, C4.
|
- Зависит: C1, C3, C4.
|
||||||
|
|
||||||
- [ ] **C3. `MODIFY` список колод.**
|
- [x] **C3. `MODIFY` список колод.**
|
||||||
- Строка: имя, `N/50`, `T N/16`, формат (`Classic`, локализовано),
|
- Строка: имя, `N/50`, `T N/16`, `Classic`, `G0…G3`, бейдж
|
||||||
распределение `G0…G3`, бейдж «Готова / Не готова».
|
«Готова / Не готова»; валидность считается локально.
|
||||||
- Валидность считать локально (50 карт, 16 триггеров, ≤4 по `nameId`).
|
- Открытая колода подсвечена; «Создать» — в шапке панели.
|
||||||
- Подсветка открытой колоды; кнопка «Создать колоду» — над списком.
|
- Реализация: выполнена. Проверка: EditMode 67/67, ручная — позже.
|
||||||
- Реализация: не выполнена. Проверка: не выполнена.
|
|
||||||
- Зависит: C2.
|
- Зависит: C2.
|
||||||
|
|
||||||
- [ ] **C4. `MODIFY` список карт колоды.**
|
- [x] **C4. `MODIFY` список карт колоды.**
|
||||||
- Группировать по `nameId`, счётчик — сумма принтов; превью — первая
|
- Группировка по `nameId`; счётчик — сумма принтов; превью — иллюстрация
|
||||||
карта группы.
|
последнего принта группы.
|
||||||
- Строка 84 px: иллюстрация 64×54 (пропорция 1.18), имя (2 строки),
|
- Строка 84 px: превью 64×54, имя, `G`, `×N`, триггер и клан; сортировка
|
||||||
бейдж `G`, `×N`, триггер.
|
грейд → имя.
|
||||||
- Сортировка: `Grade` → `Loc.CardName` (`StringComparer.Ordinal`).
|
- Клик — убрать копию; ПКМ — карточка.
|
||||||
- Клик по строке — убрать одну копию; ПКМ — карточка. Счётчики и
|
- Реализация: выполнена. Проверка: `GroupDeck_*` тесты, EditMode 67/67.
|
||||||
валидация обновляются сразу.
|
|
||||||
- Реализация: не выполнена. Проверка: не выполнена.
|
|
||||||
- Зависит: C1, C2.
|
- Зависит: C1, C2.
|
||||||
|
|
||||||
- [ ] **C5. `MODIFY` фильтры — мультивыбор.**
|
- [x] **C5. `MODIFY` фильтры — мультивыбор.**
|
||||||
- Хранить `HashSet<string> Clans`, `HashSet<int> Grades`,
|
- `DeckFilter` с наборами клана/грейда/триггера; пустой набор = «все».
|
||||||
`HashSet<string> Triggers`; пустой набор = «все».
|
- Оверлей: чипы с подсветкой, «Сбросить» (черновик), «Отмена»,
|
||||||
- Оверлей: группы чипов с подсветкой выбранного, «Все»/«Сбросить»,
|
«Применить»; сводка `Клан: … · Ранг: … · Триггер: …`.
|
||||||
«Применить» (применяет черновик) и «Отмена» (откатывает).
|
- Применение пересобирает коллекцию; счётчики обновляются точечно.
|
||||||
- Сводка в топбаре: `Клан: RP, Kagero · Ранг: 0, 1 · Триггер: Heal`.
|
- Реализация: выполнена. Проверка: тесты фильтра, EditMode 67/67.
|
||||||
- `MatchesFilter` проверяет вхождение во все активные наборы.
|
|
||||||
- После применения — пересборка коллекции; счётчики в ячейках
|
|
||||||
обновляются без полного пересоздания (если возможно).
|
|
||||||
- Реализация: не выполнена. Проверка: не выполнена.
|
|
||||||
- Зависит: C2, C6.
|
- Зависит: C2, C6.
|
||||||
|
|
||||||
- [ ] **C6. `MODIFY` фильтрация и сортировка — чистый класс.**
|
- [x] **C6. `CREATE` `Runtime/UI/DeckListFilter.cs`.**
|
||||||
- Вынести в `Runtime/UI/DeckListFilter.cs` (или статический helper):
|
- `DeckFilter`, `DeckCardGroup`, `DeckValidation`, `DeckListModel`:
|
||||||
`Matches(meta, filter)`, `SortDeck(cards, catalog)`, `GroupDeck(...)`.
|
`Matches`, `GroupDeck`, `Validate`, `IsTrigger`.
|
||||||
- Фильтр: наборы клана/грейда/триггера; сортировка колоды: грейд → имя;
|
- Реализация: выполнена. Проверка: 4 теста `DeckBuilderTests`.
|
||||||
группировка по `nameId`.
|
|
||||||
- Реализация: не выполнена. Проверка: не выполнена.
|
|
||||||
- Зависит: C4, C5.
|
- Зависит: C4, C5.
|
||||||
|
|
||||||
- [ ] **C7. `MODIFY` ячейки коллекции.**
|
- [x] **C7. `MODIFY` ячейки коллекции.**
|
||||||
- Иллюстрация через `ApplyIllustration`, область арта с правильной
|
- Иллюстрация `ApplyIllustration`, область арта 174×144 без растяжения.
|
||||||
пропорцией (например, 174×147), без растяжения.
|
- Счётчик по `nameId`; при лимите 4 или 16 триггеров цифры красные;
|
||||||
- Показывать `×в колоде` по `nameId`; при достижении лимита 4 или 16
|
добавление по-прежнему блокируется с сообщением.
|
||||||
триггеров — приглушать и не добавлять.
|
- Реализация: выполнена. Проверка: EditMode 67/67, ручная — позже.
|
||||||
- Реализация: не выполнена. Проверка: не выполнена.
|
|
||||||
- Зависит: C1, C5.
|
- Зависит: C1, C5.
|
||||||
|
|
||||||
- [ ] **C8. `MODIFY` live-валидация.**
|
- [x] **C8. `MODIFY` live-валидация.**
|
||||||
- `UpdateStats` дополнить статусом валидности и цветом счётчиков;
|
- `UpdateStats`: счётчики, распределение, «не сохранено», цвет;
|
||||||
«Сохранить», «В бой», «С ботом» — `interactable` только при валидной
|
«Сохранить» — при валидной колоде, «В бой»/«С ботом» — только для
|
||||||
сохранённой колоде.
|
сохранённой валидной колоды; «Удалить» — для сохранённой.
|
||||||
- Ошибки лимитов — в статусе редактора, без модальных окон.
|
- Ошибки — в статусе, без модальных окон.
|
||||||
- Реализация: не выполнена. Проверка: не выполнена.
|
- Реализация: выполнена. Проверка: EditMode 67/67, ручная — позже.
|
||||||
- Зависит: C3, C4.
|
- Зависит: C3, C4.
|
||||||
|
|
||||||
- [ ] **C9. `MODIFY` `Runtime/UI/Localization.cs`** — новые ключи.
|
- [x] **C9. `MODIFY` `Runtime/UI/Localization.cs`** — новые ключи.
|
||||||
- «Мои колоды», «К списку колод», «Готова», «Не готова», «Клан: »,
|
- «Создать», «К списку колод», «Готова/Не готова», варианты триггеров,
|
||||||
«Ранг: », «Триггер: », «Применить», «Отмена», «Все кланы»,
|
«Сбросить», «Применить», «Удалить колоду», «не сохранено», ошибки.
|
||||||
«Все ранги», «Все триггеры», «Иллюстрация» и подписи счётчиков.
|
- Реализация: выполнена. Проверка: дубликатов ключей нет.
|
||||||
- Реализация: не выполнена. Проверка: не выполнена.
|
|
||||||
- Зависит: C2–C5.
|
- Зависит: C2–C5.
|
||||||
|
|
||||||
- [ ] **C10. `TEST` EditMode.**
|
- [x] **C10. `TEST` EditMode.**
|
||||||
- `DeckListFilter`: мультивыбор клана/грейда/триггера, пустой набор =
|
- `DeckBuilderTests`: пустой фильтр, мультивыбор, группировка/сортировка,
|
||||||
все; сортировка грейд → имя; группировка по `nameId` суммирует принты.
|
неизвестный принт, валидация 50/16/4.
|
||||||
- `CardImageService`: обрезка иллюстрации возвращает неполный rect
|
- `Client_Views_Build`: проверены `DeckListPanel`, `DeckPanel`,
|
||||||
(проверка по спрайту/текстуре без реального файла — на fallback).
|
`FilterOverlay`.
|
||||||
- UI: один экран, переключение панелей, сводка фильтров, бейдж
|
- Реализация: выполнена. Проверка: EditMode **67/67** в копии без
|
||||||
валидности.
|
`Library`.
|
||||||
- Существующий `Client_Views_Build` — без изменений ожиданий.
|
|
||||||
- Реализация: не выполнена. Проверка: не выполнена.
|
|
||||||
- Зависит: C1–C9.
|
- Зависит: C1–C9.
|
||||||
|
|
||||||
- [ ] **C11. `VERIFY` EditMode, smoke, standalone.**
|
- [x] **C11. `VERIFY` EditMode, smoke, standalone.**
|
||||||
- Копия без `Library`; EditMode; `SCENE_SMOKE`; standalone.
|
- Копия без `Library`; EditMode; `SCENE_SMOKE`; `PLAYMAT_SMOKE`.
|
||||||
- Ручная проверка: фильтры, список колод, редактор, добавление/удаление,
|
- Реализация: выполнена. Проверка: EditMode 67/67,
|
||||||
сохранение, «В бой»/«С ботом», ПКМ-карточка.
|
`SCENE_SMOKE success=True`, `PLAYMAT_SMOKE success=True`; ручная
|
||||||
- Реализация: не выполнена. Проверка: не выполнена.
|
проверка — за пользователем.
|
||||||
- Зависит: C10.
|
- Зависит: C10.
|
||||||
|
|
||||||
#### D. Последовательность выполнения
|
#### D. Последовательность выполнения
|
||||||
|
|
@ -2565,11 +2549,22 @@ HUD с номером хода и фазовой шкалой, компактн
|
||||||
|
|
||||||
#### G. Результат
|
#### G. Результат
|
||||||
|
|
||||||
- **Реализовано:** —
|
- **Реализовано:** C1–C11. DeckBuilder — один экран: коллекция с
|
||||||
- **Проверки:** —
|
мультифильтрами (клан/грейд/триггер), справа панель «Мои колоды» ⇄
|
||||||
- **Не завершено/заблокировано:** —
|
«Колода». Список колод показывает `50/50`, триггеры, грейды и
|
||||||
- **Skill обновлён:** —
|
валидность; список колоды группируется по `nameId`, сортируется
|
||||||
- **Git:** —
|
грейд → имя, превью — иллюстрация карты без искажений.
|
||||||
|
Live-валидация управляет «Сохранить»/«В бой»/«С ботом».
|
||||||
|
- **Проверки:** EditMode **67/67** в копии без `Library` (+5 тестов
|
||||||
|
`DeckBuilderTests`); `SCENE_SMOKE success=True`;
|
||||||
|
`PLAYMAT_SMOKE success=True`.
|
||||||
|
- **Не завершено:** ручная проверка фильтров, переключения панелей,
|
||||||
|
добавления/удаления, сохранения, «В бой»/«С ботом», ПКМ-карточки.
|
||||||
|
- **Замечания:** граница иллюстрации 0.59 подобрана по BT01/TD01
|
||||||
|
(350×511); при другом макете карты константу нужно проверить. Правка
|
||||||
|
имени колоды помечает колоду как несохранённую.
|
||||||
|
- **Skill обновлён:** не требуется.
|
||||||
|
- **Git:** не коммитилось.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,29 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-10-31 — TASK-2026-023: переделка DeckBuilder
|
||||||
|
|
||||||
|
- Один экран вместо двух: слева коллекция, справа панель «Мои колоды» ⇄
|
||||||
|
«Колода»; переключение без смены сцены.
|
||||||
|
- Мультифильтры клана, грейда и триггера в модальном оверлее: чипы,
|
||||||
|
черновик, «Сбросить», «Отмена», «Применить»; сводка в топбаре.
|
||||||
|
- Список колод: имя, `N/50`, `T N/16`, `Classic`, `G0…G3`, бейдж
|
||||||
|
«Готова / Не готова», подсветка открытой.
|
||||||
|
- Список карт колоды: группировка по `nameId`, сортировка грейд → имя,
|
||||||
|
превью-иллюстрация (верхние 59% карты) без искажений, имя, `×N`, G,
|
||||||
|
триггер и клан.
|
||||||
|
- Live-валидация 50/16/4 управляет «Сохранить», «В бой», «С ботом»;
|
||||||
|
изменение имени помечает колоду как несохранённую.
|
||||||
|
- Новые: `CardImageService.ApplyIllustration`, `DeckListFilter.cs`
|
||||||
|
(`DeckFilter`, `DeckListModel`, `DeckValidation`), 5 тестов
|
||||||
|
`DeckBuilderTests`.
|
||||||
|
- Проверки: EditMode **67/67**, `SCENE_SMOKE success=True`,
|
||||||
|
`PLAYMAT_SMOKE success=True`, standalone собран.
|
||||||
|
- Ручная проверка (фильтры, панели, добавление/удаление, сохранение,
|
||||||
|
«В бой»/«С ботом», ПКМ) — за пользователем.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-10-31 — TASK-2026-022: Lobby по референсу
|
## 2026-10-31 — TASK-2026-022: Lobby по референсу
|
||||||
|
|
||||||
- `Lobby` переведён на 2D-дашборд: процедурный фон, топбар (инициалы, ник,
|
- `Lobby` переведён на 2D-дашборд: процедурный фон, топбар (инициалы, ник,
|
||||||
|
|
|
||||||
|
|
@ -61,17 +61,17 @@
|
||||||
- Сервисы: `ApiClient`, `AuthService`, `MetaService`, `TokenStore`,
|
- Сервисы: `ApiClient`, `AuthService`, `MetaService`, `TokenStore`,
|
||||||
`PlayerState`, `GameConnection`.
|
`PlayerState`, `GameConnection`.
|
||||||
- Работает: логин/регистрация/refresh, онбординг, коллекция с разбором,
|
- Работает: логин/регистрация/refresh, онбординг, коллекция с разбором,
|
||||||
декбилдер в духе HS (список колод справа, карточная сетка коллекции,
|
декбилдер (слева карточная сетка коллекции с мультифильтрами, справа
|
||||||
фильтры в оверлее, сортировка клан→ранг→имя, редактор с вертикальным
|
панель «Мои колоды» ⇄ «Колода»; список карт колоды — грейд→имя,
|
||||||
списком карт колоды и статистикой по рангам/общему числу),
|
превью-иллюстрации), магазин (бустер/триал-дека), история матчей и
|
||||||
магазин (бустер/триал-дека), история матчей и реплей, очередь,
|
реплей, очередь, WebSocket-матч (snapshot, интенты, маллиган, сдача,
|
||||||
WebSocket-матч (snapshot, интенты, маллиган, сдача, `GameFinished`),
|
`GameFinished`), таймер, результат.
|
||||||
таймер, результат.
|
- Декбилдер: `DeckBuilderView` (один экран), `DeckListFilter`
|
||||||
- Декбилдер: `DeckBuilderView`; каталог метаданных — `GET /api/cards`
|
(`DeckFilter`/`DeckListModel`); каталог метаданных — `GET /api/cards`
|
||||||
(grade, trigger, clan, rarity, printing, power/shield/type, текст
|
(grade, trigger, clan, rarity, printing, power/shield/type, текст
|
||||||
способности), клиент объединяет с коллекцией.
|
способности), клиент объединяет с коллекцией.
|
||||||
Изображения карт — реальные арты из `Resources/CardArt` (124/124), с
|
Превью карт — иллюстрация (верхние 59% арта) без искажений; полный арт
|
||||||
процедурным fallback по цвету клана.
|
остаётся в карточке и 3D-поле.
|
||||||
- ПКМ по карте — оверлей с картой, способностями, разбором в пыль и
|
- ПКМ по карте — оверлей с картой, способностями, разбором в пыль и
|
||||||
крафтом; SP/TD не разбираются и не крафтятся.
|
крафтом; SP/TD не разбираются и не крафтятся.
|
||||||
- Локализация RU/EN: `Loc` + `LocalizedText`; переключатель в Login и
|
- Локализация RU/EN: `Loc` + `LocalizedText`; переключатель в Login и
|
||||||
|
|
@ -285,7 +285,7 @@ Standalone Windows:
|
||||||
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
|
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
|
||||||
```
|
```
|
||||||
|
|
||||||
Клиентские проверки: **62/62** EditMode (включая новые тесты TASK-2026-001:
|
Клиентские проверки: **67/67** EditMode (включая новые тесты TASK-2026-001:
|
||||||
повторный render скрытых карт, открытие урона, source-позиция нового юнита,
|
повторный render скрытых карт, открытие урона, source-позиция нового юнита,
|
||||||
coalescing `CardMoveAnimator`; сборка всех экранов, импорт арта, перевод
|
coalescing `CardMoveAnimator`; сборка всех экранов, импорт арта, перевод
|
||||||
строк, live-переключение языка, рендер 3D-доски, геометрия поля, правила
|
строк, live-переключение языка, рендер 3D-доски, геометрия поля, правила
|
||||||
|
|
@ -376,9 +376,10 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack
|
||||||
**TASK-2026-022 — Lobby по референсу: реализован C1–C11, ждёт ручной
|
**TASK-2026-022 — Lobby по референсу: реализован C1–C11, ждёт ручной
|
||||||
приёмки.** EditMode 62/62, `SCENE_SMOKE`, `PLAYMAT_SMOKE`, standalone
|
приёмки.** EditMode 62/62, `SCENE_SMOKE`, `PLAYMAT_SMOKE`, standalone
|
||||||
собраны и запущены. **TASK-2026-023 — DeckBuilder (один экран,
|
собраны и запущены. **TASK-2026-023 — DeckBuilder (один экран,
|
||||||
мультифильтры, список колоды): анализ и план готовы**, реализация —
|
мультифильтры, список колоды): реализован C1–C11, ждёт ручной
|
||||||
следующим проходом. После приёмки — **TASK-2026-019** (загрузочная
|
приёмки.** EditMode 67/67, `SCENE_SMOKE`, `PLAYMAT_SMOKE`; standalone.
|
||||||
сцена, fade, кадр без покадровых аллокаций).
|
После приёмки — **TASK-2026-019** (загрузочная сцена, fade, кадр без
|
||||||
|
покадровых аллокаций).
|
||||||
2. **TASK-2026-001 — ручная проверка пользователем** (C10): draw, call, ride,
|
2. **TASK-2026-001 — ручная проверка пользователем** (C10): draw, call, ride,
|
||||||
move/swap, guard, trigger, drive/damage, retire, search, открытие скрытого
|
move/swap, guard, trigger, drive/damage, retire, search, открытие скрытого
|
||||||
урона, рука противника, повторные клики UI во время/после анимации.
|
урона, рука противника, повторные клики UI во время/после анимации.
|
||||||
|
|
@ -455,7 +456,8 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack
|
||||||
multi-target Soul Blast/прочие стоимости, отдельный `CostPaymentOverlay`,
|
multi-target Soul Blast/прочие стоимости, отдельный `CostPaymentOverlay`,
|
||||||
world-space Power/Shield (верхний strip принят как HUD), world-space
|
world-space Power/Shield (верхний strip принят как HUD), world-space
|
||||||
dash shader; полировка позиций на конкретном мониторе.
|
dash shader; полировка позиций на конкретном мониторе.
|
||||||
4. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты.
|
4. Декбилдер: drag&drop карт, пресеты, предупреждение о
|
||||||
|
несохранённых изменениях, «Все/Ничего» по секциям фильтра.
|
||||||
5. 3D-наполнение: модели карт/стола, prefab-экраны сцен, анимации retire/ghost.
|
5. 3D-наполнение: модели карт/стола, prefab-экраны сцен, анимации retire/ghost.
|
||||||
6. Звук и настройки, DOTween.
|
6. Звук и настройки, DOTween.
|
||||||
7. Лаунчер WPF.
|
7. Лаунчер WPF.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue