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>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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() { }
|
||||
}
|
||||
Reference in New Issue
Block a user