commit 89ee23f1e6577cf0002654bd99340014883801df Author: iisaev Date: Fri Oct 23 09:30:05 2026 +0300 Trading mod: shops as data and the trade window diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3030c1a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,30 @@ +# Line endings: LF everywhere, on every OS, whatever core.autocrlf says. +* text=auto eol=lf + +# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binaries: never converted, never diffed as text. +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.svg text +*.ogg binary +*.wav binary +*.mp3 binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.dll binary +*.so binary +*.dylib binary +*.pdb binary +*.exe binary +*.zip binary +*.pck binary +*.moc3 binary +*.res binary +*.scn binary diff --git a/items/money/coin.json b/items/money/coin.json new file mode 100644 index 0000000..489389a --- /dev/null +++ b/items/money/coin.json @@ -0,0 +1,14 @@ +{ + "Kind": "item", + "Items": [ + { + "Id": "money/coin", + "Name": "trading/item/coin/name", + "Description": "trading/item/coin/desc", + "Cost": 1, + "Sprite": "money/gold_coin", + "ItemType": "Resource,CantDrop", + "ItemAction": "null" + } + ] +} diff --git a/items/money/gold_coin.png b/items/money/gold_coin.png new file mode 100644 index 0000000..86f2a87 Binary files /dev/null and b/items/money/gold_coin.png differ diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..5fc663b --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,25 @@ +mod_trading_name = Trading +mod_trading_desc = Merchants' shops: what they sell, what they buy back and at which price ("Kind": "shop" data), and a trade window of two inventories, the merchant's goods and the player's bag. Money is an ordinary item, the gold coin. +trading/item/coin/name = Gold coin +trading/item/coin/desc = Common coin. Merchants take and pay nothing else. +trading/ui/merchant_goods = Merchant's goods +trading/ui/your_bag = Your bag +trading/ui/hint = Pick goods on the left to buy or an item on the right to sell. Esc closes. +trading/ui/select = Pick goods of the merchant or an item in your bag. +trading/ui/badge_merchant = Merchant's +trading/ui/badge_yours = Yours +trading/ui/unit_price = Price +trading/ui/wallet = Wallet +trading/ui/you_have = You have +trading/ui/not_bought = The merchant does not buy this. +trading/ui/no_money = Not enough coins. +trading/ui/nothing_to_sell = Nothing to sell. +trading/ui/quantity = Quantity +trading/ui/all = All +trading/ui/total = Total: {0} coins +trading/ui/buy = Buy +trading/ui/sell = Sell +trading/ui/bought = Bought: {0} ×{1} for {2} coins +trading/ui/sold = Sold: {0} ×{1} for {2} coins +trading/ui/deal_failed = The deal did not go through. +trading/ui/price_tag = {0} c diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..1fd9c08 --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,25 @@ +mod_trading_name = Торговля +mod_trading_desc = Лавки торговцев: что продают, что скупают и почём (данные «Kind»: «shop»), и окно торговли из двух инвентарей — товары торговца и сумка игрока. Деньги — обычный предмет, золотая монета. +trading/item/coin/name = Золотая монета +trading/item/coin/desc = Ходовая монета. Торговцы берут и платят только ею. +trading/ui/merchant_goods = Товары торговца +trading/ui/your_bag = Ваша сумка +trading/ui/hint = Выберите товар слева, чтобы купить, или вещь справа, чтобы продать. Esc — закрыть. +trading/ui/select = Выберите товар у торговца или вещь в своей сумке. +trading/ui/badge_merchant = У торговца +trading/ui/badge_yours = Ваше +trading/ui/unit_price = Цена +trading/ui/wallet = В кошельке +trading/ui/you_have = У вас +trading/ui/not_bought = Торговец это не покупает. +trading/ui/no_money = Не хватает монет. +trading/ui/nothing_to_sell = Продавать нечего. +trading/ui/quantity = Количество +trading/ui/all = Все +trading/ui/total = Итого: {0} мон. +trading/ui/buy = Купить +trading/ui/sell = Продать +trading/ui/bought = Куплено: {0} ×{1} за {2} мон. +trading/ui/sold = Продано: {0} ×{1} за {2} мон. +trading/ui/deal_failed = Сделка не состоялась. +trading/ui/price_tag = {0} мон. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..3b38572 --- /dev/null +++ b/manifest.json @@ -0,0 +1,29 @@ +{ + "id": "trading", + "name": "Trading", + "version": "1.0.0", + "author": "ct", + "description": "Shops: merchants defined as data (\"Kind\": \"shop\": what they sell, what they buy back and at which price) and a trade window of two inventories side by side, the merchant's goods and the player's bag, with a deal card between them. Money is an ordinary inventory item, the gold coin money/coin by default. Scenario action: trade.open shop=.", + "category": "gameplay", + "sources": [ + "src/**/*.cs" + ], + "test_sources": [ + "tests/*.cs" + ], + "depends": ["ui_base>=1", "items_base>=1"], + "test_suites": [ + {"id": "trade", "activate": ["--itest=trade"]} + ], + "ui": [ + { + "id": "trade", + "kind": "overlay", + "slot": "ModLastPanel", + "scene_path": "scenes/trade_panel.tscn", + "once": true, + "show_on_start": false, + "priority": 10 + } + ] +} diff --git a/mod.jpg b/mod.jpg new file mode 100644 index 0000000..75f6c37 Binary files /dev/null and b/mod.jpg differ diff --git a/scenes/trade_panel.tscn b/scenes/trade_panel.tscn new file mode 100644 index 0000000..f698340 --- /dev/null +++ b/scenes/trade_panel.tscn @@ -0,0 +1,153 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://mods/trading/src/TradePanel.cs" id="1_script"] + +[node name="TradePanel" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_script") + +[node name="Backdrop" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Panel" type="Panel" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 40.0 +offset_top = 78.0 +offset_right = -40.0 +offset_bottom = -30.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 + +[node name="Margin" type="MarginContainer" parent="Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 16 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 14 +mouse_filter = 1 + +[node name="Root" type="VBoxContainer" parent="Panel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 12 +mouse_filter = 1 + +[node name="Header" type="HBoxContainer" parent="Panel/Margin/Root"] +layout_mode = 2 +theme_override_constants/separation = 18 + +[node name="Title" type="Label" parent="Panel/Margin/Root/Header"] +layout_mode = 2 +theme_override_font_sizes/font_size = 24 + +[node name="HeaderSpacer" type="Control" parent="Panel/Margin/Root/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 2 + +[node name="Wallet" type="HBoxContainer" parent="Panel/Margin/Root/Header"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="WalletIcon" type="TextureRect" parent="Panel/Margin/Root/Header/Wallet"] +custom_minimum_size = Vector2(30, 30) +layout_mode = 2 +size_flags_vertical = 4 +expand_mode = 1 +stretch_mode = 5 +mouse_filter = 2 + +[node name="WalletLabel" type="Label" parent="Panel/Margin/Root/Header/Wallet"] +layout_mode = 2 +theme_override_font_sizes/font_size = 20 + +[node name="CloseButton" type="Button" parent="Panel/Margin/Root/Header"] +custom_minimum_size = Vector2(38, 38) +layout_mode = 2 +focus_mode = 0 +text = "✕" + +[node name="Rule" type="Panel" parent="Panel/Margin/Root"] +custom_minimum_size = Vector2(0, 1) +layout_mode = 2 +mouse_filter = 2 + +[node name="Body" type="HBoxContainer" parent="Panel/Margin/Root"] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/separation = 14 + +[node name="Merchant" type="VBoxContainer" parent="Panel/Margin/Root/Body"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 10 + +[node name="MerchantTitle" type="Label" parent="Panel/Margin/Root/Body/Merchant"] +layout_mode = 2 +theme_override_font_sizes/font_size = 13 + +[node name="MerchantScroll" type="ScrollContainer" parent="Panel/Margin/Root/Body/Merchant"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="MerchantGrid" type="Control" parent="Panel/Margin/Root/Body/Merchant/MerchantScroll"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 0 + +[node name="Deal" type="PanelContainer" parent="Panel/Margin/Root/Body"] +custom_minimum_size = Vector2(400, 0) +layout_mode = 2 + +[node name="DealScroll" type="ScrollContainer" parent="Panel/Margin/Root/Body/Deal"] +layout_mode = 2 +horizontal_scroll_mode = 0 + +[node name="DealCard" type="VBoxContainer" parent="Panel/Margin/Root/Body/Deal/DealScroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 14 + +[node name="Player" type="VBoxContainer" parent="Panel/Margin/Root/Body"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 10 + +[node name="PlayerTitle" type="Label" parent="Panel/Margin/Root/Body/Player"] +layout_mode = 2 +theme_override_font_sizes/font_size = 13 + +[node name="PlayerScroll" type="ScrollContainer" parent="Panel/Margin/Root/Body/Player"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="PlayerGrid" type="Control" parent="Panel/Margin/Root/Body/Player/PlayerScroll"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 0 + +[node name="Footer" type="Label" parent="Panel/Margin/Root"] +layout_mode = 2 +theme_override_font_sizes/font_size = 12 diff --git a/src/ShopFactory.cs b/src/ShopFactory.cs new file mode 100644 index 0000000..7bd343e --- /dev/null +++ b/src/ShopFactory.cs @@ -0,0 +1,14 @@ +using System; +using Cthangover.Core.Factories; + +namespace Cthangover.Mods.Trading +{ + /// Shops collected by the shared entity resolver ("Kind": "shop"). + public class ShopFactory : FileFactory + { + private static readonly Lazy instance = new(() => new ShopFactory()); + + /// Shared instance. + public static ShopFactory Instance => instance.Value; + } +} diff --git a/src/ShopInfo.cs b/src/ShopInfo.cs new file mode 100644 index 0000000..055a9e8 --- /dev/null +++ b/src/ShopInfo.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Cthangover.Core.Factories; + +namespace Cthangover.Mods.Trading +{ + /// + /// A merchant as data ("Kind": "shop"), opened by + /// action trade.open shop=<id>. The stock of 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 + /// (), which keeps it in the items save slot and lets + /// scenarios pay with item.add. + /// + [EntityKind("shop")] + public class ShopInfo : IIdentifiable + { + /// Currency item used when a shop does not name one. + public const string DefaultCurrency = "money/coin"; + + /// Shop id, the shop= argument of trade.open. + [JsonPropertyName("Id")] + public string ID { get; set; } + + /// Locale key of the window title (the merchant's name). + public string Name { get; set; } + + /// Item id of the money this shop takes and pays. + public string Currency { get; set; } = DefaultCurrency; + + /// What the merchant sells to the player, in display order. + public List Sells { get; set; } = new(); + + /// What the merchant buys from the player; anything else is refused. + public List Buys { get; set; } = new(); + + /// The offer for buying from the merchant, or null. + public ShopOffer FindSell(string itemId) => Find(Sells, itemId); + + /// The offer for selling to the merchant, or null. + public ShopOffer FindBuy(string itemId) => Find(Buys, itemId); + + private static ShopOffer Find(List 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; + } + } +} diff --git a/src/ShopOffer.cs b/src/ShopOffer.cs new file mode 100644 index 0000000..781811e --- /dev/null +++ b/src/ShopOffer.cs @@ -0,0 +1,19 @@ +namespace Cthangover.Mods.Trading +{ + /// + /// 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 + /// it is what the player pays, in + /// what the merchant pays. An offer with a price + /// below 1 is ignored by : a free deal is a gift and + /// belongs in a scenario. + /// + public class ShopOffer + { + /// Item id ("Kind": "item"). + public string Item { get; set; } + + /// Unit price in the shop's currency. + public int Price { get; set; } + } +} diff --git a/src/TradeLedger.cs b/src/TradeLedger.cs new file mode 100644 index 0000000..1191b02 --- /dev/null +++ b/src/TradeLedger.cs @@ -0,0 +1,64 @@ +using Cthangover.Core.Items; + +namespace Cthangover.Mods.Trading +{ + /// + /// 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. + /// + public static class TradeLedger + { + /// The player's money in the shop's currency. + public static int Money(IInventory inventory, ShopInfo shop) + { + return inventory == null || shop == null ? 0 : inventory.CheckCount(shop.Currency); + } + + /// How many units of the player can afford; 0 when the shop does not sell it. + public static int MaxBuy(IInventory inventory, ShopInfo shop, string itemId) + { + var offer = shop?.FindSell(itemId); + return offer == null ? 0 : Money(inventory, shop) / offer.Price; + } + + /// How many units of the player can sell; 0 when the merchant does not buy it. + 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); + } + + /// + /// Buys units: takes their price in money and adds + /// the items. Returns false and changes nothing when the shop does not + /// sell the item, the count is not positive or the money is short. + /// + 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; + } + + /// + /// Sells units: removes the items and pays their + /// price. Returns false and changes nothing when the merchant does not + /// buy the item, the count is not positive or the player has fewer units. + /// + 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; + } + } +} diff --git a/src/TradeOpenAction.cs b/src/TradeOpenAction.cs new file mode 100644 index 0000000..408dd68 --- /dev/null +++ b/src/TradeOpenAction.cs @@ -0,0 +1,41 @@ +using Cthangover.Core.Actions; +using Cthangover.Core.UI; + +namespace Cthangover.Mods.Trading +{ + /// + /// action trade.open shop=village_merchant — 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. + /// + public class TradeOpenAction : IScenarioAction + { + /// DSL name. + public string Name => "trade.open"; + + /// Resolves the shop and shows the window for it. + 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); + } + } +} diff --git a/src/TradePanel.cs b/src/TradePanel.cs new file mode 100644 index 0000000..c6d151e --- /dev/null +++ b/src/TradePanel.cs @@ -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; + + /// + /// 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. + /// + /// The window is a hosted "once" overlay ( in the manifest), + /// built on first use and reused for every merchant: + /// hands it a 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 + /// ; the window only redraws on + /// , so it also follows changes made elsewhere. + /// The money item is hidden from the player's grid and shown in the header. + /// + /// 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. + /// + public partial class TradePanel : Widget + { + /// Id of the UI registry entry (manifest ui). + 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 NoOffers = new(); + + private readonly List _merchantCells = new(); + private readonly List _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; + + /// + /// 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. + /// + public void SetShop(ShopInfo shop) + { + _shop = shop; + ResetSelection(); + if (Visible) + RebuildAll(); + } + + protected override void OnceConstruct() + { + MouseFilter = MouseFilterEnum.Stop; + _title = GetNode