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