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