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,50 @@
|
||||
using Theriapolis.Core.Data;
|
||||
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 IV — Background. Two-column grid of background cards: name + flavor
|
||||
/// paragraph + named feature chip + sealed-skill chips.
|
||||
/// </summary>
|
||||
public static class StepBackground
|
||||
{
|
||||
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover)
|
||||
{
|
||||
var col = new Column { Spacing = 14 };
|
||||
col.Add(StepCommon.PageIntro(
|
||||
"Folio IV — Of Histories",
|
||||
"Choose your Background",
|
||||
"Where the clade gives you body and the calling gives you craft, the background gives you a past — debts, contacts, scars, the way you sleep."));
|
||||
|
||||
var grid = new Grid { Columns = 2 };
|
||||
foreach (var b in s.Backgrounds) grid.Add(BuildCard(s, atlas, popover, b));
|
||||
col.Add(grid);
|
||||
return col;
|
||||
}
|
||||
|
||||
private static CodexWidget BuildCard(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover, BackgroundDef b)
|
||||
{
|
||||
var content = new Column { Spacing = 8 };
|
||||
content.Add(new CodexLabel(b.Name, CodexFonts.DisplayMedium, CodexColors.Ink));
|
||||
if (!string.IsNullOrEmpty(b.Flavor))
|
||||
content.Add(new CodexLabel(b.Flavor, CodexFonts.SerifItalic, CodexColors.InkSoft));
|
||||
|
||||
content.Add(new CodexLabel("FEATURE", CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
var featRow = new WrapRow();
|
||||
featRow.Add(new HoverableChip(atlas, popover, b.FeatureName, b.FeatureName, b.FeatureDescription, "FEATURE", ChipKind.BgFeature));
|
||||
content.Add(featRow);
|
||||
|
||||
content.Add(new CodexLabel("SKILLS", CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
var skills = new WrapRow();
|
||||
foreach (var sk in b.SkillProficiencies)
|
||||
skills.Add(new HoverableChip(atlas, popover, CodexCopy.SkillName(sk), CodexCopy.SkillName(sk), CodexCopy.SkillDescription(sk), "BACKGROUND", ChipKind.SkillFromBg));
|
||||
content.Add(skills);
|
||||
|
||||
return new CodexCard(atlas, content, s.Background == b,
|
||||
onClick: () => { s.Background = b; s.InvalidateLayout(); });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Core.Data;
|
||||
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 I — Clade. Two grouped grids (Predators / Prey) of clade cards,
|
||||
/// each card showing the clade name + kind + ability mods + language chips
|
||||
/// + trait chips. Selection swaps the species default to the first species
|
||||
/// belonging to that clade.
|
||||
/// </summary>
|
||||
public static class StepClade
|
||||
{
|
||||
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover)
|
||||
{
|
||||
var col = new Column { Spacing = 14 };
|
||||
col.Add(StepCommon.PageIntro("Folio I — Of Bloodlines", "Choose your Clade",
|
||||
"The seven great families of Theriapolis. Your clade is the body you were born to — the broad shape of your gait, the fall of your shadow, the words your scent carries before you speak."));
|
||||
|
||||
col.Add(new CodexLabel("PREDATORS", CodexFonts.MonoTag, CodexColors.InkMute));
|
||||
col.Add(BuildGrid(s, atlas, popover, "predator"));
|
||||
col.Add(new CodexLabel("PREY", CodexFonts.MonoTag, CodexColors.InkMute));
|
||||
col.Add(BuildGrid(s, atlas, popover, "prey"));
|
||||
return col;
|
||||
}
|
||||
|
||||
private static CodexWidget BuildGrid(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover, string kind)
|
||||
{
|
||||
var grid = new Grid { Columns = 3 };
|
||||
foreach (var c in s.Clades)
|
||||
{
|
||||
if (c.Kind != kind) continue;
|
||||
grid.Add(BuildCard(s, atlas, popover, c));
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
private static CodexWidget BuildCard(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover, CladeDef c)
|
||||
{
|
||||
var content = new Column { Spacing = 8 };
|
||||
|
||||
// Header: sigil + name/kind
|
||||
var headerRow = new Row { Spacing = 12, VAlignChildren = VAlign.Top };
|
||||
headerRow.Add(new SigilWidget(atlas, c.Id));
|
||||
var titleCol = new Column { Spacing = 2 };
|
||||
titleCol.Add(new CodexLabel(c.Name, CodexFonts.DisplayMedium, CodexColors.Ink));
|
||||
titleCol.Add(new CodexLabel(c.Kind.ToUpperInvariant(), CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
headerRow.Add(titleCol);
|
||||
content.Add(headerRow);
|
||||
|
||||
// Mods row
|
||||
if (c.AbilityMods.Count > 0)
|
||||
{
|
||||
var mods = new WrapRow();
|
||||
foreach (var kv in c.AbilityMods)
|
||||
mods.Add(new ModChipMini(atlas, kv.Key, kv.Value));
|
||||
content.Add(mods);
|
||||
}
|
||||
|
||||
// Languages
|
||||
content.Add(new CodexLabel("LANGUAGES", CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
var langs = new WrapRow();
|
||||
foreach (var l in c.Languages)
|
||||
langs.Add(new HoverableChip(atlas, popover, CodexCopy.LanguageName(l), CodexCopy.LanguageName(l), CodexCopy.LanguageDescription(l), null, ChipKind.Language));
|
||||
content.Add(langs);
|
||||
|
||||
// Traits
|
||||
content.Add(new CodexLabel("TRAITS", CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
var traits = new WrapRow();
|
||||
foreach (var t in c.Traits)
|
||||
traits.Add(new HoverableChip(atlas, popover, t.Name, t.Name, t.Description, null, ChipKind.Trait));
|
||||
foreach (var t in c.Detriments)
|
||||
traits.Add(new HoverableChip(atlas, popover, t.Name, t.Name, t.Description, "DETRIMENT", ChipKind.TraitDetriment));
|
||||
content.Add(traits);
|
||||
|
||||
bool isSelected = s.Clade == c;
|
||||
var card = new CodexCard(atlas, content, isSelected,
|
||||
onClick: () =>
|
||||
{
|
||||
s.Clade = c;
|
||||
// If the previously-picked species belongs to a different
|
||||
// clade, drop it — but never auto-pick a new species. The
|
||||
// user must visit the Species folio explicitly so the
|
||||
// Calling step stays locked behind that decision.
|
||||
if (s.Species is not null && s.Species.CladeId != c.Id)
|
||||
s.Species = null;
|
||||
s.InvalidateLayout();
|
||||
});
|
||||
card.CornerSigil = atlas.SigilFor(c.Id);
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Renders the clade sigil placeholder + a centred initial letter.</summary>
|
||||
internal sealed class SigilWidget : CodexWidget
|
||||
{
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly string _cladeId;
|
||||
public SigilWidget(CodexAtlas atlas, string cladeId) { _atlas = atlas; _cladeId = cladeId; }
|
||||
|
||||
protected override Point MeasureCore(Point available) => new(56, 56);
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
sb.Draw(_atlas.SigilFor(_cladeId), Bounds, Color.White);
|
||||
// Letter overlay so the placeholder is identifiable.
|
||||
char ch = char.ToUpper(_cladeId.Length > 0 ? _cladeId[0] : '?');
|
||||
var font = CodexFonts.DisplayMedium;
|
||||
var s = font.MeasureString(ch.ToString());
|
||||
font.DrawText(sb, ch.ToString(),
|
||||
new Vector2(Bounds.X + (Bounds.Width - s.X) / 2f, Bounds.Y + (Bounds.Height - font.LineHeight) / 2f),
|
||||
CodexColors.Ink);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Inline mod pill drawn as `STR +1` / `DEX -1` etc.</summary>
|
||||
internal sealed class ModChipMini : CodexWidget
|
||||
{
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly string _label;
|
||||
private readonly bool _positive;
|
||||
private readonly SpriteFontBase _font = CodexFonts.MonoTagSmall;
|
||||
|
||||
public ModChipMini(CodexAtlas atlas, string ab, int v)
|
||||
{
|
||||
_atlas = atlas;
|
||||
_label = $"{ab} {(v >= 0 ? "+" : "")}{v}";
|
||||
_positive = v >= 0;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
var s = _font.MeasureString(_label);
|
||||
return new Point((int)s.X + 14, (int)System.MathF.Ceiling(_font.LineHeight) + 6);
|
||||
}
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
var border = _positive ? CodexColors.Seal : CodexColors.InkMute;
|
||||
var fill = CodexColors.Bg;
|
||||
sb.Draw(_atlas.Pixel, Bounds, fill);
|
||||
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);
|
||||
var s = _font.MeasureString(_label);
|
||||
_font.DrawText(sb, _label,
|
||||
new Vector2(Bounds.X + (Bounds.Width - s.X) / 2f, Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f),
|
||||
_positive ? CodexColors.Seal : CodexColors.InkSoft);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chip variant that drives the screen's popover when hovered. Acts as a
|
||||
/// thin wrapper around <see cref="CodexChip"/> plus a popover-show side
|
||||
/// effect during update.
|
||||
/// </summary>
|
||||
internal sealed class HoverableChip : CodexWidget
|
||||
{
|
||||
private readonly CodexChip _chip;
|
||||
private readonly CodexHoverPopover _popover;
|
||||
private readonly string _title, _body;
|
||||
private readonly string? _tag;
|
||||
private readonly bool _detriment;
|
||||
|
||||
public HoverableChip(CodexAtlas atlas, CodexHoverPopover popover, string text,
|
||||
string popTitle, string popBody, string? popTag, ChipKind kind)
|
||||
{
|
||||
_chip = new CodexChip(text, kind, atlas, popTitle, popBody, popTag);
|
||||
_popover = popover;
|
||||
_title = popTitle;
|
||||
_body = popBody;
|
||||
_tag = popTag;
|
||||
_detriment = kind == ChipKind.TraitDetriment;
|
||||
}
|
||||
|
||||
public System.Action? OnClick { get => _chip.OnClick; set => _chip.OnClick = value; }
|
||||
|
||||
protected override Point MeasureCore(Point available) => _chip.Measure(available);
|
||||
protected override void ArrangeCore(Rectangle bounds) => _chip.Arrange(bounds);
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
_chip.Update(gt, input);
|
||||
if (_chip.IsHovered && !string.IsNullOrEmpty(_body))
|
||||
_popover.Show(_chip.Bounds, _title, _body, _tag, _detriment);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt) => _chip.Draw(sb, gt);
|
||||
}
|
||||
|
||||
/// <summary>Reusable page-intro block: small mono eyebrow, large display title, body paragraph.</summary>
|
||||
public static class StepCommon
|
||||
{
|
||||
public static CodexWidget PageIntro(string eyebrow, string title, string body)
|
||||
{
|
||||
var col = new Column { Spacing = 6 };
|
||||
col.Add(new CodexLabel(eyebrow.ToUpperInvariant(), CodexFonts.MonoTag, CodexColors.InkMute));
|
||||
col.Add(new CodexLabel(title, CodexFonts.DisplayLarge, CodexColors.Ink));
|
||||
col.Add(new CodexLabel(body, CodexFonts.SerifBody, CodexColors.InkSoft));
|
||||
return new Padding(col, new Thickness(0, 0, 0, 14));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Theriapolis.Core.Data;
|
||||
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 III — Calling. Two-column grid of class cards. Cards recommended
|
||||
/// for the chosen clade get a small "★ Suits Clade" badge. Level-1
|
||||
/// features render as inline trait chips with hover popovers.
|
||||
/// </summary>
|
||||
public static class StepClass
|
||||
{
|
||||
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover)
|
||||
{
|
||||
var col = new Column { Spacing = 14 };
|
||||
col.Add(StepCommon.PageIntro(
|
||||
"Folio III — Of Vocations",
|
||||
"Choose your Calling",
|
||||
"Eight callings exist within the Covenant. Each shapes how you fight, treat, parley, or unmake the world."));
|
||||
|
||||
var grid = new Grid { Columns = 2 };
|
||||
foreach (var c in s.Classes) grid.Add(BuildCard(s, atlas, popover, c));
|
||||
col.Add(grid);
|
||||
return col;
|
||||
}
|
||||
|
||||
private static CodexWidget BuildCard(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover, ClassDef c)
|
||||
{
|
||||
var content = new Column { Spacing = 8 };
|
||||
|
||||
var titleRow = new Row { Spacing = 8, VAlignChildren = VAlign.Middle };
|
||||
titleRow.Add(new CodexLabel(c.Name, CodexFonts.DisplayMedium, CodexColors.Ink));
|
||||
bool suits = s.Clade is not null && CodexCopy.IsSuited(c.Id, s.Clade.Id);
|
||||
if (suits) titleRow.Add(new RecBadge(atlas));
|
||||
content.Add(titleRow);
|
||||
|
||||
content.Add(new CodexLabel(
|
||||
$"D{c.HitDie} · PRIMARY {string.Join("/", c.PrimaryAbility)} · SAVES {string.Join("/", c.Saves)}",
|
||||
CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
|
||||
// Level-1 features as trait chips
|
||||
var lvl1 = System.Array.Find(c.LevelTable, e => e.Level == 1);
|
||||
if (lvl1 is not null)
|
||||
{
|
||||
var chips = new WrapRow();
|
||||
foreach (var k in lvl1.Features)
|
||||
{
|
||||
if (k == "asi" || k == "subclass_select" || k == "subclass_feature") continue;
|
||||
if (!c.FeatureDefinitions.TryGetValue(k, out var fd)) continue;
|
||||
chips.Add(new HoverableChip(atlas, popover, fd.Name, fd.Name, fd.Description, fd.Kind?.ToUpperInvariant(), ChipKind.Trait));
|
||||
}
|
||||
content.Add(chips);
|
||||
}
|
||||
|
||||
content.Add(new CodexLabel(
|
||||
$"PICKS {c.SkillsChoose} SKILL{(c.SkillsChoose > 1 ? "S" : "")} · ARMOR: {string.Join(", ", c.ArmorProficiencies)}",
|
||||
CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
|
||||
return new CodexCard(atlas, content, s.Class == c,
|
||||
onClick: () =>
|
||||
{
|
||||
// If switching class, drop any previously-picked skills
|
||||
// that aren't on the new class's option list — but never
|
||||
// auto-pick. The Sign step must stay locked until the
|
||||
// user explicitly visits the Skills folio.
|
||||
if (s.Class != c)
|
||||
{
|
||||
s.Class = c;
|
||||
var allowed = new System.Collections.Generic.HashSet<string>(c.SkillOptions, System.StringComparer.OrdinalIgnoreCase);
|
||||
var bgLocked = new System.Collections.Generic.HashSet<string>(
|
||||
s.Background?.SkillProficiencies ?? System.Array.Empty<string>(),
|
||||
System.StringComparer.OrdinalIgnoreCase);
|
||||
s.ChosenSkills.RemoveWhere(sk =>
|
||||
{
|
||||
string raw = sk.ToString().ToLowerInvariant();
|
||||
return !allowed.Contains(raw) || bgLocked.Contains(raw);
|
||||
});
|
||||
}
|
||||
s.InvalidateLayout();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RecBadge : CodexWidget
|
||||
{
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly FontStashSharp.SpriteFontBase _font = CodexFonts.MonoTagSmall;
|
||||
private const string Text = "★ SUITS CLADE";
|
||||
|
||||
public RecBadge(CodexAtlas atlas) { _atlas = atlas; }
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
var s = _font.MeasureString(Text);
|
||||
return new Point((int)s.X + 12, (int)System.MathF.Ceiling(_font.LineHeight) + 6);
|
||||
}
|
||||
protected override void ArrangeCore(Microsoft.Xna.Framework.Rectangle bounds) { }
|
||||
|
||||
public override void Draw(Microsoft.Xna.Framework.Graphics.SpriteBatch sb, Microsoft.Xna.Framework.GameTime gt)
|
||||
{
|
||||
var fill = new Microsoft.Xna.Framework.Color(CodexColors.Gild.R, CodexColors.Gild.G, CodexColors.Gild.B, (byte)20);
|
||||
sb.Draw(_atlas.Pixel, Bounds, fill);
|
||||
sb.Draw(_atlas.Pixel, new Microsoft.Xna.Framework.Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 1), CodexColors.Gild);
|
||||
sb.Draw(_atlas.Pixel, new Microsoft.Xna.Framework.Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1), CodexColors.Gild);
|
||||
sb.Draw(_atlas.Pixel, new Microsoft.Xna.Framework.Rectangle(Bounds.X, Bounds.Y, 1, Bounds.Height), CodexColors.Gild);
|
||||
sb.Draw(_atlas.Pixel, new Microsoft.Xna.Framework.Rectangle(Bounds.Right - 1, Bounds.Y, 1, Bounds.Height), CodexColors.Gild);
|
||||
var s = _font.MeasureString(Text);
|
||||
_font.DrawText(sb, Text,
|
||||
new Microsoft.Xna.Framework.Vector2(Bounds.X + (Bounds.Width - s.X) / 2f,
|
||||
Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f),
|
||||
CodexColors.Gild);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
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 VI — Skills. Two-column grid of <see cref="SkillGroupPanel"/>, one
|
||||
/// per ability. Each panel lists every skill governed by that ability with
|
||||
/// its current <see cref="CheckboxState"/>. Background-sealed skills are
|
||||
/// pre-checked and locked; class-pickable skills are toggleable up to the
|
||||
/// class's <c>SkillsChoose</c> count.
|
||||
/// </summary>
|
||||
public static class StepSkills
|
||||
{
|
||||
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover)
|
||||
{
|
||||
var col = new Column { Spacing = 14 };
|
||||
col.Add(StepCommon.PageIntro(
|
||||
"Folio VI — Of Trained Hands",
|
||||
"Choose your Skills",
|
||||
$"Your background grants {s.Background?.SkillProficiencies.Length ?? 0} skill(s) automatically (sealed). From your calling's offered list, choose {s.Class?.SkillsChoose ?? 0} more."));
|
||||
|
||||
// Meta line
|
||||
var meta = new Row { Spacing = 16, VAlignChildren = VAlign.Middle, Padding = new Thickness(14, 12, 14, 12) };
|
||||
meta.Add(new CodexLabel($"{s.ChosenSkills.Count} / {s.Class?.SkillsChoose ?? 0} CHOSEN", CodexFonts.DisplaySmall, CodexColors.Ink));
|
||||
meta.Add(new CodexLabel($"+ {s.Background?.SkillProficiencies.Length ?? 0} SEALED BY BACKGROUND",
|
||||
CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
col.Add(new CodexPanel(atlas, meta));
|
||||
|
||||
// Skill groups by ability
|
||||
var bgLocked = new System.Collections.Generic.HashSet<string>(
|
||||
s.Background?.SkillProficiencies ?? System.Array.Empty<string>(),
|
||||
System.StringComparer.OrdinalIgnoreCase);
|
||||
var classOpts = new System.Collections.Generic.HashSet<string>(
|
||||
s.Class?.SkillOptions ?? System.Array.Empty<string>(),
|
||||
System.StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var groupedByAbility = new System.Collections.Generic.Dictionary<AbilityId, System.Collections.Generic.List<string>>();
|
||||
foreach (var ab in CodexCopy.AbilityOrder) groupedByAbility[ab] = new();
|
||||
foreach (var skillId in CodexCharacterCreationScreen.AllSkillIds())
|
||||
groupedByAbility[CodexCopy.SkillAbility(skillId)].Add(skillId);
|
||||
|
||||
var grid = new Grid { Columns = 2 };
|
||||
foreach (var ab in CodexCopy.AbilityOrder)
|
||||
grid.Add(BuildGroup(s, atlas, popover, ab, groupedByAbility[ab], bgLocked, classOpts));
|
||||
col.Add(grid);
|
||||
return col;
|
||||
}
|
||||
|
||||
private static CodexWidget BuildGroup(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover,
|
||||
AbilityId ab, System.Collections.Generic.List<string> skillIds,
|
||||
System.Collections.Generic.HashSet<string> bgLocked,
|
||||
System.Collections.Generic.HashSet<string> classOpts)
|
||||
{
|
||||
var inner = new Column { Spacing = 4 };
|
||||
inner.Add(new CodexLabel(CodexCopy.AbilityLabels[ab].ToUpperInvariant(),
|
||||
CodexFonts.DisplaySmall, CodexColors.Ink));
|
||||
foreach (var skillId in skillIds)
|
||||
{
|
||||
bool fromBg = bgLocked.Contains(skillId);
|
||||
bool fromClass = classOpts.Contains(skillId);
|
||||
bool checkedNow;
|
||||
try { checkedNow = s.ChosenSkills.Contains(SkillIdExtensions.FromJson(skillId)); }
|
||||
catch { checkedNow = false; }
|
||||
|
||||
var state = fromBg ? CheckboxState.LockedFromBg
|
||||
: checkedNow ? CheckboxState.Checked
|
||||
: !fromClass ? CheckboxState.Unavailable
|
||||
: CheckboxState.Default;
|
||||
|
||||
string sourceTag = fromBg ? "BACKGROUND" : (fromClass ? "CLASS" : "—");
|
||||
var row = new CodexCheckboxRow(CodexCopy.SkillName(skillId), sourceTag, state, atlas);
|
||||
string sid = skillId;
|
||||
row.OnClick = () =>
|
||||
{
|
||||
SkillId enumId;
|
||||
try { enumId = SkillIdExtensions.FromJson(sid); } catch { return; }
|
||||
if (s.ChosenSkills.Contains(enumId)) s.ChosenSkills.Remove(enumId);
|
||||
else if (s.ChosenSkills.Count < (s.Class?.SkillsChoose ?? 0)) s.ChosenSkills.Add(enumId);
|
||||
s.InvalidateLayout();
|
||||
};
|
||||
row.OnHover = () =>
|
||||
popover.Show(row.Bounds,
|
||||
CodexCopy.SkillName(sid),
|
||||
CodexCopy.SkillDescription(sid),
|
||||
CodexCopy.SkillAbility(sid).ToString());
|
||||
inner.Add(row);
|
||||
}
|
||||
return new CodexPanel(atlas, inner) { Inset = new Thickness(14, 12, 16, 12) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Theriapolis.Core.Data;
|
||||
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 II — Species. Card grid filtered to the selected clade. Each card
|
||||
/// shows name + size + base speed + ability mods + traits.
|
||||
/// </summary>
|
||||
public static class StepSpecies
|
||||
{
|
||||
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover)
|
||||
{
|
||||
var col = new Column { Spacing = 14 };
|
||||
col.Add(StepCommon.PageIntro(
|
||||
$"Folio II — Of Lineage within {s.Clade?.Name ?? "—"}",
|
||||
"Choose your Species",
|
||||
"Within every clade are kindreds — different statures, ranges, and inheritances. The species refines what the clade began."));
|
||||
|
||||
var grid = new Grid { Columns = 3 };
|
||||
foreach (var sp in s.AllSpecies.Where(x => s.Clade is null || x.CladeId == s.Clade.Id))
|
||||
grid.Add(BuildCard(s, atlas, popover, sp));
|
||||
col.Add(grid);
|
||||
return col;
|
||||
}
|
||||
|
||||
private static CodexWidget BuildCard(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover, SpeciesDef sp)
|
||||
{
|
||||
var content = new Column { Spacing = 8 };
|
||||
content.Add(new CodexLabel(sp.Name, CodexFonts.DisplayMedium, CodexColors.Ink));
|
||||
content.Add(new CodexLabel($"{CodexCopy.SizeLabel(sp.Size).ToUpperInvariant()} · {sp.BaseSpeedFt} FT.",
|
||||
CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
|
||||
if (sp.AbilityMods.Count > 0)
|
||||
{
|
||||
var mods = new WrapRow();
|
||||
foreach (var kv in sp.AbilityMods) mods.Add(new ModChipMini(atlas, kv.Key, kv.Value));
|
||||
content.Add(mods);
|
||||
}
|
||||
|
||||
if (sp.Traits.Length > 0 || sp.Detriments.Length > 0)
|
||||
{
|
||||
var traits = new WrapRow();
|
||||
foreach (var t in sp.Traits) traits.Add(new HoverableChip(atlas, popover, t.Name, t.Name, t.Description, null, ChipKind.Trait));
|
||||
foreach (var t in sp.Detriments) traits.Add(new HoverableChip(atlas, popover, t.Name, t.Name, t.Description, "DETRIMENT", ChipKind.TraitDetriment));
|
||||
content.Add(traits);
|
||||
}
|
||||
|
||||
return new CodexCard(atlas, content, s.Species == sp,
|
||||
onClick: () => { s.Species = sp; s.InvalidateLayout(); });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
using Theriapolis.Game.CodexUI.Drag;
|
||||
using Theriapolis.Game.CodexUI.Widgets;
|
||||
using Theriapolis.Game.CodexUI.Screens;
|
||||
using Theriapolis.Game.UI;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Step V — Abilities. Method tabs at the top, dashed-bordered pool of
|
||||
/// draggable value tiles below, six ability rows (drop targets) below
|
||||
/// that. The right side of the pool row hosts the inline action buttons:
|
||||
/// Reroll (roll mode only), Auto-assign, Clear.
|
||||
/// </summary>
|
||||
public static class StepStats
|
||||
{
|
||||
public static CodexWidget Build(CodexCharacterCreationScreen s, CodexAtlas atlas, CodexHoverPopover popover, DragDropController drag)
|
||||
{
|
||||
var col = new Column { Spacing = 14 };
|
||||
col.Add(StepCommon.PageIntro(
|
||||
"Folio V — Of Aptitudes",
|
||||
"Set your Abilities",
|
||||
"Six numbers describe what your body and mind can do. Drag values from the pool onto the abilities — or click a value, then click an ability."));
|
||||
|
||||
// Method tabs
|
||||
var tabs = new Row { Spacing = 0 };
|
||||
tabs.Add(new MethodTab("Standard Array", !s.UseRoll, atlas,
|
||||
() => { s.UseRoll = false; s.InitStandardArrayPool(); s.InvalidateLayout(); }));
|
||||
tabs.Add(new MethodTab("Roll 4d6 — drop lowest", s.UseRoll, atlas,
|
||||
() => { s.UseRoll = true; s.RollAndPool(); s.InvalidateLayout(); }));
|
||||
col.Add(tabs);
|
||||
|
||||
// Pool row (with action buttons)
|
||||
col.Add(new PoolBox(s, atlas, drag));
|
||||
|
||||
// Roll history
|
||||
if (s.UseRoll && s.StatHistory.Count > 1)
|
||||
{
|
||||
string hist = string.Join(" ", s.StatHistory.Take(s.StatHistory.Count - 1).TakeLast(3).Select(h => "[" + string.Join(", ", h) + "]"));
|
||||
col.Add(new CodexLabel("Previous rolls: " + hist, CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
}
|
||||
|
||||
// Six ability rows
|
||||
foreach (var ab in CodexCopy.AbilityOrder)
|
||||
{
|
||||
var row = new CodexAbilityRow(ab, atlas, drag)
|
||||
{
|
||||
Assigned = s.StatAssign.TryGetValue(ab, out var v) ? v : (int?)null,
|
||||
Bonus = s.TotalBonus(ab),
|
||||
LongName = CodexCopy.AbilityLabels[ab],
|
||||
IsPrimary = s.IsPrimary(ab),
|
||||
BonusSourceText = ComposeBonusSourceText(s, ab),
|
||||
};
|
||||
row.OnSlotClick = () =>
|
||||
{
|
||||
if (row.Assigned is int existing)
|
||||
{
|
||||
s.StatPool.Add(existing);
|
||||
s.StatAssign.Remove(ab);
|
||||
s.PendingPoolIdx = null;
|
||||
s.InvalidateLayout();
|
||||
}
|
||||
else if (s.PendingPoolIdx is int pidx && pidx < s.StatPool.Count)
|
||||
{
|
||||
s.StatAssign[ab] = s.StatPool[pidx];
|
||||
s.StatPool.RemoveAt(pidx);
|
||||
s.PendingPoolIdx = null;
|
||||
s.InvalidateLayout();
|
||||
}
|
||||
};
|
||||
row.OnDragStart = payload => drag.BeginDrag(payload, (sb, p) => DrawDieGhost(sb, atlas, p, payload.Value));
|
||||
col.Add(row);
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
private static string ComposeBonusSourceText(CodexCharacterCreationScreen s, AbilityId ab)
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
int cm = s.CladeMod(ab);
|
||||
int sm = s.SpeciesMod(ab);
|
||||
if (cm != 0) parts.Add($"{s.Clade?.Name ?? "Clade"} {(cm >= 0 ? "+" : "")}{cm}");
|
||||
if (sm != 0) parts.Add($"{s.Species?.Name ?? "Species"} {(sm >= 0 ? "+" : "")}{sm}");
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
private static void DrawDieGhost(SpriteBatch sb, CodexAtlas atlas, Point cursor, int value)
|
||||
{
|
||||
var rect = new Rectangle(cursor.X - 28, cursor.Y - 28, 56, 56);
|
||||
var border = CodexColors.Gild;
|
||||
sb.Draw(atlas.Pixel, rect, new Color(CodexColors.Bg.R, CodexColors.Bg.G, CodexColors.Bg.B, (byte)200));
|
||||
sb.Draw(atlas.Pixel, new Rectangle(rect.X, rect.Y, rect.Width, 1), border);
|
||||
sb.Draw(atlas.Pixel, new Rectangle(rect.X, rect.Bottom - 1, rect.Width, 1), border);
|
||||
sb.Draw(atlas.Pixel, new Rectangle(rect.X, rect.Y, 1, rect.Height), border);
|
||||
sb.Draw(atlas.Pixel, new Rectangle(rect.Right - 1, rect.Y, 1, rect.Height), border);
|
||||
var font = CodexFonts.DisplayMedium;
|
||||
string label = value.ToString();
|
||||
var sz = font.MeasureString(label);
|
||||
font.DrawText(sb, label, new Vector2(rect.X + (rect.Width - sz.X) / 2f, rect.Y + (rect.Height - font.LineHeight) / 2f), CodexColors.Ink);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MethodTab : CodexWidget
|
||||
{
|
||||
private readonly string _label;
|
||||
private readonly bool _active;
|
||||
private readonly System.Action _onClick;
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly FontStashSharp.SpriteFontBase _font = CodexFonts.DisplaySmall;
|
||||
private bool _hovered;
|
||||
|
||||
public MethodTab(string label, bool active, CodexAtlas atlas, System.Action onClick)
|
||||
{
|
||||
_label = label;
|
||||
_active = active;
|
||||
_atlas = atlas;
|
||||
_onClick = onClick;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
var s = _font.MeasureString(_label);
|
||||
return new Point((int)s.X + 36, (int)System.MathF.Ceiling(_font.LineHeight) + 20);
|
||||
}
|
||||
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)
|
||||
{
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1), CodexColors.Rule);
|
||||
if (_active)
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 2, Bounds.Width, 2), CodexColors.Gild);
|
||||
var color = _active ? CodexColors.Ink : (_hovered ? CodexColors.Gild : CodexColors.InkMute);
|
||||
var s = _font.MeasureString(_label);
|
||||
_font.DrawText(sb, _label, new Vector2(Bounds.X + (Bounds.Width - s.X) / 2f,
|
||||
Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f - 2),
|
||||
color);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pool widget that holds the draggable value tiles + the inline action
|
||||
/// buttons. Acts as a drop target so values dragged out of a slot can land
|
||||
/// back in the pool.
|
||||
/// </summary>
|
||||
internal sealed class PoolBox : CodexWidget
|
||||
{
|
||||
private readonly CodexCharacterCreationScreen _s;
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly DragDropController _drag;
|
||||
private readonly System.Collections.Generic.List<CodexPoolDie> _dice = new();
|
||||
private readonly System.Collections.Generic.List<CodexButton> _actions = new();
|
||||
|
||||
public PoolBox(CodexCharacterCreationScreen s, CodexAtlas atlas, DragDropController drag)
|
||||
{
|
||||
_s = s;
|
||||
_atlas = atlas;
|
||||
_drag = drag;
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
_dice.Clear();
|
||||
for (int i = 0; i < _s.StatPool.Count; i++)
|
||||
{
|
||||
int idx = i;
|
||||
int v = _s.StatPool[i];
|
||||
var die = new CodexPoolDie(v, idx, _atlas, _drag)
|
||||
{
|
||||
IsSelected = _s.PendingPoolIdx == idx,
|
||||
};
|
||||
die.OnDragStart = _ => { /* drag begin handled inside the die */ };
|
||||
die.OnClick = () => { _s.PendingPoolIdx = (_s.PendingPoolIdx == idx ? null : (int?)idx); _s.InvalidateLayout(); };
|
||||
_dice.Add(die);
|
||||
}
|
||||
|
||||
_actions.Clear();
|
||||
if (_s.UseRoll)
|
||||
_actions.Add(new CodexButton("Reroll", _atlas, CodexButtonVariant.Small,
|
||||
onClick: () => { _s.RollAndPool(); _s.InvalidateLayout(); }));
|
||||
var auto = new CodexButton("Auto-assign", _atlas, CodexButtonVariant.Small,
|
||||
onClick: () => { _s.AutoAssignByClassPriority(); _s.InvalidateLayout(); });
|
||||
auto.Enabled = _s.StatPool.Count > 0;
|
||||
_actions.Add(auto);
|
||||
var clear = new CodexButton("Clear", _atlas, CodexButtonVariant.Small,
|
||||
onClick: () => { _s.ClearAssignments(); _s.InvalidateLayout(); });
|
||||
clear.Enabled = _s.StatAssign.Count > 0;
|
||||
_actions.Add(clear);
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
Rebuild();
|
||||
return new Point(available.X, 90);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
// Pool dice on the left, actions on the right.
|
||||
int x = bounds.X + 14;
|
||||
int y = bounds.Y + (bounds.Height - 56) / 2;
|
||||
foreach (var d in _dice)
|
||||
{
|
||||
var s = d.Measure(new Point(56, 56));
|
||||
d.Arrange(new Rectangle(x, y, s.X, s.Y));
|
||||
x += s.X + CodexDensity.ColGap;
|
||||
}
|
||||
// Right-aligned action stack.
|
||||
int rightX = bounds.X + bounds.Width - 14;
|
||||
for (int i = _actions.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var a = _actions[i];
|
||||
var s = a.Measure(new Point(160, 32));
|
||||
rightX -= s.X;
|
||||
a.Arrange(new Rectangle(rightX, y + (56 - s.Y) / 2, s.X, s.Y));
|
||||
rightX -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
foreach (var d in _dice) d.Update(gt, input);
|
||||
foreach (var a in _actions) a.Update(gt, input);
|
||||
if (_drag.IsDragging) _drag.RegisterTarget("pool", Bounds);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
bool over = _drag.IsDragging && Bounds.Contains(_drag.CursorPosition);
|
||||
var fill = over
|
||||
? new Color(CodexColors.Seal.R, CodexColors.Seal.G, CodexColors.Seal.B, (byte)20)
|
||||
: new Color(CodexColors.Gild.R, CodexColors.Gild.G, CodexColors.Gild.B, (byte)8);
|
||||
sb.Draw(_atlas.Pixel, Bounds, fill);
|
||||
// Dashed border (4-px dashes / 3-px gaps)
|
||||
var border = over ? CodexColors.Seal : CodexColors.Rule;
|
||||
for (int x = Bounds.X; x < Bounds.Right; x += 7)
|
||||
{
|
||||
int w = System.Math.Min(4, Bounds.Right - x);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(x, Bounds.Y, w, 1), border);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(x, Bounds.Bottom - 1, w, 1), border);
|
||||
}
|
||||
for (int y = Bounds.Y; y < Bounds.Bottom; y += 7)
|
||||
{
|
||||
int h = System.Math.Min(4, Bounds.Bottom - y);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, y, 1, h), border);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.Right - 1, y, 1, h), border);
|
||||
}
|
||||
|
||||
if (_dice.Count == 0)
|
||||
{
|
||||
var font = CodexFonts.MonoTagSmall;
|
||||
string msg = "ALL VALUES ASSIGNED. DRAG FROM A SLOT TO RETURN.";
|
||||
var s = font.MeasureString(msg);
|
||||
font.DrawText(sb, msg,
|
||||
new Vector2(Bounds.X + 14, Bounds.Y + (Bounds.Height - font.LineHeight) / 2f),
|
||||
CodexColors.InkMute);
|
||||
}
|
||||
foreach (var d in _dice) d.Draw(sb, gt);
|
||||
foreach (var a in _actions) a.Draw(sb, gt);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user