client: DeckBuilder — фильтры, карты вне коллекции и «Распылить всё»
CI / test (push) Successful in 5m53s Details

- Кнопка «Сброс» убрана из строки фильтров; сброс остался в оверлее.
- В фильтрах добавлен режим «Показывать вне коллекции»: карты без копий
  показываются серыми, без добавления в колоду (ПКМ — карточка).
- Рядом с «Фильтры» — «Распылить всё», если есть копии сверх 4 на
  название (SP пропускаются). Подтверждение показывает количество и пыль.
- DeckListModel.PlanExtras + тест; EditMode 70/70, standalone собран.
This commit is contained in:
MpakobecEx 2026-11-01 22:41:43 +05:00
parent c7f2c51fec
commit 42e57f5ac1
4 changed files with 253 additions and 43 deletions

View File

@ -50,6 +50,8 @@ namespace Vanguard.UI
private Button _botButton;
private Button _duelButton;
private Button _deleteButton;
private Button _dismantleAllButton;
private RectTransform _unownedChips;
private RectTransform _deckMenuOverlay;
private Text _deckMenuTitle;
private DeckDto _deckMenuDeck;
@ -61,8 +63,9 @@ namespace Vanguard.UI
private RectTransform _clanChips;
private RectTransform _confirmOverlay;
private InputField _searchInput;
private Text _confirmTitle;
private Text _confirmText;
private DeckDto _pendingDelete;
private Action _confirmAction;
private RectTransform _cardOverlay;
private Image _cardArt;
@ -200,8 +203,15 @@ namespace Vanguard.UI
var filters = UiKit.CreateButton(filterBar, "Filters", "Фильтры", OpenFilters, 16);
SetFixedSize(filters, 110f, 38f);
var reset = UiKit.CreateButton(filterBar, "ResetFilters", "Сброс", ResetFilters, 16);
SetFixedSize(reset, 90f, 38f);
_dismantleAllButton = UiKit.CreateButton(
filterBar,
"DismantleAll",
"Распылить всё",
ConfirmDismantleExtras,
15);
SetFixedSize(_dismantleAllButton, 200f, 38f);
SetButtonColor(_dismantleAllButton, new Color(0.30f, 0.16f, 0.08f, 0.95f));
_dismantleAllButton.gameObject.SetActive(false);
var scroll = UiKit.CreateGridScrollView(
collectionArea,
@ -334,7 +344,7 @@ namespace Vanguard.UI
var overlay = UiKit.CreatePanel(_root, "FilterOverlay", new Color(0f, 0f, 0f, 0.78f));
var card = UiKit.CreateCenteredCard(
overlay, "FilterCard", new Vector2(900f, 940f), PanelFill);
overlay, "FilterCard", new Vector2(900f, 1000f), PanelFill);
UiKit.AddVerticalLayout(card, 24, 10);
var title = UiKit.CreateText(card, "Title", "Фильтры коллекции", 28, TextPrimary);
@ -345,10 +355,11 @@ namespace Vanguard.UI
UiKit.SetPreferredHeight(_searchInput, 44f);
_searchInput.onValueChanged.AddListener(value => _draftFilter.Search = value);
_unownedChips = BuildFilterSection(card, "Отображение", 44f);
_rarityChips = BuildFilterSection(card, "Редкость", 44f);
_gradeChips = BuildFilterSection(card, "Ранг", 44f);
_triggerChips = BuildFilterSection(card, "Триггер", 44f);
_clanChips = BuildFilterSection(card, "Клан", 260f, flexible: true);
_clanChips = BuildFilterSection(card, "Клан", 220f, flexible: true);
var footer = UiKit.CreatePanel(card, "Footer", new Color(0f, 0f, 0f, 0f));
UiKit.SetPreferredHeight(footer, 48f);
@ -433,18 +444,19 @@ namespace Vanguard.UI
_root, "DeleteDeckConfirm", new Color(0f, 0f, 0f, 0.78f));
var card = UiKit.CreateCenteredCard(
overlay, "ConfirmCard", new Vector2(640f, 300f), PanelFill);
overlay, "ConfirmCard", new Vector2(640f, 320f), PanelFill);
UiKit.AddVerticalLayout(card, 30, 12);
var title = UiKit.CreateText(card, "Title", "Удалить колоду?", 28, TextPrimary);
title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(title, 42f);
_confirmTitle = UiKit.CreateText(card, "Title", "Удалить колоду?", 28, TextPrimary);
_confirmTitle.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(_confirmTitle, 42f);
_confirmText = UiKit.CreateText(
card, "Text", string.Empty, 19, TextMuted);
_confirmText.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(_confirmText, 70f);
var yes = UiKit.CreateButton(card, "Yes", "Удалить", ConfirmDelete, 20);
var yes = UiKit.CreateButton(card, "Yes", "Подтвердить", ConfirmAction, 20);
UiKit.SetPreferredHeight(yes, 48f);
var no = UiKit.CreateButton(card, "No", "Отмена", CancelDelete, 20);
@ -877,8 +889,26 @@ namespace Vanguard.UI
ClearChildren(_collectionContent);
_countTexts.Clear();
var items = _collection
.Select(item => new { Item = item, Meta = MetaOf(item) })
var ownedByPrinting = new Dictionary<string, CollectionItemDto>(StringComparer.Ordinal);
foreach (var item in _collection)
ownedByPrinting[item.PrintingId] = item;
var source = _filter.IncludeUnowned
? _catalog.Values.Select(meta => new
{
Meta = meta,
Item = ownedByPrinting.TryGetValue(meta.PrintingId, out var owned)
? owned
: new CollectionItemDto
{
PrintingId = meta.PrintingId,
NameId = meta.NameId,
Count = 0,
},
})
: _collection.Select(item => new { Meta = MetaOf(item), Item = item });
var items = source
.Where(pair => pair.Meta != null && _filter.Matches(pair.Meta))
.OrderBy(pair => pair.Meta.Clan, StringComparer.Ordinal)
.ThenBy(pair => pair.Meta.Grade)
@ -886,6 +916,8 @@ namespace Vanguard.UI
foreach (var pair in items)
AddCollectionCell(pair.Item, pair.Meta);
UpdateDismantleButton();
}
private void AddCollectionCell(CollectionItemDto item, CardCatalogItemDto meta)
@ -899,7 +931,7 @@ namespace Vanguard.UI
frame.effectColor = CardArt.ClanColor(meta.Clan);
frame.effectDistance = new Vector2(2f, -2f);
if (_deckOpen)
if (_deckOpen && item.Count > 0)
{
var button = cell.gameObject.AddComponent<Button>();
button.targetGraphic = cell.GetComponent<Image>();
@ -908,10 +940,13 @@ namespace Vanguard.UI
var art = UiKit.CreatePanel(cell, "Art", Color.white);
UiKit.SetPreferredHeight(art, 234f);
var artImage = art.GetComponent<Image>();
CardImageService.Apply(
art.GetComponent<Image>(),
artImage,
meta.ImageKey,
CardArt.ForCard(meta.NameId, meta.Clan));
if (item.Count <= 0)
artImage.color = new Color(0.45f, 0.45f, 0.45f, 1f);
var qty = UiKit.CreatePanel(cell, "Qty", new Color(0.02f, 0.05f, 0.12f, 0.95f));
UiKit.SetPreferredHeight(qty, 46f);
@ -943,7 +978,9 @@ namespace Vanguard.UI
var atCap = _deckOpen
&& (used >= 4 || (meta != null && IsTrigger(meta.Trigger) && triggerCap));
pair.Value.text = CountText(item);
pair.Value.color = atCap
pair.Value.color = item.Count <= 0
? new Color(0.50f, 0.50f, 0.50f)
: atCap
? new Color(1f, 0.55f, 0.45f)
: new Color(0.90f, 0.86f, 0.70f);
}
@ -1032,15 +1069,16 @@ namespace Vanguard.UI
RebuildFilterChips();
}
private void ResetFilters()
{
_filter.Clear();
UpdateFilterSummary();
RebuildCollection();
}
private void RebuildFilterChips()
{
ClearChildren(_unownedChips);
Chip(
_unownedChips,
"Показывать вне коллекции",
_draftFilter.IncludeUnowned,
ToggleUnowned,
360f);
ClearChildren(_rarityChips);
foreach (var rarity in new[] { "C", "R", "RR", "RRR", "SP" })
{
@ -1130,6 +1168,12 @@ namespace Vanguard.UI
RebuildFilterChips();
}
private void ToggleUnowned()
{
_draftFilter.IncludeUnowned = !_draftFilter.IncludeUnowned;
RebuildFilterChips();
}
private static Button Chip(
Transform parent,
string label,
@ -1174,6 +1218,8 @@ namespace Vanguard.UI
parts.Add("Триггер: " + string.Join(", ", _filter.Triggers.OrderBy(item => item, StringComparer.Ordinal)));
if (!string.IsNullOrWhiteSpace(_filter.Search))
parts.Add("Поиск: " + _filter.Search.Trim());
if (_filter.IncludeUnowned)
parts.Add("вне коллекции");
_filterSummary.text = string.Join(" · ", parts);
}
@ -1420,25 +1466,88 @@ namespace Vanguard.UI
private void RequestDelete(DeckDto deck)
{
_pendingDelete = deck;
_confirmTitle.text = "Удалить колоду?";
_confirmText.text = deck.Name + "\nКолода будет удалена без восстановления.";
_confirmAction = () => DeleteDeck(deck);
ShowConfirm();
}
private void ConfirmDismantleExtras()
{
var plan = DeckListModel.PlanExtras(_collection);
if (plan.IsEmpty || _busy)
return;
_confirmTitle.text = "Распылить лишние карты?";
_confirmText.text = "Будет распылено " + plan.CardCount
+ " карт на " + plan.Dust + " пыли.";
_confirmAction = () => DismantleExtras(plan);
ShowConfirm();
}
private void ShowConfirm()
{
_confirmOverlay.SetAsLastSibling();
_confirmOverlay.gameObject.SetActive(true);
}
private void CancelDelete()
{
_pendingDelete = null;
_confirmAction = null;
_confirmOverlay.gameObject.SetActive(false);
}
private void ConfirmDelete()
private void ConfirmAction()
{
var deck = _pendingDelete;
var action = _confirmAction;
_confirmAction = null;
_confirmOverlay.gameObject.SetActive(false);
_pendingDelete = null;
if (deck != null)
DeleteDeck(deck);
action?.Invoke();
}
private async void DismantleExtras(DismantlePlan plan)
{
if (_busy || plan == null || plan.IsEmpty)
return;
_busy = true;
UpdateDismantleButton();
SetStatus("Распыляем…", false);
try
{
await _auth.EnsureAccessAsync();
foreach (var item in plan.Items)
await _meta.DismantleAsync(item.PrintingId, item.Count);
await ReloadCollectionAsync(null);
SetStatus(
"Распылено " + plan.CardCount + " карт на " + plan.Dust + " пыли.",
false);
}
catch (ApiException exception)
{
SetStatus(exception.Message, true);
}
catch (Exception exception)
{
Debug.LogException(exception);
SetStatus("Не удалось распылить карты.", true);
}
finally
{
_busy = false;
UpdateDismantleButton();
}
}
private void UpdateDismantleButton()
{
if (_dismantleAllButton == null)
return;
var plan = DeckListModel.PlanExtras(_collection);
_dismantleAllButton.gameObject.SetActive(!plan.IsEmpty);
_dismantleAllButton.interactable = !plan.IsEmpty && !_busy;
}
// ------------------------------------------------------------ карточка
@ -1500,7 +1609,7 @@ namespace Vanguard.UI
_craftButton.interactable = canChange && !_busy && _dust >= craftCost;
SetButtonLabel(
_dismantleButton,
canChange ? "Разобрать (+" + DustFor(meta.Rarity) + " пыли)" : "Нельзя разобрать");
canChange ? "Разобрать (+" + DeckListModel.DustFor(meta.Rarity) + " пыли)" : "Нельзя разобрать");
SetButtonLabel(
_craftButton,
canChange
@ -1586,7 +1695,12 @@ namespace Vanguard.UI
_dust = collection.Balances != null ? collection.Balances.Dust : 0;
RebuildCollection();
RefreshCounts();
_selectedItem = _collection.Find(item => item.PrintingId == printingId);
UpdateDismantleButton();
_selectedItem = printingId == null
? null
: _collection.Find(item => item.PrintingId == printingId);
if (_selectedItem == null)
{
_cardOverlay.gameObject.SetActive(false);
@ -1638,15 +1752,6 @@ namespace Vanguard.UI
private CardCatalogItemDto MetaOf(CollectionItemDto item) =>
_catalog.TryGetValue(item.PrintingId, out var meta) ? meta : null;
private static int DustFor(string rarity) => rarity switch
{
"C" => 5,
"R" => 10,
"RR" => 25,
"RRR" => 100,
_ => 0,
};
private static int CraftCostFor(string rarity) => rarity switch
{
"C" => 20,

View File

@ -16,9 +16,10 @@ namespace Vanguard.UI
public HashSet<string> Triggers { get; } = new HashSet<string>(StringComparer.Ordinal);
public HashSet<string> Rarities { get; } = new HashSet<string>(StringComparer.Ordinal);
public string Search { get; set; } = string.Empty;
public bool IncludeUnowned { get; set; }
public bool IsEmpty => Clans.Count == 0 && Grades.Count == 0 && Triggers.Count == 0
&& Rarities.Count == 0 && string.IsNullOrWhiteSpace(Search);
&& Rarities.Count == 0 && string.IsNullOrWhiteSpace(Search) && !IncludeUnowned;
public bool Matches(CardCatalogItemDto meta)
{
@ -54,6 +55,7 @@ namespace Vanguard.UI
Triggers.Clear();
Rarities.Clear();
Search = string.Empty;
IncludeUnowned = false;
}
public void CopyFrom(DeckFilter other)
@ -67,6 +69,7 @@ namespace Vanguard.UI
Triggers.UnionWith(other.Triggers);
Rarities.UnionWith(other.Rarities);
Search = other.Search ?? string.Empty;
IncludeUnowned = other.IncludeUnowned;
}
public DeckFilter Clone()
@ -98,6 +101,35 @@ namespace Vanguard.UI
public bool IsValid => Count == 50 && Triggers == 16 && MaxCopiesOk;
}
public sealed class DismantlePlanItem
{
public string PrintingId { get; set; } = string.Empty;
public int Count { get; set; }
public int Dust { get; set; }
}
/// <summary>Лишние копии сверх 4 на название, которые можно распылить.</summary>
public sealed class DismantlePlan
{
public List<DismantlePlanItem> Items { get; } = new List<DismantlePlanItem>();
public int CardCount { get; private set; }
public int Dust { get; private set; }
public bool IsEmpty => Items.Count == 0;
public void Add(string printingId, int count, int dust)
{
Items.Add(new DismantlePlanItem
{
PrintingId = printingId,
Count = count,
Dust = dust,
});
CardCount += count;
Dust += dust;
}
}
/// <summary>Чистые правила фильтрации, группировки и проверки колоды.</summary>
public static class DeckListModel
{
@ -172,5 +204,48 @@ namespace Vanguard.UI
public static bool IsTrigger(string trigger) =>
!string.IsNullOrEmpty(trigger)
&& !string.Equals(trigger, "None", StringComparison.Ordinal);
/// <summary>
/// План распыления лишних копий: всё сверх 4 на название. SP/TD не
/// распыляются, поэтому пропускаются. Сначала берутся карты с меньшей
/// ценой распыления, чтобы сохранить ценные.
/// </summary>
public static DismantlePlan PlanExtras(IEnumerable<CollectionItemDto> collection)
{
var plan = new DismantlePlan();
if (collection == null)
return plan;
foreach (var group in collection.GroupBy(item => item.NameId))
{
var total = group.Sum(item => item.Count);
var extra = total - 4;
if (extra <= 0)
continue;
foreach (var item in group
.Where(item => DustFor(item.Rarity) > 0 && item.Count > 0)
.OrderBy(item => DustFor(item.Rarity)))
{
if (extra <= 0)
break;
var take = Math.Min(extra, item.Count);
plan.Add(item.PrintingId, take, take * DustFor(item.Rarity));
extra -= take;
}
}
return plan;
}
public static int DustFor(string rarity) => rarity switch
{
"C" => 5,
"R" => 10,
"RR" => 25,
"RRR" => 100,
_ => 0,
};
}
}

