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>
250 lines
8.5 KiB
C#
250 lines
8.5 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.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() { }
|
||
}
|