Files
TheriapolisV3/Theriapolis.Game/CodexUI/Steps/StepReview.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

251 lines
11 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 Theriapolis.Game.CodexUI.Core;
using Theriapolis.Game.CodexUI.Screens;
using Theriapolis.Game.CodexUI.Widgets;
using Theriapolis.Game.UI;
namespace Theriapolis.Game.CodexUI.Steps;
/// <summary>
/// Step VII — Sign. Name input + summary panels for lineage / calling /
/// abilities / skills / starting kit. Each panel has an Edit link that
/// jumps the wizard back to the source step.
/// </summary>
public static class StepReview
{
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover)
{
var col = new Column { Spacing = 14 };
col.Add(StepCommon.PageIntro(
"Folio VII — Of Names & Witness",
"Sign the Codex",
"Review your character. The name you sign here is the one the world will speak."));
// Name input
var nameBlock = new Column { Spacing = 4 };
nameBlock.Add(new CodexLabel("NAME", CodexFonts.MonoTag, CodexColors.InkMute));
var nameInput = new CodexTextBox(s.Name, atlas, fixedWidth: 480, onChanged: t => s.Name = t)
{
Placeholder = "Wanderer",
};
nameBlock.Add(nameInput);
col.Add(nameBlock);
// Lineage / Calling pair
var pair = new Grid { Columns = 2 };
pair.Add(BuildBlock(atlas, "Lineage", () => s.Step = 0, s.InvalidateLayout, BuildLineage(s)));
pair.Add(BuildBlock(atlas, "Calling & History", () => s.Step = 2, s.InvalidateLayout, BuildCalling(s)));
col.Add(pair);
// Final abilities
col.Add(BuildBlock(atlas, "Final Abilities", () => s.Step = 4, s.InvalidateLayout, new StatStrip(s, atlas)));
// Skills — HoverableChip so the player can re-read each skill's
// codex flavour text without bouncing back to the Skills folio.
var skillsBlock = new WrapRow();
if (s.Background is not null)
foreach (var sk in s.Background.SkillProficiencies)
skillsBlock.Add(new HoverableChip(atlas, popover,
CodexCopy.SkillName(sk),
CodexCopy.SkillName(sk),
CodexCopy.SkillDescription(sk),
"BACKGROUND",
ChipKind.SkillFromBg));
foreach (var sk in s.ChosenSkills.OrderBy(x => x.ToString()))
{
string id = CodexCopy.SkillIdToJson(sk);
skillsBlock.Add(new HoverableChip(atlas, popover,
CodexCopy.SkillName(id),
CodexCopy.SkillName(id),
CodexCopy.SkillDescription(id),
"CLASS",
ChipKind.SkillFromClass));
}
col.Add(BuildBlock(atlas, "Skills", () => s.Step = 5, s.InvalidateLayout, skillsBlock));
// Starting kit — each chip resolves the item def for description /
// properties / damage etc. and shows them on hover.
var kitBlock = new WrapRow();
if (s.Class?.StartingKit is not null)
foreach (var entry in s.Class.StartingKit)
kitBlock.Add(new KitItemWidget(atlas, popover, entry, s.Content));
col.Add(BuildBlock(atlas, "Starting Kit", () => s.Step = 2, s.InvalidateLayout, kitBlock));
return col;
}
private static CodexWidget BuildLineage(CodexCharacterCreationScreen s)
{
var col = new Column { Spacing = 4 };
col.Add(new CodexLabel(s.Clade?.Name ?? "—", CodexFonts.DisplayMedium, CodexColors.Ink));
col.Add(new CodexLabel(
(s.Species?.Name ?? "—") + (s.Species is not null ? $" · {CodexCopy.SizeLabel(s.Species.Size).ToUpperInvariant()}" : ""),
CodexFonts.SerifBody, CodexColors.InkSoft));
return col;
}
private static CodexWidget BuildCalling(CodexCharacterCreationScreen s)
{
var col = new Column { Spacing = 4 };
col.Add(new CodexLabel(s.Class?.Name ?? "—", CodexFonts.DisplayMedium, CodexColors.Ink));
col.Add(new CodexLabel(
(s.Class is not null ? $"D{s.Class.HitDie} · {string.Join("/", s.Class.PrimaryAbility)}" : ""),
CodexFonts.MonoTagSmall, CodexColors.InkMute));
col.Add(new CodexLabel(s.Background?.Name ?? "—", CodexFonts.SerifItalic, CodexColors.InkSoft));
return col;
}
private static CodexWidget BuildBlock(CodexAtlas atlas, string title, System.Action onEdit, System.Action onAfterEdit, CodexWidget body)
{
var inner = new Column { Spacing = 8 };
var head = new Row { Spacing = 8, VAlignChildren = VAlign.Middle };
head.Add(new CodexLabel(title, CodexFonts.DisplaySmall, CodexColors.Ink));
var spacerL = new HSpacer();
head.Add(spacerL);
head.Add(new EditLink(atlas, () => { onEdit(); onAfterEdit(); }));
inner.Add(head);
inner.Add(body);
return new CodexPanel(atlas, inner);
}
}
internal sealed class EditLink : CodexWidget
{
private readonly CodexAtlas _atlas;
private readonly FontStashSharp.SpriteFontBase _font = CodexFonts.MonoTagSmall;
private readonly System.Action _onClick;
private bool _hovered;
private const string Text = "EDIT ";
public EditLink(CodexAtlas atlas, System.Action onClick) { _atlas = atlas; _onClick = onClick; }
protected override Point MeasureCore(Point available)
{
var s = _font.MeasureString(Text);
return new Point((int)s.X + 6, (int)System.MathF.Ceiling(_font.LineHeight) + 4);
}
protected override void ArrangeCore(Rectangle bounds) { }
public override void Update(GameTime gt, CodexInput input)
{
_hovered = ContainsPoint(input.MousePosition);
if (_hovered && input.LeftJustReleased) _onClick();
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
var s = _font.MeasureString(Text);
var color = _hovered ? CodexColors.GildBright : CodexColors.Gild;
_font.DrawText(sb, Text, new Vector2(Bounds.X, Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f), color);
}
}
internal sealed class HSpacer : CodexWidget
{
protected override Point MeasureCore(Point available) => new(System.Math.Max(0, available.X - 80), 1);
protected override void ArrangeCore(Rectangle bounds) { }
}
internal sealed class KitItemWidget : CodexWidget
{
private readonly CodexAtlas _atlas;
private readonly CodexHoverPopover _popover;
private readonly Theriapolis.Core.Data.StartingKitItem _entry;
private readonly Theriapolis.Core.Data.ItemDef? _itemDef;
private readonly FontStashSharp.SpriteFontBase _font = CodexFonts.SerifBody;
private readonly FontStashSharp.SpriteFontBase _qtyFont = CodexFonts.MonoTagSmall;
private bool _hovered;
public KitItemWidget(CodexAtlas atlas, CodexHoverPopover popover,
Theriapolis.Core.Data.StartingKitItem entry,
Theriapolis.Core.Data.ContentResolver content)
{
_atlas = atlas;
_popover = popover;
_entry = entry;
// Item id may not resolve (forward-compat starting kits, missing
// entries) — gracefully fall back to a name-only popover.
content.Items.TryGetValue(entry.ItemId, out _itemDef);
}
protected override Point MeasureCore(Point available) => new(160, 48);
protected override void ArrangeCore(Rectangle bounds) { }
public override void Update(GameTime gt, CodexInput input)
{
_hovered = ContainsPoint(input.MousePosition);
if (_hovered) ShowPopover();
}
private void ShowPopover()
{
string title = CodexCopy.ItemName(_entry.ItemId);
string body = ComposePopoverBody();
string? tag = _itemDef?.Kind?.ToUpperInvariant();
_popover.Show(Bounds, title, body, tag);
}
/// <summary>Compose a multi-line description from the item's stats and
/// the auto-equip slot. Falls back to the codex flavor description
/// when no kind-specific stats are available.</summary>
private string ComposePopoverBody()
{
var sb = new System.Text.StringBuilder();
if (_entry.Qty > 1) sb.Append($"Quantity: ×{_entry.Qty}\n");
if (_entry.AutoEquip) sb.Append($"Equipped to: {_entry.EquipSlot.Replace('_', ' ')}\n");
if (_itemDef is not null)
{
switch (_itemDef.Kind)
{
case "weapon":
sb.Append($"Damage: {_itemDef.Damage}");
if (!string.IsNullOrEmpty(_itemDef.DamageType)) sb.Append(' ').Append(_itemDef.DamageType);
if (!string.IsNullOrEmpty(_itemDef.DamageVersatile))
sb.Append($" ({_itemDef.DamageVersatile} two-handed)");
sb.Append('\n');
break;
case "armor":
sb.Append($"AC {_itemDef.AcBase}");
if (_itemDef.AcMaxDex >= 0) sb.Append($" + DEX (max +{_itemDef.AcMaxDex})");
if (_itemDef.MinStr > 0) sb.Append($" · Min STR {_itemDef.MinStr}");
sb.Append('\n');
break;
case "shield":
sb.Append($"+{_itemDef.AcBase} AC\n");
break;
case "consumable":
if (!string.IsNullOrEmpty(_itemDef.Healing)) sb.Append($"Heals {_itemDef.Healing}\n");
break;
}
if (_itemDef.Properties.Length > 0)
sb.Append("Properties: ").Append(string.Join(", ", _itemDef.Properties)).Append('\n');
if (!string.IsNullOrEmpty(_itemDef.Description))
sb.Append(_itemDef.Description);
}
if (sb.Length == 0) sb.Append("(no description)");
return sb.ToString().TrimEnd('\n');
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
Color border = _hovered ? CodexColors.Gild
: _entry.AutoEquip ? CodexColors.Gild
: CodexColors.Rule;
sb.Draw(_atlas.Pixel, Bounds, CodexColors.Bg);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 1), border);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1), border);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, 1, Bounds.Height), border);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.Right - 1, Bounds.Y, 1, Bounds.Height), border);
_font.DrawText(sb, CodexCopy.ItemName(_entry.ItemId),
new Vector2(Bounds.X + 8, Bounds.Y + 6), CodexColors.Ink);
_qtyFont.DrawText(sb, "×" + _entry.Qty,
new Vector2(Bounds.X + 8, Bounds.Y + 22), CodexColors.InkMute);
if (_entry.AutoEquip)
_qtyFont.DrawText(sb, _entry.EquipSlot.ToUpperInvariant(),
new Vector2(Bounds.X + 8, Bounds.Bottom - _qtyFont.LineHeight - 4),
CodexColors.Gild);
}
}