View File

@ -440,6 +440,18 @@ namespace Vanguard.UI
["есть"] = "owned",
["в колоде"] = "in deck",
["Открыть"] = "Open",
["Распылить всё"] = "Dismantle all",
["Показывать вне коллекции"] = "Show cards not owned",
["Распылить лишние карты?"] = "Dismantle excess cards?",
["Будет распылено "] = "Will dismantle ",
[" карт на "] = " cards for ",
[" пыли."] = " dust.",
["Распыляем…"] = "Dismantling…",
["Распылено "] = "Dismantled ",
["Не удалось распылить карты."] = "Could not dismantle the cards.",
["вне коллекции"] = "not owned",
["Подтвердить"] = "Confirm",
["Отображение"] = "Display",
};
private static readonly KeyValuePair<string, string>[] Phrases = Exact

View File

@ -141,6 +141,24 @@ namespace Vanguard.Tests
Assert.AreEqual("missing", groups[1].NameId);
}
[Test]
public void PlanExtras_Takes_Copies_Above_Four_And_Skips_SP()
{
var collection = new List<CollectionItemDto>
{
new CollectionItemDto { PrintingId = "a", NameId = "A", Rarity = "C", Count = 5 },
new CollectionItemDto { PrintingId = "b", NameId = "B", Rarity = "SP", Count = 6 },
new CollectionItemDto { PrintingId = "c", NameId = "C", Rarity = "RR", Count = 3 },
};
var plan = DeckListModel.PlanExtras(collection);
Assert.AreEqual(1, plan.CardCount);
Assert.AreEqual(5, plan.Dust);
Assert.AreEqual(1, plan.Items.Count);
Assert.AreEqual("a", plan.Items[0].PrintingId);
}
[Test]
public void Validate_Requires_Count_Triggers_And_Copy_Limit()
{