Trading mod: shops as data and the trade window

This commit is contained in:
cthangover committed 2026-10-23 09:30:05 +03:00
commit 89ee23f1e6
19 files changed
+1595

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
using System;
using Cthangover.Core.Factories;
namespace Cthangover.Mods.Trading
{
/// <summary>Shops collected by the shared entity resolver (<c>"Kind": "shop"</c>).</summary>
public class ShopFactory : FileFactory<ShopInfo>
{
private static readonly Lazy<ShopFactory> instance = new(() => new ShopFactory());
/// <summary>Shared instance.</summary>
public static ShopFactory Instance => instance.Value;
}
}
+57
View File
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Cthangover.Core.Factories;
namespace Cthangover.Mods.Trading
{
/// <summary>
/// A merchant as data (<c>"Kind": "shop"</c>), opened by
/// <c>action trade.open shop=&lt;id&gt;</c>. The stock of <see cref="Sells"/> is
/// unlimited and the shop has no state of its own: what the player sells
/// disappears into the merchant's pocket, so nothing needs saving except the
/// player's inventory. Money is an ordinary inventory item
/// (<see cref="Currency"/>), which keeps it in the items save slot and lets
/// scenarios pay with <c>item.add</c>.
/// </summary>
[EntityKind("shop")]
public class ShopInfo : IIdentifiable
{
/// <summary>Currency item used when a shop does not name one.</summary>
public const string DefaultCurrency = "money/coin";
/// <summary>Shop id, the <c>shop=</c> argument of <c>trade.open</c>.</summary>
[JsonPropertyName("Id")]
public string ID { get; set; }
/// <summary>Locale key of the window title (the merchant's name).</summary>
public string Name { get; set; }
/// <summary>Item id of the money this shop takes and pays.</summary>
public string Currency { get; set; } = DefaultCurrency;
/// <summary>What the merchant sells to the player, in display order.</summary>
public List<ShopOffer> Sells { get; set; } = new();
/// <summary>What the merchant buys from the player; anything else is refused.</summary>
public List<ShopOffer> Buys { get; set; } = new();
/// <summary>The offer for buying <paramref name="itemId"/> from the merchant, or <c>null</c>.</summary>
public ShopOffer FindSell(string itemId) => Find(Sells, itemId);
/// <summary>The offer for selling <paramref name="itemId"/> to the merchant, or <c>null</c>.</summary>
public ShopOffer FindBuy(string itemId) => Find(Buys, itemId);
private static ShopOffer Find(List<ShopOffer> offers, string itemId)
{
if (offers == null || string.IsNullOrEmpty(itemId))
return null;
foreach (var offer in offers)
{
if (offer != null && offer.Price > 0 && string.Equals(offer.Item, itemId, StringComparison.Ordinal))
return offer;
}
return null;
}
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Cthangover.Mods.Trading
{
/// <summary>
/// One line of a shop's price list: an item id and its unit price in the
/// shop's currency. The same type serves both directions — in
/// <see cref="ShopInfo.Sells"/> it is what the player pays, in
/// <see cref="ShopInfo.Buys"/> what the merchant pays. An offer with a price
/// below 1 is ignored by <see cref="TradeLedger"/>: a free deal is a gift and
/// belongs in a scenario.
/// </summary>
public class ShopOffer
{
/// <summary>Item id (<c>"Kind": "item"</c>).</summary>
public string Item { get; set; }
/// <summary>Unit price in the shop's currency.</summary>
public int Price { get; set; }
}
}
+64
View File
@@ -0,0 +1,64 @@
using Cthangover.Core.Items;
namespace Cthangover.Mods.Trading
{
/// <summary>
/// The arithmetic of a deal, apart from the window so it can be tested and
/// reused by a scenario action later. A deal is all or nothing: it is checked
/// first (offer exists, positive quantity, enough money or goods) and only
/// then both stacks change, so a refused deal leaves the inventory untouched.
/// </summary>
public static class TradeLedger
{
/// <summary>The player's money in the shop's currency.</summary>
public static int Money(IInventory inventory, ShopInfo shop)
{
return inventory == null || shop == null ? 0 : inventory.CheckCount(shop.Currency);
}
/// <summary>How many units of <paramref name="itemId"/> the player can afford; 0 when the shop does not sell it.</summary>
public static int MaxBuy(IInventory inventory, ShopInfo shop, string itemId)
{
var offer = shop?.FindSell(itemId);
return offer == null ? 0 : Money(inventory, shop) / offer.Price;
}
/// <summary>How many units of <paramref name="itemId"/> the player can sell; 0 when the merchant does not buy it.</summary>
public static int MaxSell(IInventory inventory, ShopInfo shop, string itemId)
{
if (inventory == null || shop?.FindBuy(itemId) == null || itemId == shop.Currency)
return 0;
return inventory.CheckCount(itemId);
}
/// <summary>
/// Buys <paramref name="count"/> units: takes their price in money and adds
/// the items. Returns <c>false</c> and changes nothing when the shop does not
/// sell the item, the count is not positive or the money is short.
/// </summary>
public static bool Buy(IInventory inventory, ShopInfo shop, string itemId, int count)
{
if (count <= 0 || count > MaxBuy(inventory, shop, itemId))
return false;
if (!inventory.Remove(shop.Currency, shop.FindSell(itemId).Price * count))
return false;
inventory.Add(itemId, count);
return true;
}
/// <summary>
/// Sells <paramref name="count"/> units: removes the items and pays their
/// price. Returns <c>false</c> and changes nothing when the merchant does not
/// buy the item, the count is not positive or the player has fewer units.
/// </summary>
public static bool Sell(IInventory inventory, ShopInfo shop, string itemId, int count)
{
if (count <= 0 || count > MaxSell(inventory, shop, itemId))
return false;
if (!inventory.Remove(itemId, count))
return false;
inventory.Add(shop.Currency, shop.FindBuy(itemId).Price * count);
return true;
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using Cthangover.Core.Actions;
using Cthangover.Core.UI;
namespace Cthangover.Mods.Trading
{
/// <summary>
/// <c>action trade.open shop=village_merchant</c> — opens the trade window with
/// that merchant. Usually the last line of a merchant's dialog. The window is a
/// hosted overlay of the UI registry (it lives above every scene and is built
/// once), so the action hands it the shop and then shows it through the
/// registry, which keeps the host's record of open entries right.
/// </summary>
public class TradeOpenAction : IScenarioAction
{
/// <summary>DSL name.</summary>
public string Name => "trade.open";
/// <summary>Resolves the shop and shows the window for it.</summary>
public void Run(IActionContext ctx)
{
var id = ctx.GetParam("shop");
var shop = string.IsNullOrWhiteSpace(id) ? null : ShopFactory.Instance.Get(id.Trim());
if (shop == null)
{
ctx.LogWarning("TRADE", $"trade.open: unknown shop '{id}'");
return;
}
var entry = UiRegistry.Get(TradePanel.EntryId);
if (entry == null || UiHost.Instance?.EnsureInstance(entry) is not TradePanel panel)
{
ctx.LogWarning("TRADE", "trade.open: the trade window is not available");
return;
}
panel.SetShop(shop);
if (!panel.Visible)
UiRegistry.Toggle(TradePanel.EntryId);
}
}
}
+553
View File
@@ -0,0 +1,553 @@
namespace Cthangover.Mods.Trading
{
using System.Collections.Generic;
using Cthangover.Core.Audio;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Items;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Inventory;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// Trade window: two inventories side by side, the merchant's goods on the left
/// and the player's bag on the right, with a deal card between them. It follows
/// the inventory panel (same cells, same frame, same passport idea), so trading
/// feels like a second bag rather than a new screen.
/// <para/>
/// The window is a hosted "once" overlay (<see cref="EntryId"/> in the manifest),
/// built on first use and reused for every merchant: <see cref="TradeOpenAction"/>
/// hands it a <see cref="ShopInfo"/> and shows it. Clicking a cell selects the item
/// and fills the deal card with its price, a quantity slider limited by money (when
/// buying) or by the stack (when selling) and the deal button. The arithmetic is
/// <see cref="TradeLedger"/>; the window only redraws on
/// <see cref="IInventory.Change"/>, so it also follows changes made elsewhere.
/// The money item is hidden from the player's grid and shown in the header.
/// <para/>
/// The inventory subscription is made on show and dropped on hide, against the
/// inventory instance of that moment: a new game or a load replaces the runtime
/// and its inventory while this window lives on.
/// </summary>
public partial class TradePanel : Widget
{
/// <summary>Id of the UI registry entry (manifest <c>ui</c>).</summary>
public const string EntryId = "trade";
private enum Side
{
None,
Merchant,
Player,
}
private const int Columns = 4;
private const float CellGap = 10f;
private const float DealWidth = 400f;
private static readonly List<ShopOffer> NoOffers = new();
private readonly List<InventoryItemBehaviour> _merchantCells = new();
private readonly List<InventoryItemBehaviour> _playerCells = new();
private ShopInfo _shop;
private IInventory _inventory;
private Side _side = Side.None;
private string _selectedId;
private int _quantity = 1;
private string _message;
private bool _messageOk;
private Label _title;
private TextureRect _walletIcon;
private Label _walletLabel;
private Button _closeButton;
private Label _merchantTitle;
private Label _playerTitle;
private Label _footer;
private Control _merchantGrid;
private Control _playerGrid;
private VBoxContainer _dealCard;
private Label _quantityLabel;
private Label _totalLabel;
/// <summary>
/// Sets the merchant to trade with. Called before the window is shown; if it is
/// already open (another merchant's dialog ended in it), it switches at once.
/// </summary>
public void SetShop(ShopInfo shop)
{
_shop = shop;
ResetSelection();
if (Visible)
RebuildAll();
}
protected override void OnceConstruct()
{
MouseFilter = MouseFilterEnum.Stop;
_title = GetNode<Label>("Panel/Margin/Root/Header/Title");
_walletIcon = GetNode<TextureRect>("Panel/Margin/Root/Header/Wallet/WalletIcon");
_walletLabel = GetNode<Label>("Panel/Margin/Root/Header/Wallet/WalletLabel");
_closeButton = GetNode<Button>("Panel/Margin/Root/Header/CloseButton");
_merchantTitle = GetNode<Label>("Panel/Margin/Root/Body/Merchant/MerchantTitle");
_merchantGrid = GetNode<Control>("Panel/Margin/Root/Body/Merchant/MerchantScroll/MerchantGrid");
_playerTitle = GetNode<Label>("Panel/Margin/Root/Body/Player/PlayerTitle");
_playerGrid = GetNode<Control>("Panel/Margin/Root/Body/Player/PlayerScroll/PlayerGrid");
_dealCard = GetNode<VBoxContainer>("Panel/Margin/Root/Body/Deal/DealScroll/DealCard");
_footer = GetNode<Label>("Panel/Margin/Root/Footer");
ApplyTheme();
_closeButton.Pressed += OnClosePressed;
}
protected override void ShowConstruct()
{
if (_inventory != null)
_inventory.Change -= OnInventoryChanged;
_inventory = GameData.Instance.Runtime.Inventory;
_inventory.Change += OnInventoryChanged;
if (_side == Side.None)
SelectFirstOffer();
RebuildAll();
}
protected override void HideDestruct()
{
if (_inventory != null)
_inventory.Change -= OnInventoryChanged;
_inventory = null;
ClearCells(_merchantCells);
ClearCells(_playerCells);
ClearChildren(_dealCard);
ResetSelection();
}
protected override void OnInput(InputEvent @event)
{
if (Visible && @event.IsActionPressed("ui_cancel"))
{
OnClosePressed();
GetViewport().SetInputAsHandled();
}
}
private void ApplyTheme()
{
UiKit.ApplyPanelBackdrop(GetNode<ColorRect>("Backdrop"));
var panelStyle = UiKit.Frame(8, UiTokens.Surface0, UiTokens.Line);
panelStyle.ShadowColor = new Color(0f, 0f, 0f, 0.6f);
panelStyle.ShadowSize = 18;
panelStyle.ShadowOffset = new Vector2(0f, 8f);
GetNode<Panel>("Panel").AddThemeStyleboxOverride("panel", panelStyle);
GetNode<Panel>("Panel/Margin/Root/Rule").AddThemeStyleboxOverride("panel", UiKit.Frame(0, UiTokens.Line));
var dealStyle = UiKit.Frame(6, UiTokens.SideBg, UiTokens.Line);
dealStyle.ContentMarginLeft = 18;
dealStyle.ContentMarginRight = 18;
dealStyle.ContentMarginTop = 16;
dealStyle.ContentMarginBottom = 16;
GetNode<PanelContainer>("Panel/Margin/Root/Body/Deal").AddThemeStyleboxOverride("panel", dealStyle);
_title.AddThemeColorOverride("font_color", UiTokens.InkBright);
_walletLabel.AddThemeColorOverride("font_color", UiTokens.AccentBright);
_merchantTitle.AddThemeColorOverride("font_color", UiTokens.Muted);
_playerTitle.AddThemeColorOverride("font_color", UiTokens.Muted);
_footer.AddThemeColorOverride("font_color", UiTokens.Faint);
CthangoverTheme.ApplyIcon(_closeButton);
_merchantTitle.Text = Tr("trading/ui/merchant_goods").ToUpperInvariant();
_playerTitle.Text = Tr("trading/ui/your_bag").ToUpperInvariant();
_footer.Text = Tr("trading/ui/hint");
}
private void RebuildAll()
{
if (_shop == null || _inventory == null)
return;
_title.Text = Tr(_shop.Name ?? _shop.ID).ToUpperInvariant();
_walletIcon.Texture = ItemFactory.Instance.Get(_shop.Currency)?.Sprite;
_walletLabel.Text = TradeLedger.Money(_inventory, _shop).ToString();
var cellSize = CalculateCellSize();
ClearCells(_merchantCells);
foreach (var offer in _shop.Sells ?? NoOffers)
{
var item = offer == null ? null : ItemFactory.Instance.Get(offer.Item);
if (item == null || offer.Price <= 0)
continue;
var cell = AddCell(_merchantGrid, _merchantCells, new ItemContainer { Item = item, Count = 1 }, cellSize);
cell.Decorate(PriceTag(offer.Price), TradeLedger.MaxBuy(_inventory, _shop, item.ID) == 0);
cell.Clicked += OnMerchantCellClicked;
}
FitGrid(_merchantGrid, _merchantCells);
ClearCells(_playerCells);
foreach (var container in _inventory.Items)
{
if (container?.Item == null || container.Item.ID == _shop.Currency)
continue;
var cell = AddCell(_playerGrid, _playerCells, container, cellSize);
var offer = _shop.FindBuy(container.Item.ID);
cell.Decorate(offer == null ? null : PriceTag(offer.Price), offer == null);
cell.Clicked += OnPlayerCellClicked;
}
FitGrid(_playerGrid, _playerCells);
if (_side == Side.Player && _inventory.CheckCount(_selectedId) == 0)
ResetSelectionKeepMessage();
MarkSelected();
BuildDeal();
}
private InventoryItemBehaviour AddCell(Control grid, List<InventoryItemBehaviour> cells, IItemContainer container, float cellSize)
{
var cell = new InventoryItemBehaviour();
cell.Construct(container);
grid.AddChild(cell);
var index = cells.Count;
cell.Position = new Vector2(index % Columns * (cellSize + CellGap), index / Columns * (cellSize + CellGap));
cell.Size = new Vector2(cellSize, cellSize);
cells.Add(cell);
return cell;
}
private static void FitGrid(Control grid, List<InventoryItemBehaviour> cells)
{
if (cells.Count == 0)
{
grid.CustomMinimumSize = Vector2.Zero;
return;
}
var last = cells[cells.Count - 1];
grid.CustomMinimumSize = new Vector2(0, last.Position.Y + last.Size.Y + CellGap);
}
private float CalculateCellSize()
{
var viewportWidth = GetViewportRect().Size.X;
if (viewportWidth <= 0)
viewportWidth = 1920f;
var gridWidth = (viewportWidth - 80f - 48f - DealWidth - 28f) / 2f;
return Mathf.Clamp((gridWidth - (Columns - 1) * CellGap - 14f) / Columns, 64f, 150f);
}
private void MarkSelected()
{
foreach (var cell in _merchantCells)
cell.SetSelected(_side == Side.Merchant && cell.Model.Item.ID == _selectedId);
foreach (var cell in _playerCells)
cell.SetSelected(_side == Side.Player && cell.Model.Item.ID == _selectedId);
}
private void BuildDeal()
{
ClearChildren(_dealCard);
_quantityLabel = null;
_totalLabel = null;
var item = _side == Side.None ? null : ItemFactory.Instance.Get(_selectedId);
if (item == null)
{
_dealCard.AddChild(UiKit.EmptyState(Tr("trading/ui/select"), 14));
AddMessage();
return;
}
var buying = _side == Side.Merchant;
var offer = buying ? _shop.FindSell(item.ID) : _shop.FindBuy(item.ID);
var max = buying ? TradeLedger.MaxBuy(_inventory, _shop, item.ID) : TradeLedger.MaxSell(_inventory, _shop, item.ID);
_quantity = Mathf.Clamp(_quantity, 1, Mathf.Max(1, max));
var header = UiKit.Row(16);
var iconFrame = new PanelContainer { MouseFilter = MouseFilterEnum.Ignore };
iconFrame.AddThemeStyleboxOverride("panel", UiKit.Frame(8, UiTokens.Surface1, UiTokens.LineSoft));
iconFrame.AddChild(UiKit.Icon(item.Sprite, 88));
header.AddChild(iconFrame);
var titles = UiKit.Column(8);
titles.SizeFlagsHorizontal = SizeFlags.ExpandFill;
var name = UiKit.Label(Tr(item.Name), 22, UiTokens.InkBright);
name.AutowrapMode = TextServer.AutowrapMode.WordSmart;
titles.AddChild(name);
var badge = UiKit.Badge(Tr(buying ? "trading/ui/badge_merchant" : "trading/ui/badge_yours"),
buying ? UiTokens.Accent : UiTokens.Ok);
badge.SizeFlagsHorizontal = SizeFlags.ShrinkBegin;
titles.AddChild(badge);
header.AddChild(titles);
_dealCard.AddChild(header);
var description = UiKit.Label(Tr(item.Description), 14, UiTokens.Body);
description.AutowrapMode = TextServer.AutowrapMode.WordSmart;
_dealCard.AddChild(description);
var stats = UiKit.Row(8);
if (offer != null)
stats.AddChild(UiKit.StatChip(Tr("trading/ui/unit_price"), offer.Price.ToString(), UiTokens.AccentBright));
stats.AddChild(buying
? UiKit.StatChip(Tr("trading/ui/wallet"), TradeLedger.Money(_inventory, _shop).ToString())
: UiKit.StatChip(Tr("trading/ui/you_have"), _inventory.CheckCount(item.ID).ToString()));
_dealCard.AddChild(stats);
if (offer == null || max == 0)
{
var reason = offer == null ? "trading/ui/not_bought" : (buying ? "trading/ui/no_money" : "trading/ui/nothing_to_sell");
var hint = UiKit.Label(Tr(reason), 13, UiTokens.Muted);
hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
_dealCard.AddChild(hint);
AddMessage();
return;
}
_dealCard.AddChild(BuildQuantity(max));
_totalLabel = UiKit.Label(string.Empty, 18, UiTokens.AccentBright);
_dealCard.AddChild(_totalLabel);
UpdateQuantityLabels(max, offer.Price);
var deal = MakeButton(Tr(buying ? "trading/ui/buy" : "trading/ui/sell"), true);
deal.CustomMinimumSize = new Vector2(0, 44);
deal.Pressed += OnDealPressed;
_dealCard.AddChild(deal);
AddMessage();
}
private Control BuildQuantity(int max)
{
var column = UiKit.Column(8);
var head = UiKit.Row(8);
var caption = UiKit.Label(Tr("trading/ui/quantity"), 12, UiTokens.Muted);
caption.SizeFlagsHorizontal = SizeFlags.ExpandFill;
head.AddChild(caption);
_quantityLabel = UiKit.Label(string.Empty, 12, UiTokens.AccentBright);
head.AddChild(_quantityLabel);
column.AddChild(head);
var slider = new HSlider
{
MinValue = 1,
MaxValue = Mathf.Max(1, max),
Step = 1,
Value = _quantity,
Editable = max > 1,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
FocusMode = FocusModeEnum.None,
};
slider.ValueChanged += OnSliderChanged;
column.AddChild(slider);
var buttons = UiKit.Row(6);
buttons.AddChild(MakeSmallButton("−", false));
buttons.AddChild(MakeSmallButton("+", true));
var all = MakeButton(Tr("trading/ui/all"), false);
all.SizeFlagsHorizontal = SizeFlags.ExpandFill;
all.Pressed += OnAllPressed;
buttons.AddChild(all);
column.AddChild(buttons);
return column;
}
private Button MakeSmallButton(string text, bool plus)
{
var button = MakeButton(text, false);
button.SizeFlagsHorizontal = SizeFlags.ExpandFill;
if (plus)
button.Pressed += OnPlusPressed;
else
button.Pressed += OnMinusPressed;
return button;
}
// The inventory passport's button look (same tokens), so both windows match.
private static Button MakeButton(string text, bool primary)
{
var button = new Button { Text = text, FocusMode = FocusModeEnum.None };
button.AddThemeFontSizeOverride("font_size", 14);
button.AddThemeStyleboxOverride("normal", ButtonStyle(primary, false));
button.AddThemeStyleboxOverride("hover", ButtonStyle(primary, true));
button.AddThemeStyleboxOverride("pressed", ButtonStyle(primary, true));
button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty());
button.AddThemeColorOverride("font_color", primary ? UiTokens.AccentBright : UiTokens.Body);
button.AddThemeColorOverride("font_hover_color", primary ? UiTokens.AccentHover : UiTokens.InkBright);
button.AddThemeColorOverride("font_pressed_color", primary ? UiTokens.AccentHover : UiTokens.InkBright);
return button;
}
private static StyleBoxFlat ButtonStyle(bool primary, bool hover)
{
var background = primary
? (hover ? UiTokens.BtnPrimaryHover : UiTokens.BtnPrimary)
: (hover ? UiTokens.BtnHover : UiTokens.Surface3);
var border = primary
? (hover ? UiTokens.AccentLineStrong : UiTokens.Alpha(UiTokens.Accent, 0.55f))
: (hover ? UiTokens.Alpha(UiTokens.Accent, 0.55f) : UiTokens.Line);
var style = UiKit.Frame(4, background, border);
style.ContentMarginLeft = 12;
style.ContentMarginRight = 12;
style.ContentMarginTop = 10;
style.ContentMarginBottom = 10;
return style;
}
private void AddMessage()
{
if (string.IsNullOrEmpty(_message))
return;
var accent = _messageOk ? UiTokens.Ok : UiTokens.DangerText;
var frame = UiKit.Frame(5, UiTokens.Alpha(accent, 0.1f), UiTokens.Alpha(accent, 0.5f));
frame.ContentMarginLeft = 12;
frame.ContentMarginRight = 12;
frame.ContentMarginTop = 9;
frame.ContentMarginBottom = 9;
var wrapper = new PanelContainer { MouseFilter = MouseFilterEnum.Ignore };
wrapper.AddThemeStyleboxOverride("panel", frame);
var label = UiKit.Label(_message, 13, accent);
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
wrapper.AddChild(label);
_dealCard.AddChild(wrapper);
}
private void UpdateQuantityLabels(int max, int price)
{
if (_quantityLabel != null)
_quantityLabel.Text = _quantity + " / " + max;
if (_totalLabel != null)
_totalLabel.Text = string.Format(Tr("trading/ui/total"), price * _quantity);
}
private int CurrentMax()
{
return _side == Side.Merchant
? TradeLedger.MaxBuy(_inventory, _shop, _selectedId)
: TradeLedger.MaxSell(_inventory, _shop, _selectedId);
}
private void SetQuantity(int quantity)
{
var max = CurrentMax();
var clamped = Mathf.Clamp(quantity, 1, Mathf.Max(1, max));
if (clamped == _quantity)
return;
_quantity = clamped;
BuildDeal();
}
private void OnSliderChanged(double value)
{
_quantity = Mathf.RoundToInt((float)value);
var offer = _side == Side.Merchant ? _shop.FindSell(_selectedId) : _shop.FindBuy(_selectedId);
UpdateQuantityLabels(CurrentMax(), offer?.Price ?? 0);
}
private void OnMinusPressed() => SetQuantity(_quantity - 1);
private void OnPlusPressed() => SetQuantity(_quantity + 1);
private void OnAllPressed() => SetQuantity(CurrentMax());
private void OnDealPressed()
{
var item = ItemFactory.Instance.Get(_selectedId);
if (item == null)
return;
var buying = _side == Side.Merchant;
var offer = buying ? _shop.FindSell(item.ID) : _shop.FindBuy(item.ID);
var count = _quantity;
var ok = buying
? TradeLedger.Buy(_inventory, _shop, item.ID, count)
: TradeLedger.Sell(_inventory, _shop, item.ID, count);
_messageOk = ok;
_message = ok
? string.Format(Tr(buying ? "trading/ui/bought" : "trading/ui/sold"), Tr(item.Name), count, (offer?.Price ?? 0) * count)
: Tr("trading/ui/deal_failed");
_quantity = 1;
PlaySound(ok ? "items/use" : "ui/close_click");
// The grids were already redrawn by OnInventoryChanged during the deal,
// before the message existed; the card is redrawn again to show it.
BuildDeal();
}
private void OnMerchantCellClicked(InventoryItemBehaviour cell) => Select(Side.Merchant, cell);
private void OnPlayerCellClicked(InventoryItemBehaviour cell) => Select(Side.Player, cell);
private void Select(Side side, InventoryItemBehaviour cell)
{
_side = side;
_selectedId = cell.Model.Item.ID;
_quantity = 1;
_message = null;
MarkSelected();
BuildDeal();
PlaySound("ui/skip");
}
private void SelectFirstOffer()
{
foreach (var offer in _shop?.Sells ?? NoOffers)
{
if (offer != null && offer.Price > 0 && ItemFactory.Instance.Get(offer.Item) != null)
{
_side = Side.Merchant;
_selectedId = offer.Item;
return;
}
}
}
private void OnInventoryChanged(string id, int delta)
{
if (Visible)
RebuildAll();
}
private void OnClosePressed()
{
PlaySound("ui/close_click");
Hide();
}
private void ResetSelection()
{
ResetSelectionKeepMessage();
_message = null;
}
private void ResetSelectionKeepMessage()
{
_side = Side.None;
_selectedId = null;
_quantity = 1;
}
private string PriceTag(int price) => string.Format(Tr("trading/ui/price_tag"), price);
private static void ClearCells(List<InventoryItemBehaviour> cells)
{
foreach (var cell in cells)
cell.QueueFree();
cells.Clear();
}
private static void ClearChildren(Node container)
{
if (container == null)
return;
foreach (var child in container.GetChildren())
{
container.RemoveChild(child);
child.QueueFree();
}
}
private static void PlaySound(string id)
{
var audio = SceneContextNode.FindNode<Node>("AudioService") as IAudioService;
audio?.PlaySound(id, SoundType.UI);
}
}
}