260 lines
10 KiB
C#
260 lines
10 KiB
C#
|
|
using Microsoft.Xna.Framework;
|
|||
|
|
using Microsoft.Xna.Framework.Graphics;
|
|||
|
|
using Microsoft.Xna.Framework.Input;
|
|||
|
|
using Myra.Graphics2D;
|
|||
|
|
using Myra.Graphics2D.Brushes;
|
|||
|
|
using Myra.Graphics2D.UI;
|
|||
|
|
using Theriapolis.Core.Data;
|
|||
|
|
using Theriapolis.Core.Entities;
|
|||
|
|
using Theriapolis.Core.Items;
|
|||
|
|
using Theriapolis.Core.Rules.Character;
|
|||
|
|
using Theriapolis.Core.Rules.Dialogue;
|
|||
|
|
using Theriapolis.Core.Rules.Reputation;
|
|||
|
|
|
|||
|
|
namespace Theriapolis.Game.Screens;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// Phase 6 M3 — buy/sell modal pushed from <see cref="InteractionScreen"/>
|
|||
|
|
/// when a dialogue option fires the <c>open_shop</c> effect.
|
|||
|
|
///
|
|||
|
|
/// Pricing applies the disposition modifier from
|
|||
|
|
/// <see cref="ShopPricing"/>: a friendly merchant sells for cheaper, an
|
|||
|
|
/// antagonistic one marks up. Hostile / Nemesis merchants refuse service
|
|||
|
|
/// outright (the screen shows a refusal line and only the close button).
|
|||
|
|
///
|
|||
|
|
/// Phase 6 M3 ships a hand-curated stock list per merchant role
|
|||
|
|
/// (innkeeper / shopkeeper / smith / alchemist) — Phase 6 M5 swaps this
|
|||
|
|
/// for trade-route-driven inventories.
|
|||
|
|
/// </summary>
|
|||
|
|
public sealed class ShopScreen : IScreen
|
|||
|
|
{
|
|||
|
|
private static readonly string[] InnkeeperStock = { "rations_predator", "rations_prey", "poultice_universal" };
|
|||
|
|
private static readonly string[] ShopkeeperStock = { "rope_claw_braid", "torch_scent_neutral", "scent_mask_basic", "rations_predator", "poultice_universal", "healers_kit" };
|
|||
|
|
private static readonly string[] SmithStock = { "fang_knife", "rend_sword", "thorn_blade", "paw_axe", "hide_vest", "leather_harness", "studded_leather", "chain_shirt", "buckler", "standard_shield" };
|
|||
|
|
private static readonly string[] AlchemistStock = { "poultice_universal", "poultice_canid", "healers_kit", "scent_mask_basic", "pheromone_vial_calm", "pheromone_vial_fear" };
|
|||
|
|
|
|||
|
|
private readonly NpcActor _npc;
|
|||
|
|
private readonly Character _pc;
|
|||
|
|
private readonly ContentResolver _content;
|
|||
|
|
private readonly PlayScreen _playScreen;
|
|||
|
|
|
|||
|
|
private Game1 _game = null!;
|
|||
|
|
private Desktop _desktop = null!;
|
|||
|
|
private VerticalStackPanel _root = null!;
|
|||
|
|
private Label _statusLabel = null!;
|
|||
|
|
private VerticalStackPanel _stockList = null!;
|
|||
|
|
private VerticalStackPanel _bagList = null!;
|
|||
|
|
private bool _escWasDown = true;
|
|||
|
|
private bool _enterWasDown = true;
|
|||
|
|
|
|||
|
|
public ShopScreen(NpcActor npc, Character pc, ContentResolver content, PlayScreen playScreen)
|
|||
|
|
{
|
|||
|
|
_npc = npc;
|
|||
|
|
_pc = pc;
|
|||
|
|
_content = content;
|
|||
|
|
_playScreen = playScreen;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public void Initialize(Game1 game)
|
|||
|
|
{
|
|||
|
|
_game = game;
|
|||
|
|
BuildLayout();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private int Disposition()
|
|||
|
|
=> EffectiveDisposition.For(_npc, _pc, _playScreen.Reputation, _content,
|
|||
|
|
_playScreen.World(), _playScreen.WorldSeed());
|
|||
|
|
|
|||
|
|
private string[] StockForRole(string roleTag)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(roleTag)) return ShopkeeperStock;
|
|||
|
|
string suffix = roleTag;
|
|||
|
|
int dot = roleTag.LastIndexOf('.');
|
|||
|
|
if (dot >= 0) suffix = roleTag[(dot + 1)..];
|
|||
|
|
return suffix.ToLowerInvariant() switch
|
|||
|
|
{
|
|||
|
|
"innkeeper" => InnkeeperStock,
|
|||
|
|
"smith" => SmithStock,
|
|||
|
|
"alchemist" => AlchemistStock,
|
|||
|
|
_ => ShopkeeperStock,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void BuildLayout()
|
|||
|
|
{
|
|||
|
|
_root = new VerticalStackPanel
|
|||
|
|
{
|
|||
|
|
Spacing = 8,
|
|||
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|||
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|||
|
|
Padding = new Thickness(40, 24, 40, 24),
|
|||
|
|
Background = new SolidBrush(new Color(15, 12, 8, 240)),
|
|||
|
|
Width = 760,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
_root.Widgets.Add(new Label
|
|||
|
|
{
|
|||
|
|
Text = $"{_npc.DisplayName} — wares",
|
|||
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|||
|
|
TextColor = new Color(255, 230, 170),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
int disp = Disposition();
|
|||
|
|
var label = DispositionLabels.For(disp);
|
|||
|
|
if (!ShopPricing.ServiceAvailable(disp))
|
|||
|
|
{
|
|||
|
|
_root.Widgets.Add(new Label
|
|||
|
|
{
|
|||
|
|
Text = $"\"I'll not deal with you. Get out before I call the constabulary.\"",
|
|||
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|||
|
|
TextColor = new Color(220, 120, 120),
|
|||
|
|
Wrap = true,
|
|||
|
|
Width = 660,
|
|||
|
|
});
|
|||
|
|
_root.Widgets.Add(new Label { Text = " " });
|
|||
|
|
var closeRefused = new TextButton { Text = "Leave", Width = 240, HorizontalAlignment = HorizontalAlignment.Center };
|
|||
|
|
closeRefused.Click += (_, _) => _game.Screens.Pop();
|
|||
|
|
_root.Widgets.Add(closeRefused);
|
|||
|
|
_desktop = new Desktop { Root = _root };
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_root.Widgets.Add(new Label
|
|||
|
|
{
|
|||
|
|
Text = $"[{DispositionLabels.DisplayName(label)}] · buy ×{ShopPricing.BuyMultiplier(disp):0.00} · sell ×{ShopPricing.SellMultiplier(disp):0.00}",
|
|||
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|||
|
|
TextColor = new Color(120, 140, 180),
|
|||
|
|
});
|
|||
|
|
_statusLabel = new Label
|
|||
|
|
{
|
|||
|
|
Text = $"Your fangs: {_pc.CurrencyFang}",
|
|||
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|||
|
|
TextColor = new Color(220, 200, 140),
|
|||
|
|
};
|
|||
|
|
_root.Widgets.Add(_statusLabel);
|
|||
|
|
_root.Widgets.Add(new Label { Text = " " });
|
|||
|
|
|
|||
|
|
var twoCol = new HorizontalStackPanel { Spacing = 24 };
|
|||
|
|
|
|||
|
|
_stockList = new VerticalStackPanel { Spacing = 2, Width = 360 };
|
|||
|
|
_stockList.Widgets.Add(new Label { Text = "BUY", TextColor = new Color(200, 180, 130) });
|
|||
|
|
twoCol.Widgets.Add(_stockList);
|
|||
|
|
|
|||
|
|
_bagList = new VerticalStackPanel { Spacing = 2, Width = 360 };
|
|||
|
|
_bagList.Widgets.Add(new Label { Text = "SELL (your bag)", TextColor = new Color(200, 180, 130) });
|
|||
|
|
twoCol.Widgets.Add(_bagList);
|
|||
|
|
|
|||
|
|
_root.Widgets.Add(twoCol);
|
|||
|
|
_root.Widgets.Add(new Label { Text = " " });
|
|||
|
|
_root.Widgets.Add(new Label
|
|||
|
|
{
|
|||
|
|
Text = "(Click an item to buy/sell · Esc / Enter to close)",
|
|||
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|||
|
|
TextColor = new Color(120, 110, 100),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
RefreshLists();
|
|||
|
|
_desktop = new Desktop { Root = _root };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void RefreshLists()
|
|||
|
|
{
|
|||
|
|
// Rebuild buy list.
|
|||
|
|
for (int i = _stockList.Widgets.Count - 1; i >= 1; i--) _stockList.Widgets.RemoveAt(i);
|
|||
|
|
int disp = Disposition();
|
|||
|
|
var stock = StockForRole(_npc.RoleTag);
|
|||
|
|
foreach (var id in stock)
|
|||
|
|
{
|
|||
|
|
if (!_content.Items.TryGetValue(id, out var def)) continue;
|
|||
|
|
int price = ShopPricing.BuyPriceFor((int)System.Math.Round(def.CostFang), disp);
|
|||
|
|
var btn = new TextButton
|
|||
|
|
{
|
|||
|
|
Text = $" {def.Name,-26} {price,4}f",
|
|||
|
|
Width = 350,
|
|||
|
|
HorizontalAlignment = HorizontalAlignment.Left,
|
|||
|
|
};
|
|||
|
|
string capturedId = def.Id;
|
|||
|
|
btn.Click += (_, _) => TryBuy(capturedId);
|
|||
|
|
_stockList.Widgets.Add(btn);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Rebuild sell list — group by item name + count, sell one at a time.
|
|||
|
|
for (int i = _bagList.Widgets.Count - 1; i >= 1; i--) _bagList.Widgets.RemoveAt(i);
|
|||
|
|
var grouped = _pc.Inventory.Items
|
|||
|
|
.Where(it => it.EquippedAt is null) // can't sell equipped gear without unequipping first
|
|||
|
|
.GroupBy(it => it.Def.Id)
|
|||
|
|
.OrderBy(g => g.Key, System.StringComparer.Ordinal);
|
|||
|
|
foreach (var grp in grouped)
|
|||
|
|
{
|
|||
|
|
var def = grp.First().Def;
|
|||
|
|
int totalQty = grp.Sum(it => it.Qty);
|
|||
|
|
int price = ShopPricing.SellPriceFor((int)System.Math.Round(def.CostFang), disp);
|
|||
|
|
var btn = new TextButton
|
|||
|
|
{
|
|||
|
|
Text = $" {def.Name,-22} ×{totalQty,2} @ {price,4}f",
|
|||
|
|
Width = 350,
|
|||
|
|
HorizontalAlignment = HorizontalAlignment.Left,
|
|||
|
|
};
|
|||
|
|
string capturedId = def.Id;
|
|||
|
|
btn.Click += (_, _) => TrySell(capturedId);
|
|||
|
|
_bagList.Widgets.Add(btn);
|
|||
|
|
}
|
|||
|
|
_statusLabel.Text = $"Your fangs: {_pc.CurrencyFang}";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void TryBuy(string itemId)
|
|||
|
|
{
|
|||
|
|
if (!_content.Items.TryGetValue(itemId, out var def)) return;
|
|||
|
|
int disp = Disposition();
|
|||
|
|
int price = ShopPricing.BuyPriceFor((int)System.Math.Round(def.CostFang), disp);
|
|||
|
|
if (_pc.CurrencyFang < price)
|
|||
|
|
{
|
|||
|
|
_statusLabel.Text = $"Not enough fangs ({_pc.CurrencyFang} / {price}).";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
_pc.CurrencyFang -= price;
|
|||
|
|
_pc.Inventory.Add(def, 1);
|
|||
|
|
_playScreen.Reputation.Submit(new RepEvent
|
|||
|
|
{
|
|||
|
|
Kind = RepEventKind.Trade,
|
|||
|
|
FactionId = _npc.FactionId,
|
|||
|
|
RoleTag = _npc.RoleTag,
|
|||
|
|
Magnitude = 1, // small positive bump per successful purchase
|
|||
|
|
Note = $"bought {def.Id}",
|
|||
|
|
TimestampSeconds = _playScreen.ClockSeconds(),
|
|||
|
|
}, _content.Factions);
|
|||
|
|
RefreshLists();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void TrySell(string itemId)
|
|||
|
|
{
|
|||
|
|
if (!_content.Items.TryGetValue(itemId, out var def)) return;
|
|||
|
|
int disp = Disposition();
|
|||
|
|
int price = ShopPricing.SellPriceFor((int)System.Math.Round(def.CostFang), disp);
|
|||
|
|
// Remove ONE unit (smallest stack first to keep stacks tidy).
|
|||
|
|
var stack = _pc.Inventory.Items.Where(it => it.Def.Id == itemId && it.EquippedAt is null)
|
|||
|
|
.OrderBy(it => it.Qty)
|
|||
|
|
.FirstOrDefault();
|
|||
|
|
if (stack is null) return;
|
|||
|
|
stack.Qty--;
|
|||
|
|
if (stack.Qty <= 0) _pc.Inventory.Remove(stack);
|
|||
|
|
_pc.CurrencyFang += price;
|
|||
|
|
RefreshLists();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public void Update(GameTime gt)
|
|||
|
|
{
|
|||
|
|
var ks = Keyboard.GetState();
|
|||
|
|
bool esc = ks.IsKeyDown(Keys.Escape);
|
|||
|
|
bool ent = ks.IsKeyDown(Keys.Enter);
|
|||
|
|
bool escPressed = esc && !_escWasDown;
|
|||
|
|
bool entPressed = ent && !_enterWasDown;
|
|||
|
|
_escWasDown = esc; _enterWasDown = ent;
|
|||
|
|
if (escPressed || entPressed) _game.Screens.Pop();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public void Draw(GameTime gt, SpriteBatch sb) => _desktop.Render();
|
|||
|
|
public void Deactivate() { }
|
|||
|
|
public void Reactivate() { RefreshLists(); }
|
|||
|
|
}
|