Files
TheriapolisV3/Theriapolis.Game/Screens/ShopScreen.cs
T

260 lines
10 KiB
C#
Raw Normal View History

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(); }
}