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:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
+249
View File
@@ -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() { }
}