diff --git a/Content.Client/_NC/Trade/NcStoreBoundUserInterface.cs b/Content.Client/_NC/Trade/NcStoreBoundUserInterface.cs new file mode 100644 index 000000000000..9bc5f0045e7f --- /dev/null +++ b/Content.Client/_NC/Trade/NcStoreBoundUserInterface.cs @@ -0,0 +1,116 @@ +using System.Linq; +using Content.Shared._NC.Trade; +using Robust.Client.Player; +using Robust.Client.UserInterface; + + +namespace Content.Client._NC.Trade; + + +public sealed class NcStoreStructuredBoundUi(EntityUid owner, Enum uiKey) + : BoundUserInterface(owner, uiKey) +{ + private readonly IPlayerManager _player = IoCManager.Resolve(); + private int _lastRevision = int.MinValue; + + private NcStoreMenu? _menu; + + private EntityUid? Actor => _player.LocalSession?.AttachedEntity; + + + protected override void UpdateState(BoundUserInterfaceState state) + { + base.UpdateState(state); + + if (state is not StoreUiState st) + return; + + EnsureMenuCreated(); + if (_menu == null) + return; + + if (st.Revision == _lastRevision) + return; + + _lastRevision = st.Revision; + + _menu.ApplyState(st.Balance, st.BalanceByCurrency, st.Listings.ToList(), st.MassSellTotals); + _menu.PopulateContracts(st.Contracts); + _menu.Visible = true; + } + + private void EnsureMenuCreated() + { + if (_menu != null) + return; + + _menu = this.CreateWindow(); + _lastRevision = int.MinValue; + + + if (EntMan.TryGetComponent(Owner, out MetaDataComponent? meta)) + _menu.Title = meta.EntityName; + + _menu.OnBuyPressed += OnBuy; + _menu.OnSellPressed += OnSell; + _menu.OnMassSellPulledCrate += OnMassSellPulledCrate; + _menu.OnContractClaim += OnContractClaim; + + _menu.OnClose += () => + { + _menu.Orphan(); + _menu = null; + _lastRevision = int.MinValue; + }; + } + + + private void OnBuy(StoreListingData data, int qty) + { + if (Actor is null) + return; + + SendMessage(new StoreBuyListingBoundUiMessage(data.Id, qty)); + } + + private void OnSell(StoreListingData data, int qty) + { + if (Actor is null) + return; + + SendMessage(new StoreSellListingBoundUiMessage(data.Id, qty)); + } + + private void OnContractClaim(string contractId) + { + if (Actor is null) + return; + + SendMessage(new ClaimContractBoundMessage(contractId)); + } + + private void OnMassSellPulledCrate() + { + if (Actor is null) + return; + + SendMessage(new StoreMassSellPulledCrateBoundUiMessage()); + } + + + protected override void Dispose(bool disposing) + { + if (_menu != null) + { + _menu.OnBuyPressed -= OnBuy; + _menu.OnSellPressed -= OnSell; + _menu.OnMassSellPulledCrate -= OnMassSellPulledCrate; + _menu.OnContractClaim -= OnContractClaim; + + _menu.Orphan(); + _menu = null; + } + + base.Dispose(disposing); + } +} diff --git a/Content.Client/_NC/Trade/NcStoreListingControl.cs b/Content.Client/_NC/Trade/NcStoreListingControl.cs new file mode 100644 index 000000000000..17619dca86b6 --- /dev/null +++ b/Content.Client/_NC/Trade/NcStoreListingControl.cs @@ -0,0 +1,527 @@ +using System.Linq; +using Content.Client.Stylesheets; +using Content.Shared._NC.Trade; +using Content.Shared.Stacks; +using Robust.Client.GameObjects; +using Robust.Client.Graphics; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + + +namespace Content.Client._NC.Trade; + + +public sealed class NcStoreListingControl : PanelContainer +{ + private const int SlotPx = 96; + private const int PriceW = 96; + private const int PriceH = 32; + private const int TextMax = 420; + private const int QtyMaxDigits = 6; + private const int MaxTotalDisplay = 999_999; + private const int DescMaxChars = 220; + + // Forge Frontier + private const int IconTiny = 16; + private const int IconSmall = 48; + private const int IconMedium = 24; + private const int IconLarge = 80; + // Forge Frontier end + + private readonly int _maxQty; + private readonly LineEdit _qtyEdit; + private Label? _priceLbl; + private int _qty; + private bool _suppressQtyEditChange; + + public NcStoreListingControl( + StoreListingData data, + SpriteSystem sprites, + int balanceHint = int.MaxValue, + int initialQty = 1, + bool actionsEnabled = true + ) + { + Margin = new(6, 6, 6, 6); + HorizontalExpand = true; + + // Карточка-обёртка + var card = new PanelContainer + { + HorizontalExpand = true, + PanelOverride = new StyleBoxFlat + { + BackgroundColor = Color.FromHex("#131317"), + BorderColor = Color.FromHex("#2e2e2e"), + BorderThickness = new(1), + PaddingLeft = 10, + PaddingRight = 10, + PaddingTop = 8, + PaddingBottom = 8 + } + }; + AddChild(card); + + var mainCol = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Vertical, + SeparationOverride = 6, + HorizontalExpand = true + }; + card.AddChild(mainCol); + + var pm = IoCManager.Resolve(); + pm.TryIndex(data.ProductEntity, out var proto); + + var title = new Label + { + Text = proto?.Name ?? data.ProductEntity, + Modulate = Color.FromHex("#9b8056"), + HorizontalExpand = true, + ClipText = true, + ToolTip = proto?.Name ?? data.ProductEntity, + Margin = new(10, 5, 6, 8) + }; + title.StyleClasses.Add(StyleBase.StyleClassLabelHeading); + mainCol.AddChild(title); + + var row = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + SeparationOverride = 6, + HorizontalExpand = true + }; + mainCol.AddChild(row); + + var leftCol = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Vertical, + SeparationOverride = 4, + HorizontalExpand = false, + // VerticalAlignment = VAlignment.Center, // Forge Frontier + MinSize = new Vector2i(SlotPx, 0), + Margin = new(20, 0, 0, 20) + }; + + if (MakeSlot(proto, sprites) is { } slot) + leftCol.AddChild(slot); + + row.AddChild(leftCol); + var textCol = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Vertical, + SeparationOverride = 2, + HorizontalExpand = true, + VerticalExpand = false + // VerticalAlignment = VAlignment.Center // Forge Frontier + }; + + var desc = MakeDescription(proto); + desc.Margin = new(0, 0, 6, 0); + textCol.AddChild(desc); + row.AddChild(textCol); + + // Правая колонка — количество, цена, остаток + var actionCol = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Vertical, + SeparationOverride = 4, + HorizontalExpand = false, + VerticalAlignment = VAlignment.Center, // Forge Frontier + Margin = new Thickness(0, 0, 20, 0), + MinSize = new Vector2i(PriceW, PriceH) + }; + + // Ограничения по количеству + var remainingCap = data.Remaining >= 0 ? data.Remaining : int.MaxValue; + var ownedCap = data.Mode == StoreMode.Sell ? data.Owned : int.MaxValue; + var moneyCap = data.Mode == StoreMode.Buy && data.Price > 0 + ? balanceHint / data.Price + : int.MaxValue; + + _maxQty = Math.Min(remainingCap, Math.Min(ownedCap, moneyCap)); + _qty = Math.Clamp(initialQty, MinAllowed, Math.Max(MinAllowed, _maxQty)); + + // Строка с выбором количества + var qtyRow = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + SeparationOverride = 4, + HorizontalExpand = false + }; + + var minusBtn = new Button + { + Text = "−", + MinSize = new Vector2i(24, 24) + }; + + var qtyLbl = new Label + { + Text = _qty.ToString(), + MinSize = new Vector2i(28, 24), + HorizontalAlignment = HAlignment.Center + }; + + var qtyEdit = new LineEdit + { + Text = _qty.ToString(), + MinSize = new Vector2i(40, 24), + HorizontalExpand = false + }; + _qtyEdit = qtyEdit; + + var plusBtn = new Button + { + Text = "+", + MinSize = new Vector2i(24, 24) + }; + + var noQty = _maxQty <= 0 || !actionsEnabled; + minusBtn.Disabled = noQty; + plusBtn.Disabled = noQty; + _qtyEdit.Editable = !noQty; + + if (!actionsEnabled) + { + minusBtn.ToolTip = "Доступно только через массовую продажу ящика."; + plusBtn.ToolTip = "Доступно только через массовую продажу ящика."; + _qtyEdit.ToolTip = "Доступно только через массовую продажу ящика."; + } + + minusBtn.OnPressed += _ => + { + if (!actionsEnabled) + return; + + if (_qty > MinAllowed) + SetQty(_qty - 1, data, qtyLbl); + }; + + plusBtn.OnPressed += _ => + { + if (!actionsEnabled) + return; + + if (_qty < _maxQty) + SetQty(_qty + 1, data, qtyLbl); + }; + + _qtyEdit.OnTextChanged += _ => + { + if (!actionsEnabled) + return; + + if (_suppressQtyEditChange) + return; + + var digits = new string(_qtyEdit.Text.Where(char.IsDigit).Take(QtyMaxDigits).ToArray()); + if (digits.Length == 0) + { + // Не даём оставить пустоту; откатываем к текущему значению без рекурсии. + _suppressQtyEditChange = true; + _qtyEdit.Text = _qty.ToString(); + _qtyEdit.CursorPosition = _qtyEdit.Text.Length; + _suppressQtyEditChange = false; + return; + } + + if (!int.TryParse(digits, out var v)) + v = _qty; + + var clamped = Math.Clamp(v, MinAllowed, Math.Max(MinAllowed, _maxQty)); + var newText = clamped.ToString(); + + if (_qtyEdit.Text != newText) + { + _suppressQtyEditChange = true; + _qtyEdit.Text = newText; + _qtyEdit.CursorPosition = _qtyEdit.Text.Length; + _suppressQtyEditChange = false; + } + + SetQty(clamped, data, qtyLbl); + }; + + _qtyEdit.OnTextEntered += _ => + { + if (!actionsEnabled) + return; + + if (_maxQty <= 0 || _qty <= 0) + return; + + switch (data.Mode) + { + case StoreMode.Buy: + OnBuyPressed?.Invoke(_qty); + break; + case StoreMode.Sell: + OnSellPressed?.Invoke(_qty); + break; + } + }; + + qtyRow.AddChild(minusBtn); + qtyRow.AddChild(qtyLbl); + qtyRow.AddChild(qtyEdit); + qtyRow.AddChild(plusBtn); + actionCol.AddChild(qtyRow); + + // Кнопка цены / статус + if (data.Remaining != 0) + { + actionCol.AddChild(MakePriceButton(data, sprites, actionsEnabled)); + UpdateTotal(data); + } + else + { + actionCol.AddChild( + new Label + { + Text = data.Mode == StoreMode.Buy ? "Нет в наличии" : "Закупка завершена", + HorizontalAlignment = HAlignment.Center, + Modulate = Color.FromHex("#666666"), + Margin = new(0, 8, 0, 0) + }); + } + + // Остаток и "у вас" + var showRemaining = data.Remaining >= 0; + var showOwned = data.Owned > 0; + + if (showRemaining) + { + actionCol.AddChild( + new Label + { + Text = data.Mode == StoreMode.Buy + ? $"Осталось: {data.Remaining}" + : $"Скупим: {data.Remaining}", + HorizontalAlignment = HAlignment.Center, + Modulate = Color.FromHex("#666666"), + Margin = new(0, 2, 0, 0) + }); + } + + if (showOwned) + { + actionCol.AddChild( + new Label + { + Text = $"У вас: {data.Owned}", + HorizontalAlignment = HAlignment.Center, + Modulate = Color.FromHex("#666666"), + Margin = new(0, 2, 0, 0) + }); + } + + row.AddChild(actionCol); + } + + private int MinAllowed => _maxQty <= 0 ? 0 : 1; + + public event Action? OnBuyPressed; + public event Action? OnSellPressed; + public event Action? OnQtyChanged; + + // --- Иконка валюты --- + + private static Texture? TryGetCurrencyIcon(string currencyId, SpriteSystem sprites) + { + var pm = IoCManager.Resolve(); + + if (!pm.TryIndex(currencyId, out var stack)) + return null; + + if (!pm.TryIndex(stack.Spawn, out var ent)) + return null; + + return sprites.GetPrototypeIcon(ent.ID).Default; + } + + // --- Слот с иконкой товара --- + + private static Control? MakeSlot(EntityPrototype? proto, SpriteSystem sprites) + { + if (proto == null) + return null; + + if (sprites.GetPrototypeIcon(proto.ID).Default is not { } tex) + return null; + + var slot = new PanelContainer + { + StyleClasses = { StyleNano.StyleClassInventorySlotBackground, }, + MinSize = new Vector2i(SlotPx, SlotPx) + }; + + slot.AddChild( + new TextureRect + { + Texture = tex, + Stretch = TextureRect.StretchMode.KeepAspectCentered, + // Forge Frontier: пробуем штуки, иконка не по центру. + MinSize = new(IconLarge, IconLarge), + HorizontalAlignment = HAlignment.Center, + VerticalAlignment = VAlignment.Center, + // Forge Frontier end + Margin = new(2) + }); + + return slot; + } + + private static Control MakeDescription(EntityPrototype? proto) + { + var full = proto?.Description ?? string.Empty; + if (string.IsNullOrWhiteSpace(full)) + return new Label { Text = string.Empty, }; + + var trimmed = TrimToChars(full, DescMaxChars); + + var msg = new FormattedMessage(); + msg.AddText(trimmed); + + var rtl = new RichTextLabel + { + HorizontalExpand = false, + VerticalExpand = false, + MaxWidth = TextMax, + ToolTip = full, + Modulate = Color.FromHex("#404040") + }; + rtl.SetMessage(msg); + return rtl; + } + + private static string TrimToChars(string text, int max) + { + if (max <= 0 || string.IsNullOrEmpty(text) || text.Length <= max) + return text; + + var cut = Math.Max(0, max - 1); + var span = text.AsSpan(0, cut); + var lastSpace = span.LastIndexOf(' '); + var end = lastSpace > 0 ? lastSpace : cut; + + return text.Substring(0, end) + "…"; + } + + // --- Кнопка цены --- + + private Control MakePriceButton(StoreListingData data, SpriteSystem sprites, bool actionsEnabled) + { + var btn = new Button + { + Text = string.Empty, + MinSize = new Vector2i(PriceW, PriceH), + MaxSize = new Vector2i(PriceW, PriceH), + ClipText = true, + Margin = new Thickness(0, 0, 20, 0), + StyleClasses = { StyleNano.StyleClassButtonBig, }, + Disabled = !actionsEnabled + || data.Remaining == 0 + || data.Mode == StoreMode.Sell && data.Owned <= 0 + || _maxQty <= 0 + }; + + if (!actionsEnabled) + btn.ToolTip = "Доступно только через массовую продажу ящика."; + + var inner = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + SeparationOverride = 6, + HorizontalExpand = true, + VerticalExpand = true + }; + + if (!string.IsNullOrEmpty(data.CurrencyId)) + { + if (TryGetCurrencyIcon(data.CurrencyId, sprites) is { } tex) + { + inner.AddChild( + new TextureRect + { + Texture = tex, + Stretch = TextureRect.StretchMode.KeepAspectCentered, + MinSize = new Vector2i(IconSmall, IconSmall), // Forge Frontier + MaxSize = new Vector2i(IconSmall, IconSmall), // Forge Frontier + Margin = new(2, 2, 0, 2), + VerticalAlignment = VAlignment.Center // Forge Frontier + }); + } + } + + _priceLbl = new() + { + Modulate = Color.FromHex("#0d0d0d"), + Text = data.Price.ToString(), + HorizontalExpand = true, + HorizontalAlignment = HAlignment.Center, + VerticalAlignment = VAlignment.Center + }; + inner.AddChild(_priceLbl); + + btn.AddChild(inner); + + btn.OnPressed += _ => + { + if (!actionsEnabled) + return; + + if (_maxQty <= 0 || _qty <= 0) + return; + + switch (data.Mode) + { + case StoreMode.Buy: + OnBuyPressed?.Invoke(_qty); + break; + case StoreMode.Sell: + OnSellPressed?.Invoke(_qty); + break; + } + }; + + return btn; + } + + // --- Количество и пересчёт цены --- + + private void SetQty(int v, StoreListingData data, Label qtyLbl) + { + var newQty = Math.Clamp(v, MinAllowed, Math.Max(MinAllowed, _maxQty)); + if (newQty == _qty) + return; + + _qty = newQty; + qtyLbl.Text = _qty.ToString(); + var text = _qty.ToString(); + + if (_qtyEdit.Text != text) + { + _suppressQtyEditChange = true; + _qtyEdit.Text = text; + _qtyEdit.CursorPosition = _qtyEdit.Text.Length; + _suppressQtyEditChange = false; + } + else + _qtyEdit.CursorPosition = _qtyEdit.Text.Length; + + UpdateTotal(data); + OnQtyChanged?.Invoke(_qty); + } + + private void UpdateTotal(StoreListingData data) + { + if (_priceLbl is null) + return; + + var value = _qty <= 0 ? data.Price : (long) data.Price * _qty; + _priceLbl.Text = value > MaxTotalDisplay ? $"{MaxTotalDisplay}+" : value.ToString(); + } +} diff --git a/Content.Client/_NC/Trade/NcStoreMenu.cs b/Content.Client/_NC/Trade/NcStoreMenu.cs new file mode 100644 index 000000000000..a55d92e8ab6e --- /dev/null +++ b/Content.Client/_NC/Trade/NcStoreMenu.cs @@ -0,0 +1,1127 @@ +using System.Linq; +using Content.Client.Message; +using Content.Client.Stylesheets; +using Content.Client.UserInterface.Controls; +using Content.Shared._NC.Trade; +using Content.Shared.Stacks; +using Robust.Client.AutoGenerated; +using Robust.Client.GameObjects; +using Robust.Client.Graphics; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Prototypes; +using Robust.Shared.Timing; + + +namespace Content.Client._NC.Trade; + + +[GenerateTypedNameReferences] +public sealed partial class NcStoreMenu : FancyWindow +{ + private const int SearchDebounceMs = 120; + private const int PageSize = 96; + private const string CrateCategory = "Готово к продаже в ящике"; + + private static readonly Color CatSelected = Color.FromHex("#1f1f1f"); + private static readonly Color CatIdle = Color.FromHex("#333333"); + private readonly Dictionary _balancesByCurrency = new(); + + private readonly Dictionary _buyCache = new(); + private readonly Dictionary _buyCatButtons = new(); + private readonly List _buyCats = new(); + private readonly List _items = new(); + + private readonly Dictionary _massSellTotals = new(); + private readonly IPrototypeManager _proto; + + private readonly Dictionary _qtyCache = new(); + private readonly Dictionary _searchIndex = new(); + private readonly Dictionary _sellCache = new(); + private readonly Dictionary _sellCatButtons = new(); + private readonly List _sellCats = new(); + private readonly SpriteSystem _sprites; + + private int _balance; + + private string _buyCat = string.Empty; + + private bool _disposed; + + private int _pageBuy = 1; + private int _pageSell = 1; + + private string _search = string.Empty; + private string _searchLower = string.Empty; + private int _searchToken; + private string _sellCat = string.Empty; + + public Action? OnContractClaim; + + public NcStoreMenu() + { + RobustXamlLoader.Load(this); + IoCManager.InjectDependencies(this); + + _sprites = IoCManager.Resolve().GetEntitySystem(); + _proto = IoCManager.Resolve(); + + SearchBar.OnTextChanged += _ => + { + if (_disposed) + return; + + _search = SearchBar.Text.Trim(); + _searchLower = _search.ToLowerInvariant(); + var token = ++_searchToken; + + Timer.Spawn( + TimeSpan.FromMilliseconds(SearchDebounceMs), + () => + { + if (token != _searchToken) + return; + + if (_disposed) + return; + + ResetPaging(); + OnSearchChanged?.Invoke(_search); + RefreshListings(); + }); + }; + + BalanceLabel.StyleClasses.Add(StyleNano.StyleClassLabelHeadingBigger); + BalanceInfo.SetMarkup("[font size=14][color=yellow]0[/color][/font]"); + + MassSellPulledCrateButton.Disabled = true; + MassSellPulledCrateButton.ClipText = true; // один раз, тут + MassSellPulledCrateButton.Text = "Продать содержимое"; + MassSellPulledCrateButton.ToolTip = + "Для массовой продажи необходимо:\n• Ящик должен быть ЗАКРЫТ\n• Вы должны его тянуть"; + MassSellPulledCrateButton.OnPressed += _ => OnMassSellPulledCrate?.Invoke(); + + } + + public event Action? OnSearchChanged; + public event Action? OnBuyPressed; + public event Action? OnSellPressed; + public event Action? OnMassSellPulledCrate; + + + public void SetBalance(int balance) + { + _balance = balance; + BalanceLabel.Text = balance.ToString(); + BalanceInfo.SetMarkup($"[font size=14][color=yellow]{balance}[/color][/font]"); + } + + private int GetBalanceForCurrency(string currencyId) + { + if (string.IsNullOrWhiteSpace(currencyId)) + return 0; + + return _balancesByCurrency.TryGetValue(currencyId, out var v) ? v : 0; + } + + public void SetBalancesByCurrency(Dictionary balances) + { + _balancesByCurrency.Clear(); + + foreach (var (cur, amt) in balances) + { + if (string.IsNullOrWhiteSpace(cur)) + continue; + + _balancesByCurrency[cur] = amt; + } + } + + public void SetMassSellTotals(Dictionary totals) + { + _massSellTotals.Clear(); + foreach (var (cur, amt) in totals) + _massSellTotals[cur] = amt; + MassSellPulledCrateButton.Text = "Продать содержимое"; + MassSellPulledCrateButton.ClipText = true; + + if (_massSellTotals.Count == 0) + { + MassSellPulledCrateButton.ToolTip = + "Для массовой продажи необходимо:\n• Ящик должен быть ЗАКРЫТ\n• Вы должны его тянуть"; + MassSellPulledCrateButton.Disabled = true; + return; + } + + MassSellPulledCrateButton.Disabled = false; + + var parts = _massSellTotals + .Where(p => p.Value > 0 && !string.IsNullOrWhiteSpace(p.Key)) + .Select(p => $"{p.Value} {CurrencyName(p.Key)}") + .ToList(); + + var full = parts.Count > 0 ? string.Join(", ", parts) : "—"; + + MassSellPulledCrateButton.ToolTip = + "Для массовой продажи необходимо:\n• Ящик должен быть ЗАКРЫТ\n• Вы должны его тянуть\n\n" + + $"Получите: {full}"; + } + + public void ApplyState( + int balance, + Dictionary balancesByCurrency, + List list, + Dictionary massTotals + ) + { + SetBalance(balance); + SetBalancesByCurrency(balancesByCurrency); + SetMassSellTotals(massTotals); + Populate(list); + } + + + public void Populate(List list) + { + _items.Clear(); + _items.AddRange(list); + + RebuildSearchIndex(); + + var ids = _items.Select(i => i.Id).ToHashSet(); + foreach (var key in _qtyCache.Keys.Where(k => !ids.Contains(k)).ToList()) + _qtyCache.Remove(key); + + foreach (var key in _buyCache.Keys.Where(k => !ids.Contains(k)).ToList()) + _buyCache.Remove(key); + foreach (var key in _sellCache.Keys.Where(k => !ids.Contains(k)).ToList()) + _sellCache.Remove(key); + + _buyCats.Clear(); + _sellCats.Clear(); + + + _buyCats.AddRange( + list.Where(i => i.Mode == StoreMode.Buy) + .Select(i => i.Category) + .Distinct() + .OrderBy(c => c)); + + _sellCats.AddRange( + list.Where(i => i.Mode == StoreMode.Sell) + .Select(i => i.Category) + .Distinct() + .OrderBy(c => c)); + + const string readyCat = "Готово к продаже"; + if (_items.Any(i => i.Mode == StoreMode.Sell && i.Category == readyCat)) + { + _sellCats.Remove(readyCat); + _sellCats.Insert(0, readyCat); + } + + if (!_buyCats.Contains(_buyCat)) + _buyCat = string.Empty; + if (!_sellCats.Contains(_sellCat)) + _sellCat = string.Empty; + + BuildCategoryButtons(); + ResetPaging(); + RefreshListings(); + } + + private void RebuildSearchIndex() + { + _searchIndex.Clear(); + + foreach (var protoId in _items.Select(i => i.ProductEntity).Distinct()) + { + if (string.IsNullOrWhiteSpace(protoId)) + continue; + + if (!_proto.TryIndex(protoId, out var p)) + continue; + + var name = p.Name; + var desc = p.Description; + _searchIndex[protoId] = (name + "\n" + desc).ToLowerInvariant(); + } + } + + public void PopulateContracts(List? list) + { + var contractList = ContractList; + + if (contractList == null) + return; + + contractList.RemoveAllChildren(); + if (list == null || list.Count == 0) + { + contractList.AddChild( + new Label + { + Text = "Контрактов пока нет. Загляните позже.", + HorizontalAlignment = HAlignment.Center, + Margin = new(0, 8, 0, 0) + }); + return; + } + + var ordered = list + .OrderBy(x => x.Difficulty switch + { + "Easy" => 0, + "Medium" => 1, + "Hard" => 2, + _ => 99 + }) + .ThenBy(x => x.Completed ? 1 : 0) + .ToList(); + + foreach (var c in ordered) + { + var panel = new PanelContainer + { + PanelOverride = new StyleBoxFlat + { + BackgroundColor = Color.FromHex("#131317"), + BorderColor = Color.FromHex("#2e2e2e"), + BorderThickness = new(1), + ContentMarginLeftOverride = 8, + ContentMarginRightOverride = 8, + ContentMarginTopOverride = 6, + ContentMarginBottomOverride = 6 + }, + Margin = new(4, 0, 4, 8) + }; + + var root = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Vertical, + HorizontalExpand = true + }; + panel.AddChild(root); + + // --- Заголовок --- + var header = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + HorizontalExpand = true, + Margin = new(4, 4, 4, 4) + }; + + var diffStrip = new PanelContainer + { + MinSize = new(4, 0), + VerticalExpand = true, + PanelOverride = new StyleBoxFlat + { + BackgroundColor = DifficultyColor(c.Difficulty, c.Completed) + }, + Margin = new(0, 0, 6, 0) + }; + header.AddChild(diffStrip); + + var titleText = string.IsNullOrWhiteSpace(c.Name) + ? $"{DifficultyName(c.Difficulty)} контракт" + : c.Name; + + var titleLabel = new Label + { + Text = titleText, + Margin = new(0, 0, 4, 0) + }; + titleLabel.StyleClasses.Add("LabelHeading"); + header.AddChild(titleLabel); + + if (c.Completed) + { + header.AddChild( + new Label + { + Text = "✔ Выполнено", + Margin = new(4, 0, 0, 0) + }); + } + + root.AddChild(header); + + // --- Описание --- + if (!string.IsNullOrWhiteSpace(c.Description)) + { + var desc = new Label + { + Text = c.Description, + Margin = new(0, 0, 0, 4) + }; + desc.StyleClasses.Add("LabelSubText"); + root.AddChild(desc); + } + + // --- Цели --- + var hasTargets = c.Targets.Count > 0; + + if (hasTargets) + { + root.AddChild( + new Label + { + Text = "Цели:", + Margin = new(0, 0, 0, 2) + }); + + var targets = c.Targets; + foreach (var t in targets) + { + EntityPrototype? targetProto = null; + if (!string.IsNullOrWhiteSpace(t.TargetItem)) + _proto.TryIndex(t.TargetItem, out targetProto); + + var targetRow = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + Margin = new(0, 0, 0, 2) + }; + + Texture? targetTex = null; + if (targetProto != null) + { + var icon = _sprites.GetPrototypeIcon(targetProto.ID); + targetTex = icon.Default; + } + + if (targetTex != null) + { + targetRow.AddChild( + new TextureRect + { + Texture = targetTex, + Stretch = TextureRect.StretchMode.KeepAspectCentered, + MinSize = new(48, 48), + Margin = new(0, 0, 4, 0) + }); + } + + var targetName = targetProto?.Name ?? t.TargetItem; + targetRow.AddChild( + new Label + { + Text = $"• {targetName} ×{t.Required} (у вас {t.Progress})" + }); + + root.AddChild(targetRow); + + if (!string.IsNullOrWhiteSpace(targetProto?.Description)) + { + var itemDesc = new Label + { + Text = targetProto.Description, + Margin = new(28, 0, 0, 2), + ClipText = true, + HorizontalExpand = true + }; + itemDesc.StyleClasses.Add("LabelSubText"); + root.AddChild(itemDesc); + } + } + } + else + { + // Legacy: одна цель как раньше. + EntityPrototype? targetProto = null; + if (!string.IsNullOrWhiteSpace(c.TargetItem)) + _proto.TryIndex(c.TargetItem, out targetProto); + + var targetRow = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + Margin = new(0, 0, 0, 2) + }; + + Texture? targetTex = null; + if (targetProto != null) + { + var icon = _sprites.GetPrototypeIcon(targetProto.ID); + targetTex = icon.Default; + } + + if (targetTex != null) + { + targetRow.AddChild( + new TextureRect + { + Texture = targetTex, + Stretch = TextureRect.StretchMode.KeepAspectCentered, + MinSize = new(24, 24), + Margin = new(0, 0, 4, 0) + }); + } + + var targetName = targetProto?.Name ?? c.TargetItem; + targetRow.AddChild( + new Label + { + Text = $"Цель: {targetName} ×{c.Required}" + }); + root.AddChild(targetRow); + + if (!string.IsNullOrWhiteSpace(targetProto?.Description)) + { + var itemDesc = new Label + { + Text = targetProto.Description, + ClipText = true, + HorizontalExpand = true, + Margin = new(0, 0, 0, 4) + }; + itemDesc.StyleClasses.Add("LabelSubText"); + root.AddChild(itemDesc); + } + } + + // --- Прогресс (суммарный) --- + if (!c.Completed) + { + var max = c.Required <= 0 ? 1 : c.Required; + var val = Math.Clamp(c.Progress, 0, max); + + root.AddChild( + new Label + { + Text = $"Прогресс: {c.Progress} / {c.Required}", + Margin = new(0, 0, 0, 2) + }); + + root.AddChild( + new ProgressBar + { + MinValue = 0, + MaxValue = max, + Value = val, + HorizontalExpand = true, + Margin = new(0, 0, 0, 4) + }); + } + + // --- Награды и кнопка --- + var bottom = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + HorizontalExpand = true, + Margin = new(0, 2, 0, 0) + }; + + var rewardsCol = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Vertical, + HorizontalExpand = true + }; + + // --- Награда: валюты (много) или fallback --- + if (c.RewardCurrencies is { Count: > 0, } currencies) + { + var rewardsRow = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + Margin = new(0, 0, 0, 2) + }; + + rewardsRow.AddChild( + new Label + { + Text = "Награда:", + Margin = new(0, 0, 4, 0) + }); + + var parts = new List(); + + foreach (var kv in currencies) + { + var curId = kv.Key; + var amount = kv.Value; + if (amount <= 0 || string.IsNullOrWhiteSpace(curId)) + continue; + + var name = CurrencyName(curId); + if (string.IsNullOrWhiteSpace(name)) + name = curId; + + parts.Add($"{amount} {name}"); + } + + rewardsRow.AddChild( + new Label + { + Text = string.Join(", ", parts) + }); + + rewardsCol.AddChild(rewardsRow); + } + else if (c.Reward > 0 && !string.IsNullOrWhiteSpace(c.RewardCurrency)) + { + var rewardRow = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + Margin = new(0, 0, 0, 2) + }; + + Texture? currencyTex = null; + if (_proto.TryIndex(c.RewardCurrency, out var stackProto) && + _proto.TryIndex(stackProto.Spawn, out var currencyEnt)) + { + var icon = _sprites.GetPrototypeIcon(currencyEnt.ID); + currencyTex = icon.Default; + } + + if (currencyTex != null) + { + rewardRow.AddChild( + new TextureRect + { + Texture = currencyTex, + Stretch = TextureRect.StretchMode.KeepAspectCentered, + MinSize = new(16, 16), + Margin = new(0, 0, 4, 0) + }); + } + + var currencyName = CurrencyName(c.RewardCurrency); + rewardRow.AddChild( + new Label + { + Text = string.IsNullOrEmpty(currencyName) + ? $"Награда: {c.Reward}" + : $"Награда: {c.Reward} {currencyName}" + }); + + rewardsCol.AddChild(rewardRow); + } + + // --- Награда: предметы (много) или fallback --- + if (c.RewardItems is { Count: > 0, } items) + { + var itemsCol = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Vertical, + Margin = new(0, 2, 0, 0) + }; + + itemsCol.AddChild( + new Label + { + Text = "Предметы награды:", + Margin = new(0, 0, 0, 2) + }); + + foreach (var kv in items) + { + var id = kv.Key; + var count = kv.Value; + if (count <= 0 || string.IsNullOrWhiteSpace(id)) + continue; + + EntityPrototype? proto; + _proto.TryIndex(id, out proto); + + var line = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + Margin = new(0, 0, 0, 2) + }; + + Texture? tex = null; + if (proto != null) + { + var icon = _sprites.GetPrototypeIcon(proto.ID); + tex = icon.Default; + } + + if (tex != null) + { + line.AddChild( + new TextureRect + { + Texture = tex, + Stretch = TextureRect.StretchMode.KeepAspectCentered, + MinSize = new(20, 20), + Margin = new(0, 0, 4, 0) + }); + } + + var name = proto?.Name ?? id; + line.AddChild(new Label { Text = $"{name} ×{count}", }); + + itemsCol.AddChild(line); + } + + rewardsCol.AddChild(itemsCol); + } + else if (!string.IsNullOrWhiteSpace(c.RewardItem) && c.RewardItemCount > 0) + { + EntityPrototype? rewardProto; + _proto.TryIndex(c.RewardItem, out rewardProto); + + var rewardItemRow = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + Margin = new(0, 2, 0, 0) + }; + + Texture? rewardTex = null; + if (rewardProto != null) + { + var icon = _sprites.GetPrototypeIcon(rewardProto.ID); + rewardTex = icon.Default; + } + + if (rewardTex != null) + { + rewardItemRow.AddChild( + new TextureRect + { + Texture = rewardTex, + Stretch = TextureRect.StretchMode.KeepAspectCentered, + MinSize = new(20, 20), + Margin = new(0, 0, 4, 0) + }); + } + + var rewardName = rewardProto?.Name ?? c.RewardItem; + rewardItemRow.AddChild( + new Label + { + Text = $"Предмет: {rewardName} ×{c.RewardItemCount}" + }); + + rewardsCol.AddChild(rewardItemRow); + } + + bottom.AddChild(rewardsCol); + + // --- Кнопка сдачи --- + var canClaim = c.Completed; + var btn = new Button + { + Text = canClaim ? "Забрать" : $"Сдать ({c.Progress}/{c.Required})", + Disabled = !canClaim, + MinSize = new(150, 24), + Margin = new(8, 0, 0, 0) + }; + + if (!canClaim) + btn.ToolTip = "Контракт ещё не выполнен."; + + btn.OnPressed += _ => + { + if (!canClaim) + return; + + OnContractClaim?.Invoke(c.Id); + }; + + bottom.AddChild(btn); + root.AddChild(bottom); + + contractList.AddChild(panel); + } + } + + + private void RefreshListings() + { + var hasSearch = !string.IsNullOrWhiteSpace(_search); + var buyFiltered = Filtered(StoreMode.Buy, _buyCat).ToList(); + var sellFiltered = Filtered(StoreMode.Sell, _sellCat).ToList(); + + var buyCount = buyFiltered.Count; + var sellCount = sellFiltered.Count; + + BuyCategoryHeader.Visible = !string.IsNullOrEmpty(_buyCat) || hasSearch; + SellCategoryHeader.Visible = !string.IsNullOrEmpty(_sellCat) || hasSearch; + + if (BuyCategoryHeader.Visible) + { + BuyCategoryHeader.Text = !string.IsNullOrEmpty(_buyCat) + ? _buyCat + : $"Результаты поиска ({buyCount})"; + } + else + BuyCategoryHeader.Text = string.Empty; + + if (SellCategoryHeader.Visible) + { + SellCategoryHeader.Text = !string.IsNullOrEmpty(_sellCat) + ? _sellCat + : $"Результаты поиска ({sellCount})"; + } + else + SellCategoryHeader.Text = string.Empty; + + FillPaneFull( + BuyListingsContainer, + StoreMode.Buy, + _buyCat, + buyFiltered, + (d, qty) => OnBuyPressed?.Invoke(d, qty)); + + FillPaneFull( + SellListingsContainer, + StoreMode.Sell, + _sellCat, + sellFiltered, + (d, qty) => OnSellPressed?.Invoke(d, qty)); + } + + private IEnumerable Filtered(StoreMode mode, string cat) + { + var q = _items.Where(i => i.Mode == mode); + + var hasCat = !string.IsNullOrEmpty(cat); + var hasSearch = !string.IsNullOrWhiteSpace(_searchLower); + + if (!hasCat && !hasSearch) + return Enumerable.Empty(); + + if (hasCat) + q = q.Where(i => i.Category == cat); + + if (hasSearch) + q = q.Where(i => MatchesSearch(i.ProductEntity)); + + return q; + } + + private void FillPaneFull( + Control pane, + StoreMode mode, + string cat, + List filtered, + Action emit + ) + { + pane.Children.Clear(); + + var hasCat = !string.IsNullOrEmpty(cat); + var hasSearch = !string.IsNullOrWhiteSpace(_searchLower); + + if (!hasCat && !hasSearch) + { + pane.AddChild(new Label { Text = "Выберите категорию.", }); + return; + } + + if (filtered.Count == 0) + { + string message; + + if (!string.IsNullOrEmpty(cat)) + message = "По вашему запросу в этой категории ничего не найдено."; + else + message = "По вашему запросу ничего не найдено."; + + pane.AddChild(new Label { Text = message, }); + return; + } + + var page = mode == StoreMode.Buy ? _pageBuy : _pageSell; + var take = Math.Min(filtered.Count, PageSize * page); + + AddListingRange(pane, mode, filtered, 0, take, emit); + + AddOrUpdateMoreButton(pane, mode, filtered.Count, take, cat, emit); + } + + + private void AppendNextPage(Control pane, StoreMode mode, string cat, Action emit) + { + var filtered = Filtered(mode, cat).ToList(); + var page = mode == StoreMode.Buy ? _pageBuy : _pageSell; + var take = Math.Min(filtered.Count, PageSize * page); + + var already = 0; + foreach (var ch in pane.Children) + if (ch is NcStoreListingControl) + already++; + + var toAddStart = already; + var toAddEnd = Math.Min(take, filtered.Count); + + if (toAddStart < toAddEnd) + AddListingRange(pane, mode, filtered, toAddStart, toAddEnd, emit); + + RemoveMoreButtons(pane); + AddOrUpdateMoreButton(pane, mode, filtered.Count, take, cat, emit); + } + + private void AddListingRange( + Control pane, + StoreMode mode, + List source, + int startExclusive, + int endExclusive, + Action emit + ) + { + var cache = mode == StoreMode.Buy ? _buyCache : _sellCache; + + for (var i = startExclusive; i < endExclusive; i++) + { + var it = source[i]; + + var balanceHint = mode == StoreMode.Buy + ? GetBalanceForCurrency(it.CurrencyId) + : int.MaxValue; + + var sig = Sig(it, balanceHint); + + NcStoreListingControl ctrl; + if (cache.TryGetValue(it.Id, out var tuple) && tuple.Sig == sig) + ctrl = tuple.Ctrl; + else + { + var initQty = _qtyCache.TryGetValue(it.Id, out var saved) ? saved : 1; + + var actionsEnabled = true; + + ctrl = new(it, _sprites, balanceHint, initQty, actionsEnabled); + ctrl.OnQtyChanged += newQty => _qtyCache[it.Id] = newQty; + + switch (mode) + { + case StoreMode.Buy: + ctrl.OnBuyPressed += qty => emit(it, qty); + break; + case StoreMode.Sell: + ctrl.OnSellPressed += qty => emit(it, qty); + break; + } + + cache[it.Id] = (ctrl, sig); + } + + pane.AddChild(ctrl); + + if (i < endExclusive - 1) + { + pane.AddChild( + new PanelContainer + { + MinSize = new Vector2i(0, 1), + StyleClasses = { "LowDivider", } + }); + } + } + } + + + private static void RemoveMoreButtons(Control pane) + { + foreach (var ch in pane.Children.ToList()) + if (ch is Button b && b.Text != null && + b.Text.StartsWith("Показать ещё (", StringComparison.Ordinal)) + pane.RemoveChild(b); + } + + private void AddOrUpdateMoreButton( + Control pane, + StoreMode mode, + int totalCount, + int shown, + string cat, + Action emit + ) + { + if (shown >= totalCount) + return; + + var left = totalCount - shown; + + var more = new Button + { + Text = $"Показать ещё ({left})", + HorizontalExpand = true, + Margin = new(0, 8, 0, 8) + }; + + more.OnPressed += _ => + { + if (mode == StoreMode.Buy) + _pageBuy++; + else + _pageSell++; + + AppendNextPage(pane, mode, cat, emit); + }; + + pane.AddChild(more); + } + + private void BuildCategoryButtons() + { + _buyCatButtons.Clear(); + _sellCatButtons.Clear(); + + MakeButtons( + _buyCats, + BuyCategoryListContainer, + _buyCat, + _buyCatButtons, + cat => + { + _buyCat = _buyCat == cat ? string.Empty : cat; + UpdateCatVisuals(_buyCatButtons, _buyCat); + ResetPaging(); + RefreshListings(); + }); + + MakeButtons( + _sellCats, + SellCategoryListContainer, + _sellCat, + _sellCatButtons, + cat => + { + _sellCat = _sellCat == cat ? string.Empty : cat; + UpdateCatVisuals(_sellCatButtons, _sellCat); + ResetPaging(); + RefreshListings(); + }); + + UpdateCatVisuals(_buyCatButtons, _buyCat); + UpdateCatVisuals(_sellCatButtons, _sellCat); + } + + private static void MakeButtons( + IEnumerable cats, + Control parent, + string current, + Dictionary registry, + Action onClick + ) + { + parent.Children.Clear(); + + foreach (var c in cats) + { + var catId = c; + + var display = c; + if (c == "Готово к продаже") + display = "Готово"; + else if (c == CrateCategory) + display = "В ящике"; + + var selected = catId == current; + + var btn = new Button + { + Text = display, + ToggleMode = true, + HorizontalExpand = true, + Pressed = selected, + ModulateSelfOverride = selected ? CatSelected : CatIdle, + ToolTip = c + }; + + btn.OnMouseEntered += _ => + btn.ModulateSelfOverride = + btn.Pressed ? Brighten(CatSelected, 1.2f) : Brighten(CatIdle, 1.2f); + btn.OnMouseExited += _ => + btn.ModulateSelfOverride = btn.Pressed ? CatSelected : CatIdle; + + btn.OnPressed += _ => onClick(catId); + + parent.AddChild(btn); + registry[catId] = btn; + } + } + + + private string CurrencyName(string? currencyId) + { + if (string.IsNullOrWhiteSpace(currencyId)) + return string.Empty; + + if (_proto.TryIndex(currencyId, out var stackProto) && + _proto.TryIndex(stackProto.Spawn, out var currencyEnt)) + return currencyEnt.Name; + + return currencyId; + } + + private Color DifficultyColor(string diff, bool completed) + { + var baseColor = diff switch + { + "Easy" => Color.FromHex("#4CAF50"), + "Medium" => Color.FromHex("#FFC107"), + "Hard" => Color.FromHex("#F44336"), + _ => Color.FromHex("#9E9E9E") + }; + + return completed ? Brighten(baseColor, 0.7f) : baseColor; + } + + private string DifficultyName(string diff) => + diff switch + { + "Easy" => "Лёгкий", + "Medium" => "Средний", + "Hard" => "Тяжёлый", + _ => diff + }; + + private static void UpdateCatVisuals(Dictionary map, string current) + { + foreach (var (name, btn) in map) + { + var selected = name == current; + btn.Pressed = selected; + btn.ModulateSelfOverride = selected ? CatSelected : CatIdle; + } + } + + private static Color Brighten(Color c, float f) => + new(MathF.Min(c.R * f, 1f), MathF.Min(c.G * f, 1f), MathF.Min(c.B * f, 1f), c.A); + + private void ResetPaging() + { + _pageBuy = 1; + _pageSell = 1; + } + + [Obsolete("Controls should only be removed from UI tree instead of being disposed")] + protected override void Dispose(bool disposing) + { + _disposed = true; + base.Dispose(disposing); + } + + private static string Sig(StoreListingData d, int balance) => + d.Mode == StoreMode.Buy + ? $"{d.Id}|{d.ProductEntity}|{d.Category}|{d.Price}|{d.Remaining}|{d.Owned}|{d.CurrencyId}|B{balance}" + : $"{d.Id}|{d.ProductEntity}|{d.Category}|{d.Price}|{d.Remaining}|{d.Owned}|{d.CurrencyId}"; + + private bool MatchesSearch(string protoId) + { + if (string.IsNullOrWhiteSpace(protoId)) + return false; + + if (string.IsNullOrWhiteSpace(_searchLower)) + return true; + + if (_searchIndex.TryGetValue(protoId, out var hay)) + return hay.Contains(_searchLower, StringComparison.Ordinal); + + if (!_proto.TryIndex(protoId, out var p)) + return false; + + var name = p.Name; + var desc = p.Description; + var combined = (name + "\n" + desc).ToLowerInvariant(); + _searchIndex[protoId] = combined; + return combined.Contains(_searchLower, StringComparison.Ordinal); + } +} diff --git a/Content.Client/_NC/Trade/NcStoreMenu.xaml b/Content.Client/_NC/Trade/NcStoreMenu.xaml new file mode 100644 index 000000000000..9c9cef47ce5a --- /dev/null +++ b/Content.Client/_NC/Trade/NcStoreMenu.xaml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +