Files
TheriapolisV3/Theriapolis.Game/Screens/ShopScreen.cs
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

260 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(); }
}