Files
TheriapolisV3/Theriapolis.Game/Screens/InventoryScreen.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

250 lines
8.5 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.Items;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Game.Screens;
/// <summary>
/// Phase 5 M3 inventory screen. Pushed by <see cref="PlayScreen"/> when the
/// player presses TAB. Two-column layout: equipped slots on the left, bagged
/// items on the right. Click an equipped slot to unequip; click a bagged
/// item to equip into its natural slot (weapon → main hand, armor → body,
/// shield → off hand, enhancer → matching natural-weapon slot).
///
/// All mutations are direct on the character's <see cref="Inventory"/>;
/// <see cref="Stats.DerivedStats"/> recomputes AC/Speed automatically on
/// next read, so no signals or events are needed.
/// </summary>
public sealed class InventoryScreen : IScreen
{
private readonly Character _character;
private Game1 _game = null!;
private Desktop _desktop = null!;
private Label? _statusLabel;
private bool _tabWasDown = true;
private bool _escWasDown = true;
public InventoryScreen(Character character)
{
_character = character ?? throw new System.ArgumentNullException(nameof(character));
}
public void Initialize(Game1 game)
{
_game = game;
BuildUI();
}
private void BuildUI()
{
var root = new VerticalStackPanel
{
Spacing = 6,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(20),
Padding = new Thickness(20, 12, 20, 12),
Background = new SolidBrush(new Color(0, 0, 0, 220)),
};
// Header
root.Widgets.Add(new Label
{
Text = $"INVENTORY — {_character.Species.Name} {_character.ClassDef.Name} (Lv{_character.Level})",
HorizontalAlignment = HorizontalAlignment.Center,
});
root.Widgets.Add(new Label
{
Text = FormatStatLine(),
HorizontalAlignment = HorizontalAlignment.Center,
});
root.Widgets.Add(new Label { Text = " " });
// Two columns
var columns = new HorizontalStackPanel { Spacing = 24 };
// Equipped column
var equippedCol = new VerticalStackPanel { Spacing = 4, Width = 320 };
equippedCol.Widgets.Add(new Label { Text = "EQUIPPED:" });
foreach (EquipSlot slot in EquipSlotsToShow())
equippedCol.Widgets.Add(BuildEquippedSlotRow(slot));
columns.Widgets.Add(equippedCol);
// Inventory column
var bagCol = new VerticalStackPanel { Spacing = 4, Width = 380 };
bagCol.Widgets.Add(new Label
{
Text = $"INVENTORY ({_character.Inventory.Items.Count} stacks, {_character.Inventory.TotalWeightLb:F1} lb):",
});
bool any = false;
foreach (var item in _character.Inventory.Items)
{
// Skip equipped items in the bag list — they're shown in the equipped column.
if (item.EquippedAt is not null) continue;
any = true;
bagCol.Widgets.Add(BuildBagItemRow(item));
}
if (!any)
bagCol.Widgets.Add(new Label { Text = " (everything is equipped)" });
columns.Widgets.Add(bagCol);
root.Widgets.Add(columns);
// Status line
root.Widgets.Add(new Label { Text = " " });
_statusLabel = new Label { Text = "TAB or ESC to close.", HorizontalAlignment = HorizontalAlignment.Center };
root.Widgets.Add(_statusLabel);
_desktop = new Desktop { Root = root };
}
private Widget BuildEquippedSlotRow(EquipSlot slot)
{
var inst = _character.Inventory.GetEquipped(slot);
string text = inst is null
? $" {SlotLabel(slot),-18}—"
: $" {SlotLabel(slot),-18}{inst.Def.Name}";
var btn = new TextButton
{
Text = text,
Width = 320,
Padding = new Thickness(4, 2, 4, 2),
};
if (inst is not null)
btn.Click += (_, _) =>
{
_character.Inventory.TryUnequip(slot, out var err);
if (!string.IsNullOrEmpty(err) && _statusLabel is not null) _statusLabel.Text = err;
else SetStatus($"Unequipped {inst.Def.Name}.");
BuildUI();
};
else btn.Enabled = false;
return btn;
}
private Widget BuildBagItemRow(ItemInstance inst)
{
string suffix = inst.Qty > 1 ? $" ×{inst.Qty}" : "";
string weight = $" ({inst.TotalWeightLb:F1} lb)";
var btn = new TextButton
{
Text = $" {inst.Def.Name}{suffix}{weight}",
Width = 380,
Padding = new Thickness(4, 2, 4, 2),
};
var auto = NaturalSlotFor(inst);
if (auto is null)
{
btn.Enabled = false; // gear / consumables — no equip target in M3
}
else
{
btn.Click += (_, _) =>
{
if (_character.Inventory.TryEquip(inst, auto.Value, out var err))
SetStatus($"Equipped {inst.Def.Name} into {auto.Value}.");
else
SetStatus(err);
BuildUI();
};
}
return btn;
}
/// <summary>
/// Default equip slot for an item based on kind. Returns null for items
/// that have no obvious slot (consumables, adventuring gear).
/// </summary>
private static EquipSlot? NaturalSlotFor(ItemInstance inst)
{
switch (inst.Def.Kind)
{
case "weapon":
return EquipSlot.MainHand;
case "armor":
return EquipSlot.Body;
case "shield":
return EquipSlot.OffHand;
case "natural_weapon_enhancer":
return EquipSlotExtensions.FromEnhancerSlot(inst.Def.EnhancerSlot);
default:
return null;
}
}
private static IEnumerable<EquipSlot> EquipSlotsToShow() => new[]
{
EquipSlot.MainHand,
EquipSlot.OffHand,
EquipSlot.Body,
EquipSlot.Helm,
EquipSlot.Cloak,
EquipSlot.Boots,
EquipSlot.AdaptivePack,
EquipSlot.NaturalWeaponFang,
EquipSlot.NaturalWeaponClaw,
EquipSlot.NaturalWeaponHoof,
EquipSlot.NaturalWeaponAntler,
EquipSlot.NaturalWeaponHorn,
};
private static string SlotLabel(EquipSlot s) => s switch
{
EquipSlot.MainHand => "Main hand:",
EquipSlot.OffHand => "Off hand:",
EquipSlot.Body => "Body:",
EquipSlot.Helm => "Helm:",
EquipSlot.Cloak => "Cloak:",
EquipSlot.Boots => "Boots:",
EquipSlot.AdaptivePack => "Pack:",
EquipSlot.NaturalWeaponFang => "Fang caps:",
EquipSlot.NaturalWeaponClaw => "Claw sheaths:",
EquipSlot.NaturalWeaponHoof => "Hoof plates:",
EquipSlot.NaturalWeaponAntler => "Antler tips:",
EquipSlot.NaturalWeaponHorn => "Horn rings:",
_ => s.ToString(),
};
private string FormatStatLine()
{
int ac = DerivedStats.ArmorClass(_character);
int spd = DerivedStats.SpeedFt(_character);
float cap = DerivedStats.CarryCapacityLb(_character);
var enc = DerivedStats.Encumbrance(_character);
return $"HP {_character.CurrentHp}/{_character.MaxHp} AC {ac} Speed {spd} ft. " +
$"Carry {_character.Inventory.TotalWeightLb:F1}/{cap:F1} lb ({enc})";
}
private void SetStatus(string text)
{
if (_statusLabel is not null) _statusLabel.Text = text;
}
public void Update(GameTime gt)
{
var ks = Keyboard.GetState();
bool tab = ks.IsKeyDown(Keys.Tab);
bool esc = ks.IsKeyDown(Keys.Escape);
bool tabPressed = tab && !_tabWasDown;
bool escPressed = esc && !_escWasDown;
_tabWasDown = tab;
_escWasDown = esc;
if (tabPressed || escPressed) _game.Screens.Pop();
}
public void Draw(GameTime gt, SpriteBatch sb)
{
// Don't clear — let the play screen's last frame show through (semi-transparent overlay).
_desktop.Render();
}
public void Deactivate() { }
public void Reactivate() { }
}