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,336 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly-typed asset table for the codex aesthetic. Each property is a
|
||||
/// texture used by one or more widgets — see §6 of the implementation plan
|
||||
/// for the full list. <see cref="LoadAll"/> looks for PNGs under
|
||||
/// <c>Content/Gfx/codex/</c> first; any missing slots fall back to a
|
||||
/// procedural placeholder generated in code so the screen still renders
|
||||
/// before art is authored.
|
||||
///
|
||||
/// Procedural placeholders aim to capture *structure* (9-slice shape,
|
||||
/// corner/edge sizes, broad colour) so layout work can proceed against
|
||||
/// them. Final art replaces them one by one.
|
||||
/// </summary>
|
||||
public sealed class CodexAtlas
|
||||
{
|
||||
public Texture2D Pixel = null!; // 1×1 white — flat-fill helper
|
||||
|
||||
public Texture2D ParchmentBg = null!; // 256×256 tileable
|
||||
public Texture2D ParchmentCard = null!; // 96×96 9-slice (24 px corners)
|
||||
public Texture2D GildFrame = null!; // 64×64 9-slice card border
|
||||
public Texture2D GildFrameSelected = null!; // 64×64 9-slice — heavier gild
|
||||
public Texture2D GildButtonPrimary = null!; // 96×32 9-slice
|
||||
public Texture2D InkButtonGhost = null!; // 96×32 9-slice
|
||||
public Texture2D WaxSeal = null!; // 64×64 sprite
|
||||
public Texture2D OrnamentDiamond = null!; // 16×16 sprite
|
||||
|
||||
public Texture2D StepperLocked = null!;
|
||||
public Texture2D StepperActive = null!;
|
||||
public Texture2D StepperDone = null!;
|
||||
|
||||
public Texture2D ChipTrait = null!;
|
||||
public Texture2D ChipSkillBg = null!;
|
||||
public Texture2D ChipSkillClass = null!;
|
||||
public Texture2D ChipLanguage = null!;
|
||||
|
||||
public Texture2D PoolDie = null!;
|
||||
public Texture2D SlotEmpty = null!;
|
||||
public Texture2D SlotFilled = null!;
|
||||
public Texture2D BarTrack = null!;
|
||||
public Texture2D BarFill = null!;
|
||||
public Texture2D PopoverBg = null!;
|
||||
|
||||
public Texture2D CladeSigilCanidae = null!;
|
||||
public Texture2D CladeSigilFelidae = null!;
|
||||
public Texture2D CladeSigilMustelidae = null!;
|
||||
public Texture2D CladeSigilUrsidae = null!;
|
||||
public Texture2D CladeSigilCervidae = null!;
|
||||
public Texture2D CladeSigilBovidae = null!;
|
||||
public Texture2D CladeSigilLeporidae = null!;
|
||||
|
||||
public void LoadAll(GraphicsDevice gd, string contentRoot)
|
||||
{
|
||||
Pixel = new Texture2D(gd, 1, 1);
|
||||
Pixel.SetData(new[] { Color.White });
|
||||
|
||||
string codexDir = System.IO.Path.Combine(contentRoot, "codex");
|
||||
|
||||
ParchmentBg = LoadOrFallback(gd, codexDir, "parchment_bg.png", () => MakeParchmentTile(gd, 256, 256));
|
||||
ParchmentCard = LoadOrFallback(gd, codexDir, "parchment_card.png", () => MakeParchmentCard(gd, 96, 96));
|
||||
GildFrame = LoadOrFallback(gd, codexDir, "gild_frame.png", () => MakeGildFrame(gd, 64, 64, selected: false));
|
||||
GildFrameSelected = LoadOrFallback(gd, codexDir, "gild_frame_selected.png",() => MakeGildFrame(gd, 64, 64, selected: true));
|
||||
GildButtonPrimary = LoadOrFallback(gd, codexDir, "gild_button_primary.png",() => MakeButton(gd, 96, 32, fill: CodexColors.Seal, border: CodexColors.Seal2));
|
||||
InkButtonGhost = LoadOrFallback(gd, codexDir, "ink_button_ghost.png", () => MakeButton(gd, 96, 32, fill: CodexColors.Bg, border: CodexColors.Ink));
|
||||
WaxSeal = LoadOrFallback(gd, codexDir, "wax_seal.png", () => MakeWaxSeal(gd, 64, 64));
|
||||
OrnamentDiamond = LoadOrFallback(gd, codexDir, "ornament_diamond.png", () => MakeOrnamentDiamond(gd, 16, 16));
|
||||
|
||||
StepperLocked = LoadOrFallback(gd, codexDir, "stepper_bullet_locked.png", () => MakeStepperBullet(gd, 24, 24, CodexColors.InkMute, CodexColors.Bg));
|
||||
StepperActive = LoadOrFallback(gd, codexDir, "stepper_bullet_active.png", () => MakeStepperBullet(gd, 24, 24, CodexColors.Gild, CodexColors.Bg));
|
||||
StepperDone = LoadOrFallback(gd, codexDir, "stepper_bullet_done.png", () => MakeStepperBullet(gd, 24, 24, CodexColors.Seal, CodexColors.Bg));
|
||||
|
||||
ChipTrait = LoadOrFallback(gd, codexDir, "chip_trait.png", () => MakeChip(gd, 96, 24, CodexColors.Bg, CodexColors.Gild));
|
||||
ChipSkillBg = LoadOrFallback(gd, codexDir, "chip_skill_bg.png", () => MakeChip(gd, 96, 24, CodexColors.Bg, CodexColors.Gild));
|
||||
ChipSkillClass = LoadOrFallback(gd, codexDir, "chip_skill_class.png", () => MakeChip(gd, 96, 24, CodexColors.Bg, CodexColors.Seal));
|
||||
ChipLanguage = LoadOrFallback(gd, codexDir, "chip_language.png", () => MakeChip(gd, 96, 24, CodexColors.Bg, CodexColors.Rule));
|
||||
|
||||
PoolDie = LoadOrFallback(gd, codexDir, "pool_die.png", () => MakeButton(gd, 56, 56, CodexColors.Bg, CodexColors.Rule));
|
||||
SlotEmpty = LoadOrFallback(gd, codexDir, "slot_empty.png", () => MakeSlot(gd, 64, 44, dashed: true));
|
||||
SlotFilled = LoadOrFallback(gd, codexDir, "slot_filled.png",() => MakeSlot(gd, 64, 44, dashed: false));
|
||||
BarTrack = LoadOrFallback(gd, codexDir, "bar_track.png", () => MakeBar(gd, 16, 8, fill: false));
|
||||
BarFill = LoadOrFallback(gd, codexDir, "bar_fill.png", () => MakeBar(gd, 16, 8, fill: true));
|
||||
PopoverBg = LoadOrFallback(gd, codexDir, "popover_bg.png", () => MakePopoverBg(gd, 96, 96));
|
||||
|
||||
// Clade sigils — placeholder = circular badge with stylised initial
|
||||
CladeSigilCanidae = LoadOrFallback(gd, codexDir, "clade_sigil_canidae.png", () => MakeSigil(gd, 48, 'C'));
|
||||
CladeSigilFelidae = LoadOrFallback(gd, codexDir, "clade_sigil_felidae.png", () => MakeSigil(gd, 48, 'F'));
|
||||
CladeSigilMustelidae = LoadOrFallback(gd, codexDir, "clade_sigil_mustelidae.png", () => MakeSigil(gd, 48, 'M'));
|
||||
CladeSigilUrsidae = LoadOrFallback(gd, codexDir, "clade_sigil_ursidae.png", () => MakeSigil(gd, 48, 'U'));
|
||||
CladeSigilCervidae = LoadOrFallback(gd, codexDir, "clade_sigil_cervidae.png", () => MakeSigil(gd, 48, 'D'));
|
||||
CladeSigilBovidae = LoadOrFallback(gd, codexDir, "clade_sigil_bovidae.png", () => MakeSigil(gd, 48, 'B'));
|
||||
CladeSigilLeporidae = LoadOrFallback(gd, codexDir, "clade_sigil_leporidae.png", () => MakeSigil(gd, 48, 'L'));
|
||||
}
|
||||
|
||||
public Texture2D SigilFor(string cladeId) => cladeId switch
|
||||
{
|
||||
"canidae" => CladeSigilCanidae,
|
||||
"felidae" => CladeSigilFelidae,
|
||||
"mustelidae" => CladeSigilMustelidae,
|
||||
"ursidae" => CladeSigilUrsidae,
|
||||
"cervidae" => CladeSigilCervidae,
|
||||
"bovidae" => CladeSigilBovidae,
|
||||
"leporidae" => CladeSigilLeporidae,
|
||||
_ => CladeSigilCanidae,
|
||||
};
|
||||
|
||||
private static Texture2D LoadOrFallback(GraphicsDevice gd, string dir, string name, System.Func<Texture2D> fallback)
|
||||
{
|
||||
string path = System.IO.Path.Combine(dir, name);
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
using var fs = System.IO.File.OpenRead(path);
|
||||
return Texture2D.FromStream(gd, fs);
|
||||
}
|
||||
return fallback();
|
||||
}
|
||||
|
||||
// ── Procedural placeholder generators ────────────────────────────────
|
||||
// Each returns a Texture2D the matching widget can use. They aim for
|
||||
// structural correctness (right size, right 9-slice insets) rather
|
||||
// than the final illuminated-codex aesthetic.
|
||||
|
||||
private static Texture2D MakeParchmentTile(GraphicsDevice gd, int w, int h)
|
||||
{
|
||||
var pixels = new Color[w * h];
|
||||
var rng = new System.Random(0xC0DE);
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
// Two layered radial gradients + grain noise for parchment feel.
|
||||
float dx1 = (x - w * 0.3f) / (w * 0.6f);
|
||||
float dy1 = (y - h * 0.2f) / (h * 0.4f);
|
||||
float light = MathHelper.Clamp(1f - (dx1 * dx1 + dy1 * dy1), 0f, 1f) * 0.18f;
|
||||
|
||||
float dx2 = (x - w * 0.8f) / (w * 0.45f);
|
||||
float dy2 = (y - h * 0.8f) / (h * 0.35f);
|
||||
float shade = MathHelper.Clamp(1f - (dx2 * dx2 + dy2 * dy2), 0f, 1f) * 0.10f;
|
||||
|
||||
float grain = (float)(rng.NextDouble() - 0.5) * 0.04f;
|
||||
float r = CodexColors.Bg.R / 255f + light - shade + grain;
|
||||
float g = CodexColors.Bg.G / 255f + light - shade + grain;
|
||||
float b = CodexColors.Bg.B / 255f + light - shade * 1.3f + grain;
|
||||
pixels[y * w + x] = new Color(MathHelper.Clamp(r, 0f, 1f), MathHelper.Clamp(g, 0f, 1f), MathHelper.Clamp(b, 0f, 1f));
|
||||
}
|
||||
var tex = new Texture2D(gd, w, h);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private static Texture2D MakeParchmentCard(GraphicsDevice gd, int w, int h)
|
||||
{
|
||||
var pixels = new Color[w * h];
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
// Slight top-down lighten as in `linear-gradient(180deg, rgba(255,250,235,0.05), transparent 30%)`.
|
||||
float topLift = MathHelper.Clamp(1f - y / (h * 0.3f), 0f, 1f) * 0.04f;
|
||||
var c = CodexColors.Bg2;
|
||||
pixels[y * w + x] = new Color(
|
||||
MathHelper.Clamp(c.R / 255f + topLift, 0f, 1f),
|
||||
MathHelper.Clamp(c.G / 255f + topLift, 0f, 1f),
|
||||
MathHelper.Clamp(c.B / 255f + topLift, 0f, 1f));
|
||||
}
|
||||
var tex = new Texture2D(gd, w, h);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private static Texture2D MakeGildFrame(GraphicsDevice gd, int w, int h, bool selected)
|
||||
{
|
||||
var pixels = new Color[w * h];
|
||||
var border = selected ? CodexColors.Seal : CodexColors.Rule;
|
||||
var glow = selected ? CodexColors.CardSelectedHalo : CodexColors.CardHoverHalo;
|
||||
int thickness = selected ? 2 : 1;
|
||||
// 1-px border around the edge, plus an inner glow stripe when selected.
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int distFromEdge = System.Math.Min(System.Math.Min(x, y), System.Math.Min(w - 1 - x, h - 1 - y));
|
||||
if (distFromEdge < thickness) pixels[y * w + x] = border;
|
||||
else if (selected && distFromEdge < thickness + 2) pixels[y * w + x] = glow;
|
||||
else pixels[y * w + x] = Color.Transparent;
|
||||
}
|
||||
var tex = new Texture2D(gd, w, h);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private static Texture2D MakeButton(GraphicsDevice gd, int w, int h, Color fill, Color border)
|
||||
{
|
||||
var pixels = new Color[w * h];
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int distFromEdge = System.Math.Min(System.Math.Min(x, y), System.Math.Min(w - 1 - x, h - 1 - y));
|
||||
pixels[y * w + x] = distFromEdge < 1 ? border : fill;
|
||||
}
|
||||
var tex = new Texture2D(gd, w, h);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private static Texture2D MakeWaxSeal(GraphicsDevice gd, int w, int h)
|
||||
{
|
||||
var pixels = new Color[w * h];
|
||||
float cx = w / 2f, cy = h / 2f, r = w / 2f - 2;
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
float dx = x - cx, dy = y - cy;
|
||||
float d = (float)System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (d > r) pixels[y * w + x] = Color.Transparent;
|
||||
else if (d > r - 1.5) pixels[y * w + x] = CodexColors.Seal2;
|
||||
else pixels[y * w + x] = Color.Lerp(CodexColors.Seal, CodexColors.Seal2, d / r);
|
||||
}
|
||||
var tex = new Texture2D(gd, w, h);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private static Texture2D MakeOrnamentDiamond(GraphicsDevice gd, int w, int h)
|
||||
{
|
||||
var pixels = new Color[w * h];
|
||||
float cx = w / 2f, cy = h / 2f;
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
float manhat = System.Math.Abs(x - cx) + System.Math.Abs(y - cy);
|
||||
pixels[y * w + x] = manhat <= cx ? CodexColors.Gild : Color.Transparent;
|
||||
}
|
||||
var tex = new Texture2D(gd, w, h);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private static Texture2D MakeStepperBullet(GraphicsDevice gd, int size, int _, Color border, Color fill)
|
||||
{
|
||||
var pixels = new Color[size * size];
|
||||
float cx = size / 2f, cy = size / 2f, r = size / 2f - 1;
|
||||
for (int y = 0; y < size; y++)
|
||||
for (int x = 0; x < size; x++)
|
||||
{
|
||||
float dx = x - cx, dy = y - cy;
|
||||
float d = (float)System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (d > r) pixels[y * size + x] = Color.Transparent;
|
||||
else if (d > r - 1.4) pixels[y * size + x] = border;
|
||||
else pixels[y * size + x] = fill;
|
||||
}
|
||||
var tex = new Texture2D(gd, size, size);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private static Texture2D MakeChip(GraphicsDevice gd, int w, int h, Color fill, Color border)
|
||||
=> MakeButton(gd, w, h, fill, border);
|
||||
|
||||
private static Texture2D MakeSlot(GraphicsDevice gd, int w, int h, bool dashed)
|
||||
{
|
||||
var pixels = new Color[w * h];
|
||||
var border = dashed ? CodexColors.InkMute : CodexColors.InkSoft;
|
||||
var fill = dashed ? Color.Transparent : new Color(180, 138, 60, 16);
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int distFromEdge = System.Math.Min(System.Math.Min(x, y), System.Math.Min(w - 1 - x, h - 1 - y));
|
||||
bool onEdge = distFromEdge < 1;
|
||||
if (onEdge)
|
||||
{
|
||||
if (dashed)
|
||||
{
|
||||
int along = (x == 0 || x == w - 1) ? y : x;
|
||||
pixels[y * w + x] = (along / 3) % 2 == 0 ? border : Color.Transparent;
|
||||
}
|
||||
else
|
||||
{
|
||||
pixels[y * w + x] = border;
|
||||
}
|
||||
}
|
||||
else pixels[y * w + x] = fill;
|
||||
}
|
||||
var tex = new Texture2D(gd, w, h);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private static Texture2D MakeBar(GraphicsDevice gd, int w, int h, bool fill)
|
||||
{
|
||||
var pixels = new Color[w * h];
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int distFromEdge = System.Math.Min(System.Math.Min(x, y), System.Math.Min(w - 1 - x, h - 1 - y));
|
||||
if (distFromEdge < 1) pixels[y * w + x] = CodexColors.Rule;
|
||||
else pixels[y * w + x] = fill ? CodexColors.Gild : CodexColors.Bg;
|
||||
}
|
||||
var tex = new Texture2D(gd, w, h);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private static Texture2D MakePopoverBg(GraphicsDevice gd, int w, int h)
|
||||
{
|
||||
var pixels = new Color[w * h];
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int distFromEdge = System.Math.Min(System.Math.Min(x, y), System.Math.Min(w - 1 - x, h - 1 - y));
|
||||
if (distFromEdge < 1) pixels[y * w + x] = CodexColors.Gild;
|
||||
else pixels[y * w + x] = CodexColors.Bg2;
|
||||
}
|
||||
var tex = new Texture2D(gd, w, h);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private static Texture2D MakeSigil(GraphicsDevice gd, int size, char letter)
|
||||
{
|
||||
var pixels = new Color[size * size];
|
||||
float cx = size / 2f, cy = size / 2f, r = size / 2f - 1;
|
||||
for (int y = 0; y < size; y++)
|
||||
for (int x = 0; x < size; x++)
|
||||
{
|
||||
float dx = x - cx, dy = y - cy;
|
||||
float d = (float)System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (d > r) pixels[y * size + x] = Color.Transparent;
|
||||
else if (d > r - 1.5) pixels[y * size + x] = CodexColors.Rule;
|
||||
else pixels[y * size + x] = Color.Lerp(CodexColors.Bg, CodexColors.Bg2, d / r);
|
||||
}
|
||||
var tex = new Texture2D(gd, size, size);
|
||||
tex.SetData(pixels);
|
||||
// Note: the letter glyph itself is drawn by the widget on top of this circle (CodexCard).
|
||||
return tex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Static font registry for CodexUI. Loaded once at game startup via
|
||||
/// <see cref="LoadAll"/>; widgets read from these fields. The slots map to
|
||||
/// the design's typographic ladder:
|
||||
/// - DisplayLarge / Medium / Small → Cinzel-Bold (fallback: Georgia Bold) — all-caps headings
|
||||
/// - SerifBody / SerifItalic → Cormorant Garamond (fallback: Georgia / Georgia Italic)
|
||||
/// - MonoTag / MonoTagSmall → JetBrains Mono (fallback: Consolas)
|
||||
///
|
||||
/// FontStashSharp loads TTF files into a runtime atlas. We try
|
||||
/// <c>Content/Fonts/</c> first (project-supplied fonts), then fall back to
|
||||
/// Windows system fonts so the game runs without bundling Cinzel/Cormorant.
|
||||
/// </summary>
|
||||
public static class CodexFonts
|
||||
{
|
||||
public static SpriteFontBase DisplayLarge = null!; // ~32 px, codex header
|
||||
public static SpriteFontBase DisplayMedium = null!; // ~22 px, h2
|
||||
public static SpriteFontBase DisplaySmall = null!; // ~16 px, h3
|
||||
public static SpriteFontBase SerifBody = null!; // ~16 px, body text
|
||||
public static SpriteFontBase SerifItalic = null!; // ~14 px, italic body / trait names
|
||||
public static SpriteFontBase MonoTag = null!; // ~10 px, eyebrow + tag text
|
||||
public static SpriteFontBase MonoTagSmall = null!; // ~9 px, sub-tags
|
||||
|
||||
private static FontSystem? _serif;
|
||||
private static FontSystem? _italic;
|
||||
private static FontSystem? _mono;
|
||||
|
||||
/// <summary>
|
||||
/// Load every font slot. Throws on failure — fonts are required for the
|
||||
/// codex screen to render anything legible.
|
||||
/// </summary>
|
||||
public static void LoadAll(GraphicsDevice gd, string contentRoot)
|
||||
{
|
||||
_serif = LoadFontSystem(contentRoot, new[] { "Fonts/Cinzel-Bold.ttf", "Fonts/CormorantGaramond-Bold.ttf" },
|
||||
new[] { @"C:\Windows\Fonts\georgiab.ttf", @"C:\Windows\Fonts\timesbd.ttf" });
|
||||
_italic = LoadFontSystem(contentRoot, new[] { "Fonts/CormorantGaramond-Italic.ttf", "Fonts/CormorantGaramond-Regular.ttf" },
|
||||
new[] { @"C:\Windows\Fonts\georgiai.ttf", @"C:\Windows\Fonts\timesi.ttf", @"C:\Windows\Fonts\georgia.ttf" });
|
||||
_mono = LoadFontSystem(contentRoot, new[] { "Fonts/JetBrainsMono-Medium.ttf" },
|
||||
new[] { @"C:\Windows\Fonts\consola.ttf", @"C:\Windows\Fonts\consolab.ttf", @"C:\Windows\Fonts\cour.ttf" });
|
||||
|
||||
DisplayLarge = _serif.GetFont(32f);
|
||||
DisplayMedium = _serif.GetFont(22f);
|
||||
DisplaySmall = _serif.GetFont(16f);
|
||||
SerifBody = _italic.GetFont(15f);
|
||||
SerifItalic = _italic.GetFont(14f);
|
||||
MonoTag = _mono.GetFont(11f);
|
||||
MonoTagSmall = _mono.GetFont(9f);
|
||||
}
|
||||
|
||||
private static FontSystem LoadFontSystem(string contentRoot, string[] preferred, string[] fallbacks)
|
||||
{
|
||||
var fs = new FontSystem();
|
||||
// Try project-supplied fonts first (Content/Fonts/*).
|
||||
foreach (var rel in preferred)
|
||||
{
|
||||
string path = System.IO.Path.Combine(contentRoot, rel);
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
fs.AddFont(System.IO.File.ReadAllBytes(path));
|
||||
return fs;
|
||||
}
|
||||
}
|
||||
// Fall back to a system font — guaranteed available on the target
|
||||
// platforms (Windows Georgia/Times/Consolas; future: macOS / Linux
|
||||
// would need their own fallback list).
|
||||
foreach (var path in fallbacks)
|
||||
{
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
fs.AddFont(System.IO.File.ReadAllBytes(path));
|
||||
return fs;
|
||||
}
|
||||
}
|
||||
throw new System.IO.FileNotFoundException(
|
||||
"CodexFonts: no font found in either project Content/Fonts/ or system fallback locations. " +
|
||||
$"Preferred: {string.Join(", ", preferred)}; fallbacks: {string.Join(", ", fallbacks)}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame input snapshot used by every CodexUI widget. <see cref="Tick"/>
|
||||
/// is called once at the top of <see cref="CodexScreen.Update"/>; widgets
|
||||
/// read the resulting edge-detected event flags during their own Update pass.
|
||||
///
|
||||
/// Text input piggybacks on MonoGame's <see cref="GameWindow.TextInput"/>
|
||||
/// event — the screen subscribes during Initialize and pushes characters
|
||||
/// into <see cref="TextEnteredThisFrame"/>. Backspace, Enter and arrow keys
|
||||
/// route through <see cref="KeyJustPressed"/> instead.
|
||||
///
|
||||
/// <para>
|
||||
/// Mouse clipping: a parent widget can set <see cref="SetMouseClip"/> before
|
||||
/// updating a region of the tree, which causes <see cref="MousePosition"/>
|
||||
/// to report off-screen for any cursor outside the clip rect. Children that
|
||||
/// rely on <c>Bounds.Contains(input.MousePosition)</c> to detect hover/click
|
||||
/// then ignore events that visually land in some other layer (e.g. a
|
||||
/// stepper bar painted over a scrolled card). Without this, both the
|
||||
/// clipped widget and the chrome above it would handle the same click.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CodexInput
|
||||
{
|
||||
public Point MousePosition { get; private set; }
|
||||
public Point PreviousMousePosition { get; private set; }
|
||||
public Point MouseDelta { get; private set; }
|
||||
public bool LeftButtonDown { get; private set; }
|
||||
public bool LeftJustPressed { get; private set; }
|
||||
public bool LeftJustReleased { get; private set; }
|
||||
public bool RightJustPressed { get; private set; }
|
||||
public int ScrollDelta { get; private set; }
|
||||
|
||||
private MouseState _prev;
|
||||
private KeyboardState _prevKb;
|
||||
private KeyboardState _curKb;
|
||||
|
||||
private Rectangle? _mouseClip;
|
||||
private Point _rawMouse;
|
||||
|
||||
/// <summary>Characters typed this frame (collected from the window's TextInput event).</summary>
|
||||
public string TextEnteredThisFrame { get; private set; } = "";
|
||||
|
||||
private System.Text.StringBuilder _textBuf = new();
|
||||
|
||||
public void OnTextInput(char c) => _textBuf.Append(c);
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
var m = Mouse.GetState();
|
||||
var kb = Keyboard.GetState();
|
||||
|
||||
PreviousMousePosition = _rawMouse;
|
||||
_rawMouse = new Point(m.X, m.Y);
|
||||
MouseDelta = _rawMouse - PreviousMousePosition;
|
||||
ScrollDelta = m.ScrollWheelValue - _prev.ScrollWheelValue;
|
||||
|
||||
bool prevLeft = _prev.LeftButton == ButtonState.Pressed;
|
||||
bool curLeft = m.LeftButton == ButtonState.Pressed;
|
||||
LeftButtonDown = curLeft;
|
||||
LeftJustPressed = !prevLeft && curLeft;
|
||||
LeftJustReleased = prevLeft && !curLeft;
|
||||
|
||||
bool prevRight = _prev.RightButton == ButtonState.Pressed;
|
||||
bool curRight = m.RightButton == ButtonState.Pressed;
|
||||
RightJustPressed = !prevRight && curRight;
|
||||
|
||||
_prev = m;
|
||||
_prevKb = _curKb;
|
||||
_curKb = kb;
|
||||
|
||||
TextEnteredThisFrame = _textBuf.ToString();
|
||||
_textBuf.Clear();
|
||||
|
||||
ApplyClip();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restrict <see cref="MousePosition"/> reads to <paramref name="clipRect"/>.
|
||||
/// Outside the rect the position is reported far off-screen so widgets
|
||||
/// that hit-test by <c>Bounds.Contains</c> stop registering hover/click.
|
||||
/// Pair with <see cref="ClearMouseClip"/> after the clipped subtree's
|
||||
/// Update completes.
|
||||
/// </summary>
|
||||
public void SetMouseClip(Rectangle clipRect)
|
||||
{
|
||||
_mouseClip = clipRect;
|
||||
ApplyClip();
|
||||
}
|
||||
|
||||
public void ClearMouseClip()
|
||||
{
|
||||
_mouseClip = null;
|
||||
ApplyClip();
|
||||
}
|
||||
|
||||
/// <summary>Read the current clip rectangle, if any. Used by widgets that
|
||||
/// nest a tighter clip and need to restore the outer one after their
|
||||
/// child subtree has finished updating.</summary>
|
||||
public Rectangle? GetMouseClip() => _mouseClip;
|
||||
|
||||
private void ApplyClip()
|
||||
{
|
||||
if (_mouseClip is Rectangle r && !r.Contains(_rawMouse))
|
||||
MousePosition = new Point(-99999, -99999);
|
||||
else
|
||||
MousePosition = _rawMouse;
|
||||
}
|
||||
|
||||
public bool KeyDown(Keys k) => _curKb.IsKeyDown(k);
|
||||
public bool KeyJustPressed(Keys k) => _curKb.IsKeyDown(k) && !_prevKb.IsKeyDown(k);
|
||||
public bool KeyJustReleased(Keys k) => !_curKb.IsKeyDown(k) && _prevKb.IsKeyDown(k);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
public enum HAlign { Left, Center, Right, Stretch }
|
||||
public enum VAlign { Top, Middle, Bottom, Stretch }
|
||||
|
||||
/// <summary>
|
||||
/// Container widget that stacks its children vertically with <see cref="Spacing"/>
|
||||
/// between them. Children are laid out top-to-bottom; their horizontal alignment
|
||||
/// is governed by <see cref="HAlignChildren"/> (defaults to <see cref="HAlign.Stretch"/>).
|
||||
/// </summary>
|
||||
public sealed class Column : CodexWidget
|
||||
{
|
||||
public System.Collections.Generic.List<CodexWidget> Children { get; } = new();
|
||||
public int Spacing { get; set; } = CodexDensity.RowGap;
|
||||
public Thickness Padding { get; set; }
|
||||
public HAlign HAlignChildren { get; set; } = HAlign.Stretch;
|
||||
|
||||
public Column Add(CodexWidget c) { c.Parent = this; Children.Add(c); return this; }
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
int innerW = available.X - Padding.HorizontalSum();
|
||||
int innerH = available.Y - Padding.VerticalSum();
|
||||
int totalH = 0, maxW = 0;
|
||||
for (int i = 0; i < Children.Count; i++)
|
||||
{
|
||||
if (!Children[i].Visible) continue;
|
||||
var s = Children[i].Measure(new Point(innerW, innerH));
|
||||
totalH += s.Y;
|
||||
if (i < Children.Count - 1) totalH += Spacing;
|
||||
if (s.X > maxW) maxW = s.X;
|
||||
}
|
||||
return new Point(maxW + Padding.HorizontalSum(), totalH + Padding.VerticalSum());
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
int x = bounds.X + Padding.Left;
|
||||
int y = bounds.Y + Padding.Top;
|
||||
int innerW = bounds.Width - Padding.HorizontalSum();
|
||||
foreach (var c in Children)
|
||||
{
|
||||
if (!c.Visible) continue;
|
||||
int cw = HAlignChildren == HAlign.Stretch ? innerW : System.Math.Min(c.DesiredSize.X, innerW);
|
||||
int cx = HAlignChildren switch
|
||||
{
|
||||
HAlign.Center => x + (innerW - cw) / 2,
|
||||
HAlign.Right => x + innerW - cw,
|
||||
_ => x,
|
||||
};
|
||||
c.Arrange(new Rectangle(cx, y, cw, c.DesiredSize.Y));
|
||||
y += c.DesiredSize.Y + Spacing;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
foreach (var c in Children) if (c.Visible) c.Update(gt, input);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
foreach (var c in Children) if (c.Visible) c.Draw(sb, gt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Container widget that stacks its children horizontally. Mirrors <see cref="Column"/>
|
||||
/// along the perpendicular axis; vertical alignment via <see cref="VAlignChildren"/>.
|
||||
/// </summary>
|
||||
public sealed class Row : CodexWidget
|
||||
{
|
||||
public System.Collections.Generic.List<CodexWidget> Children { get; } = new();
|
||||
public int Spacing { get; set; } = CodexDensity.ColGap;
|
||||
public Thickness Padding { get; set; }
|
||||
public VAlign VAlignChildren { get; set; } = VAlign.Top;
|
||||
|
||||
public Row Add(CodexWidget c) { c.Parent = this; Children.Add(c); return this; }
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
int innerW = available.X - Padding.HorizontalSum();
|
||||
int innerH = available.Y - Padding.VerticalSum();
|
||||
int totalW = 0, maxH = 0;
|
||||
for (int i = 0; i < Children.Count; i++)
|
||||
{
|
||||
if (!Children[i].Visible) continue;
|
||||
var s = Children[i].Measure(new Point(innerW, innerH));
|
||||
totalW += s.X;
|
||||
if (i < Children.Count - 1) totalW += Spacing;
|
||||
if (s.Y > maxH) maxH = s.Y;
|
||||
}
|
||||
return new Point(totalW + Padding.HorizontalSum(), maxH + Padding.VerticalSum());
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
int x = bounds.X + Padding.Left;
|
||||
int y = bounds.Y + Padding.Top;
|
||||
int innerH = bounds.Height - Padding.VerticalSum();
|
||||
foreach (var c in Children)
|
||||
{
|
||||
if (!c.Visible) continue;
|
||||
int cy = VAlignChildren switch
|
||||
{
|
||||
VAlign.Middle => y + (innerH - c.DesiredSize.Y) / 2,
|
||||
VAlign.Bottom => y + innerH - c.DesiredSize.Y,
|
||||
VAlign.Stretch => y,
|
||||
_ => y,
|
||||
};
|
||||
int ch = VAlignChildren == VAlign.Stretch ? innerH : c.DesiredSize.Y;
|
||||
c.Arrange(new Rectangle(x, cy, c.DesiredSize.X, ch));
|
||||
x += c.DesiredSize.X + Spacing;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
foreach (var c in Children) if (c.Visible) c.Update(gt, input);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
foreach (var c in Children) if (c.Visible) c.Draw(sb, gt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap-flow layout: lays children left-to-right, breaking to a new row when
|
||||
/// the running width would exceed the container. Used for chip rows and the
|
||||
/// review screen's starting-kit grid.
|
||||
/// </summary>
|
||||
public sealed class WrapRow : CodexWidget
|
||||
{
|
||||
public System.Collections.Generic.List<CodexWidget> Children { get; } = new();
|
||||
public int HSpacing { get; set; } = CodexDensity.ColGap;
|
||||
public int VSpacing { get; set; } = CodexDensity.RowGap;
|
||||
|
||||
public WrapRow Add(CodexWidget c) { c.Parent = this; Children.Add(c); return this; }
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
int innerW = available.X;
|
||||
int x = 0, y = 0, rowMaxH = 0, totalW = 0;
|
||||
foreach (var c in Children)
|
||||
{
|
||||
if (!c.Visible) continue;
|
||||
var s = c.Measure(new Point(innerW, available.Y));
|
||||
if (x > 0 && x + s.X > innerW) { x = 0; y += rowMaxH + VSpacing; rowMaxH = 0; }
|
||||
x += s.X + HSpacing;
|
||||
if (s.Y > rowMaxH) rowMaxH = s.Y;
|
||||
if (x > totalW) totalW = x;
|
||||
}
|
||||
y += rowMaxH;
|
||||
return new Point(System.Math.Min(totalW, innerW), y);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
int x = bounds.X, y = bounds.Y, rowMaxH = 0;
|
||||
foreach (var c in Children)
|
||||
{
|
||||
if (!c.Visible) continue;
|
||||
if (x > bounds.X && x + c.DesiredSize.X > bounds.X + bounds.Width)
|
||||
{
|
||||
x = bounds.X;
|
||||
y += rowMaxH + VSpacing;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
c.Arrange(new Rectangle(x, y, c.DesiredSize.X, c.DesiredSize.Y));
|
||||
x += c.DesiredSize.X + HSpacing;
|
||||
if (c.DesiredSize.Y > rowMaxH) rowMaxH = c.DesiredSize.Y;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
foreach (var c in Children) if (c.Visible) c.Update(gt, input);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
foreach (var c in Children) if (c.Visible) c.Draw(sb, gt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-column grid: lays children left-to-right then wraps. Each cell gets
|
||||
/// (innerWidth - colGap*(columns-1)) / columns of horizontal space. Used for
|
||||
/// the card grid on Clade / Species / Class / Background steps.
|
||||
/// </summary>
|
||||
public sealed class Grid : CodexWidget
|
||||
{
|
||||
public System.Collections.Generic.List<CodexWidget> Children { get; } = new();
|
||||
public int Columns { get; set; } = 2;
|
||||
public int HSpacing { get; set; } = CodexDensity.CardGap;
|
||||
public int VSpacing { get; set; } = CodexDensity.CardGap;
|
||||
|
||||
public Grid Add(CodexWidget c) { c.Parent = this; Children.Add(c); return this; }
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
int cols = System.Math.Max(1, Columns);
|
||||
int cellW = (available.X - HSpacing * (cols - 1)) / cols;
|
||||
int rowH = 0, totalH = 0, col = 0;
|
||||
foreach (var c in Children)
|
||||
{
|
||||
if (!c.Visible) continue;
|
||||
var s = c.Measure(new Point(cellW, available.Y));
|
||||
if (s.Y > rowH) rowH = s.Y;
|
||||
col++;
|
||||
if (col >= cols) { totalH += rowH + VSpacing; rowH = 0; col = 0; }
|
||||
}
|
||||
if (col > 0) totalH += rowH;
|
||||
else if (totalH >= VSpacing) totalH -= VSpacing;
|
||||
return new Point(available.X, totalH);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
int cols = System.Math.Max(1, Columns);
|
||||
int cellW = (bounds.Width - HSpacing * (cols - 1)) / cols;
|
||||
int x = bounds.X, y = bounds.Y, col = 0, rowH = 0;
|
||||
foreach (var c in Children)
|
||||
{
|
||||
if (!c.Visible) continue;
|
||||
int ch = c.DesiredSize.Y;
|
||||
c.Arrange(new Rectangle(x, y, cellW, ch));
|
||||
if (ch > rowH) rowH = ch;
|
||||
col++;
|
||||
if (col >= cols) { y += rowH + VSpacing; x = bounds.X; col = 0; rowH = 0; }
|
||||
else { x += cellW + HSpacing; }
|
||||
}
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
foreach (var c in Children) if (c.Visible) c.Update(gt, input);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
foreach (var c in Children) if (c.Visible) c.Draw(sb, gt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-child decorator that adds breathing room. Combine with any widget
|
||||
/// that doesn't expose its own padding field.
|
||||
/// </summary>
|
||||
public sealed class Padding : CodexWidget
|
||||
{
|
||||
public CodexWidget? Child { get; set; }
|
||||
public Thickness Inset { get; set; }
|
||||
|
||||
public Padding(CodexWidget child, Thickness inset) { Child = child; if (child is not null) child.Parent = this; Inset = inset; }
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
if (Child is null) return new Point(Inset.HorizontalSum(), Inset.VerticalSum());
|
||||
var inner = new Point(available.X - Inset.HorizontalSum(), available.Y - Inset.VerticalSum());
|
||||
var s = Child.Measure(inner);
|
||||
return new Point(s.X + Inset.HorizontalSum(), s.Y + Inset.VerticalSum());
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
Child?.Arrange(new Rectangle(
|
||||
bounds.X + Inset.Left,
|
||||
bounds.Y + Inset.Top,
|
||||
bounds.Width - Inset.HorizontalSum(),
|
||||
bounds.Height - Inset.VerticalSum()));
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input) => Child?.Update(gt, input);
|
||||
public override void Draw(SpriteBatch sb, GameTime gt) => Child?.Draw(sb, gt);
|
||||
}
|
||||
|
||||
/// <summary>Box-model insets: independent left/top/right/bottom in pixels.</summary>
|
||||
public readonly struct Thickness
|
||||
{
|
||||
public readonly int Left, Top, Right, Bottom;
|
||||
public Thickness(int uniform) : this(uniform, uniform, uniform, uniform) { }
|
||||
public Thickness(int l, int t, int r, int b) { Left = l; Top = t; Right = r; Bottom = b; }
|
||||
public int HorizontalSum() => Left + Right;
|
||||
public int VerticalSum() => Top + Bottom;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Drag;
|
||||
using Theriapolis.Game.Screens;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for screens implemented in CodexUI. Owns the input snapshot,
|
||||
/// the root widget, the parchment background tiling, the drag-drop
|
||||
/// controller, and the popover layer. Concrete screens override
|
||||
/// <see cref="BuildRoot"/> to populate the widget tree.
|
||||
///
|
||||
/// Implements <see cref="IScreen"/> so the existing <c>ScreenManager</c>
|
||||
/// can treat CodexUI screens identically to Myra-based ones.
|
||||
/// </summary>
|
||||
public abstract class CodexScreen : IScreen
|
||||
{
|
||||
protected Game1 Game = null!;
|
||||
protected CodexInput Input { get; } = new();
|
||||
protected CodexWidget? Root;
|
||||
protected CodexAtlas Atlas { get; private set; } = null!;
|
||||
protected DragDropController DragDrop { get; } = new();
|
||||
|
||||
/// <summary>The popover layer is painted last so floating panels stay above the page.</summary>
|
||||
protected Widgets.CodexHoverPopover? Popover { get; set; }
|
||||
|
||||
private System.EventHandler<TextInputEventArgs>? _textInputHandler;
|
||||
private bool _layoutDirty = true;
|
||||
|
||||
public virtual void Initialize(Game1 game)
|
||||
{
|
||||
Game = game;
|
||||
Atlas = game.CodexAtlas;
|
||||
if (CodexFonts.DisplayLarge is null)
|
||||
throw new System.InvalidOperationException("CodexFonts.LoadAll must be called before any CodexScreen is initialized.");
|
||||
|
||||
_textInputHandler = (_, e) =>
|
||||
{
|
||||
// Filter out non-printable controls so backspace etc. routes via
|
||||
// KeyJustPressed instead of pushing into the text buffer.
|
||||
if (e.Character >= 32 && e.Character != 127) Input.OnTextInput(e.Character);
|
||||
};
|
||||
game.Window.TextInput += _textInputHandler;
|
||||
|
||||
Root = BuildRoot();
|
||||
_layoutDirty = true;
|
||||
}
|
||||
|
||||
public virtual void Deactivate()
|
||||
{
|
||||
if (_textInputHandler is not null) Game.Window.TextInput -= _textInputHandler;
|
||||
}
|
||||
|
||||
public virtual void Reactivate() { _layoutDirty = true; }
|
||||
|
||||
/// <summary>Concrete screens build the entire widget tree here. Called once after Initialize.</summary>
|
||||
protected abstract CodexWidget BuildRoot();
|
||||
|
||||
/// <summary>Force a re-measure on next frame (e.g. step changed).</summary>
|
||||
public void InvalidateLayout()
|
||||
{
|
||||
Root = BuildRoot();
|
||||
_layoutDirty = true;
|
||||
}
|
||||
|
||||
public virtual void Update(GameTime gameTime)
|
||||
{
|
||||
Input.Tick();
|
||||
if (Root is null) return;
|
||||
|
||||
if (_layoutDirty)
|
||||
{
|
||||
var vp = Game.GraphicsDevice.Viewport;
|
||||
Root.Measure(new Point(vp.Width, vp.Height));
|
||||
Root.Arrange(new Rectangle(0, 0, vp.Width, vp.Height));
|
||||
_layoutDirty = false;
|
||||
}
|
||||
Root.Update(gameTime, Input);
|
||||
Popover?.Update(gameTime, Input);
|
||||
DragDrop.Update(gameTime, Input);
|
||||
}
|
||||
|
||||
public virtual void Draw(GameTime gameTime, SpriteBatch sb)
|
||||
{
|
||||
Game.GraphicsDevice.Clear(CodexColors.BgDeep);
|
||||
sb.Begin();
|
||||
DrawBackground(sb);
|
||||
Root?.Draw(sb, gameTime);
|
||||
Popover?.Draw(sb, gameTime);
|
||||
DragDrop.Draw(sb);
|
||||
sb.End();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paint a flat <see cref="CodexColors.BgDeep"/> across the full viewport.
|
||||
/// The body widget paints its own lighter <see cref="CodexColors.Bg"/>
|
||||
/// fill on top so cards (which use the slightly-darker
|
||||
/// <see cref="CodexColors.Bg2"/>) read clearly against their immediate
|
||||
/// background. We don't tile a parchment-grain texture: a 256-px tile
|
||||
/// produces visible seams, and the procedural radial gradients made
|
||||
/// some areas brighter than the cards on top of them.
|
||||
/// </summary>
|
||||
private void DrawBackground(SpriteBatch sb)
|
||||
{
|
||||
var vp = Game.GraphicsDevice.Viewport;
|
||||
sb.Draw(Atlas.Pixel, vp.Bounds, CodexColors.BgDeep);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Color tokens for the illuminated-codex aesthetic. Values extracted from
|
||||
/// the React design at <c>_design_handoff/character_creation/from_design/index.html</c>
|
||||
/// `:root` CSS variable block. Single source of truth for every paint colour
|
||||
/// in CodexUI; widgets must read from here rather than hardcoding.
|
||||
/// </summary>
|
||||
public static class CodexColors
|
||||
{
|
||||
// Backgrounds — parchment family
|
||||
public static readonly Color Bg = HexColor(0xE8DCC0); // parchment
|
||||
public static readonly Color BgDeep = HexColor(0xC7B48B); // worn parchment shadow
|
||||
public static readonly Color Bg2 = HexColor(0xD9C9A6); // card body fill
|
||||
|
||||
// Inks — primary text gradient
|
||||
public static readonly Color Ink = HexColor(0x2B1D10); // primary serif text
|
||||
public static readonly Color InkSoft = HexColor(0x5A4527); // secondary text
|
||||
public static readonly Color InkMute = HexColor(0x8A6F48); // tertiary / placeholder
|
||||
|
||||
// Accents
|
||||
public static readonly Color Gild = HexColor(0xB48A3C); // gilded borders + select halo
|
||||
public static readonly Color GildBright = HexColor(0xD4A23E); // hover halo / highlight
|
||||
public static readonly Color Seal = HexColor(0x7A1F12); // wax-red, danger / negative bonus
|
||||
public static readonly Color Seal2 = HexColor(0x5A160C); // dark wax (selected accent)
|
||||
public static readonly Color Rule = HexColor(0x8A6F48); // hairline rule color
|
||||
|
||||
// Transparent / overlay variants
|
||||
public static readonly Color CardHoverHalo = new(180, 138, 60, 28);
|
||||
public static readonly Color CardSelectedHalo = new(122, 31, 18, 64);
|
||||
public static readonly Color PoolDieBg = new(180, 138, 60, 12);
|
||||
|
||||
private static Color HexColor(uint rgb) =>
|
||||
new((byte)((rgb >> 16) & 0xFF), (byte)((rgb >> 8) & 0xFF), (byte)(rgb & 0xFF), (byte)0xFF);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Density / spacing tokens. Mirror the React design's <c>--gap</c>, <c>--pad</c>
|
||||
/// and per-widget spacing constants. Widgets pull these instead of hardcoding
|
||||
/// magic numbers so a future "compact density" toggle can resize the whole UI.
|
||||
/// </summary>
|
||||
public static class CodexDensity
|
||||
{
|
||||
public const int RowGap = 6;
|
||||
public const int ColGap = 8;
|
||||
public const int CardPad = 18;
|
||||
public const int PanelPad = 28;
|
||||
public const int ChipPad = 6;
|
||||
public const int ButtonPad = 10;
|
||||
public const int CardWidth = 240; // minimum card width (matches design's grid-template min)
|
||||
public const int CardGap = 24;
|
||||
public const int AsideWidth = 380;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for every CodexUI widget. Layout is two-pass:
|
||||
/// 1. <see cref="Measure"/> — child reports a desired size given an
|
||||
/// available size envelope.
|
||||
/// 2. <see cref="Arrange"/> — parent places the child by writing into
|
||||
/// <see cref="Bounds"/>; the child propagates to its own children.
|
||||
///
|
||||
/// Update + Draw run after layout. Hit-testing uses screen-space
|
||||
/// <see cref="Bounds"/> directly via <see cref="ContainsPoint"/>.
|
||||
/// </summary>
|
||||
public abstract class CodexWidget
|
||||
{
|
||||
public Rectangle Bounds { get; protected set; }
|
||||
public bool Visible { get; set; } = true;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public CodexWidget? Parent { get; internal set; }
|
||||
|
||||
public Point DesiredSize { get; protected set; }
|
||||
|
||||
/// <summary>Child reports its preferred size; parent decides whether to honour it.</summary>
|
||||
public Point Measure(Point available)
|
||||
{
|
||||
DesiredSize = MeasureCore(available);
|
||||
return DesiredSize;
|
||||
}
|
||||
|
||||
/// <summary>Parent commits a final rectangle; child propagates to grandchildren.</summary>
|
||||
public void Arrange(Rectangle bounds)
|
||||
{
|
||||
Bounds = bounds;
|
||||
ArrangeCore(bounds);
|
||||
}
|
||||
|
||||
protected abstract Point MeasureCore(Point available);
|
||||
protected abstract void ArrangeCore(Rectangle bounds);
|
||||
|
||||
public virtual void Update(GameTime gt, CodexInput input) { }
|
||||
public virtual void Draw(SpriteBatch sb, GameTime gt) { }
|
||||
|
||||
/// <summary>True if a screen-space point lies inside our bounds. Hover-/click-test helper.</summary>
|
||||
public bool ContainsPoint(Point p) => Bounds.Contains(p);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a 9-slice texture into an arbitrary destination rectangle: the
|
||||
/// four corners stay at their authored size, the four edges stretch along
|
||||
/// one axis, and the centre stretches both axes. Used for parchment cards,
|
||||
/// gilded borders, button backgrounds, and slot frames.
|
||||
///
|
||||
/// Per-side inset values describe how many source-texture pixels the
|
||||
/// fixed-size corner+edge bands take. They must add up to less than the
|
||||
/// source texture's width/height in each axis or the centre band collapses.
|
||||
/// </summary>
|
||||
public readonly struct NineSliceInsets
|
||||
{
|
||||
public readonly int Left, Top, Right, Bottom;
|
||||
|
||||
public NineSliceInsets(int uniform) : this(uniform, uniform, uniform, uniform) { }
|
||||
public NineSliceInsets(int l, int t, int r, int b) { Left = l; Top = t; Right = r; Bottom = b; }
|
||||
}
|
||||
|
||||
public static class NineSlice
|
||||
{
|
||||
/// <summary>
|
||||
/// Draw a 9-slice <paramref name="texture"/> tinted with <paramref name="tint"/>
|
||||
/// into <paramref name="dest"/> using <paramref name="insets"/> as the corner sizes.
|
||||
/// </summary>
|
||||
public static void Draw(SpriteBatch sb, Texture2D texture, Rectangle dest,
|
||||
NineSliceInsets insets, Color tint)
|
||||
{
|
||||
int sw = texture.Width;
|
||||
int sh = texture.Height;
|
||||
int l = insets.Left, t = insets.Top, r = insets.Right, b = insets.Bottom;
|
||||
int cw = sw - l - r; // source center width
|
||||
int ch = sh - t - b; // source center height
|
||||
if (cw < 1) cw = 1;
|
||||
if (ch < 1) ch = 1;
|
||||
|
||||
int dl = dest.X;
|
||||
int dt = dest.Y;
|
||||
int dcw = System.Math.Max(0, dest.Width - l - r);
|
||||
int dch = System.Math.Max(0, dest.Height - t - b);
|
||||
int dr = dest.X + dest.Width - r;
|
||||
int db = dest.Y + dest.Height - b;
|
||||
|
||||
// Corners (1× scale)
|
||||
sb.Draw(texture, new Rectangle(dl, dt, l, t), new Rectangle(0, 0, l, t), tint);
|
||||
sb.Draw(texture, new Rectangle(dr, dt, r, t), new Rectangle(sw - r, 0, r, t), tint);
|
||||
sb.Draw(texture, new Rectangle(dl, db, l, b), new Rectangle(0, sh - b, l, b), tint);
|
||||
sb.Draw(texture, new Rectangle(dr, db, r, b), new Rectangle(sw - r, sh - b, r, b), tint);
|
||||
|
||||
// Edges (stretched along their long axis)
|
||||
sb.Draw(texture, new Rectangle(dl + l, dt, dcw, t), new Rectangle(l, 0, cw, t), tint);
|
||||
sb.Draw(texture, new Rectangle(dl + l, db, dcw, b), new Rectangle(l, sh - b, cw, b), tint);
|
||||
sb.Draw(texture, new Rectangle(dl, dt + t, l, dch), new Rectangle(0, t, l, ch), tint);
|
||||
sb.Draw(texture, new Rectangle(dr, dt + t, r, dch), new Rectangle(sw - r, t, r, ch), tint);
|
||||
|
||||
// Centre (stretched both axes)
|
||||
sb.Draw(texture, new Rectangle(dl + l, dt + t, dcw, dch), new Rectangle(l, t, cw, ch), tint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Drag;
|
||||
|
||||
/// <summary>
|
||||
/// Screen-level coordinator for click-drag interactions. Source widgets call
|
||||
/// <see cref="BeginDrag"/> on left-mouse-down within their bounds and supply
|
||||
/// (a) an arbitrary payload object — game-specific state, e.g. <c>StatPoolPayload</c> —
|
||||
/// and (b) a ghost callback that paints a small follow-the-cursor visual.
|
||||
///
|
||||
/// Drop targets register via <see cref="RegisterTarget"/>; on left-mouse-up
|
||||
/// the controller hit-tests in registration order and fires <see cref="OnDrop"/>
|
||||
/// with the matching target's id. Pressing <see cref="Keys.Escape"/> mid-drag
|
||||
/// cancels and fires <see cref="OnCancel"/>.
|
||||
///
|
||||
/// One-controller-per-screen; concrete screens own the instance and pass it
|
||||
/// down to widgets that participate in drag-drop.
|
||||
/// </summary>
|
||||
public sealed class DragDropController
|
||||
{
|
||||
public bool IsDragging => _payload is not null;
|
||||
public object? Payload => _payload;
|
||||
public Point CursorPosition { get; private set; }
|
||||
|
||||
private object? _payload;
|
||||
private System.Action<SpriteBatch, Point>? _ghost;
|
||||
|
||||
private readonly System.Collections.Generic.List<DropTarget> _targets = new();
|
||||
|
||||
public event System.Action<object, string>? OnDrop; // (payload, targetId)
|
||||
public event System.Action<object>? OnCancel;
|
||||
public event System.Action<object, Point>? OnDropAnywhere; // fired when drop lands outside any registered target
|
||||
|
||||
public void BeginDrag(object payload, System.Action<SpriteBatch, Point> ghost)
|
||||
{
|
||||
_payload = payload;
|
||||
_ghost = ghost;
|
||||
}
|
||||
|
||||
public void RegisterTarget(string id, Rectangle bounds)
|
||||
=> _targets.Add(new DropTarget(id, bounds));
|
||||
|
||||
public void ClearTargets() => _targets.Clear();
|
||||
|
||||
public void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
CursorPosition = input.MousePosition;
|
||||
if (!IsDragging) { _targets.Clear(); return; }
|
||||
|
||||
if (input.KeyJustPressed(Keys.Escape))
|
||||
{
|
||||
OnCancel?.Invoke(_payload!);
|
||||
_payload = null;
|
||||
_ghost = null;
|
||||
_targets.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.LeftJustReleased)
|
||||
{
|
||||
string? hit = null;
|
||||
foreach (var t in _targets)
|
||||
if (t.Bounds.Contains(input.MousePosition)) { hit = t.Id; break; }
|
||||
if (hit is not null) OnDrop?.Invoke(_payload!, hit);
|
||||
else OnDropAnywhere?.Invoke(_payload!, input.MousePosition);
|
||||
_payload = null;
|
||||
_ghost = null;
|
||||
_targets.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void Draw(SpriteBatch sb)
|
||||
{
|
||||
if (_payload is null || _ghost is null) return;
|
||||
_ghost(sb, CursorPosition);
|
||||
}
|
||||
|
||||
private readonly struct DropTarget
|
||||
{
|
||||
public readonly string Id;
|
||||
public readonly Rectangle Bounds;
|
||||
public DropTarget(string id, Rectangle bounds) { Id = id; Bounds = bounds; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload type for the stat-assignment drag-drop dance. Mirrors the React
|
||||
/// design's <c>{from, value, idx, ability}</c> object so behavior ports verbatim.
|
||||
/// </summary>
|
||||
public sealed class StatPoolPayload
|
||||
{
|
||||
public required string Source { get; init; } // "pool" or "slot"
|
||||
public required int Value { get; init; }
|
||||
public int? PoolIdx { get; init; } // index in pool list when Source == "pool"
|
||||
public Theriapolis.Core.Rules.Stats.AbilityId? Ability { get; init; } // when Source == "slot"
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
using Theriapolis.Game.CodexUI.Steps;
|
||||
using Theriapolis.Game.CodexUI.Widgets;
|
||||
using Theriapolis.Game.UI;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Right-column live summary. Reads from <see cref="CodexCharacterCreationScreen"/>'s
|
||||
/// state and renders five blocks (Name, Lineage, Calling+History, Abilities,
|
||||
/// Skills) plus a stat strip. Mirrors the React <c><Aside /></c>
|
||||
/// component in <c>app.jsx</c>.
|
||||
/// </summary>
|
||||
public sealed class CodexAside
|
||||
{
|
||||
private readonly CodexCharacterCreationScreen _s;
|
||||
private readonly CodexAtlas _atlas;
|
||||
|
||||
public CodexAside(CodexCharacterCreationScreen s, CodexAtlas atlas)
|
||||
{
|
||||
_s = s;
|
||||
_atlas = atlas;
|
||||
}
|
||||
|
||||
public CodexWidget Build()
|
||||
{
|
||||
// Aside lives next to the page main and shares the screen's
|
||||
// popover layer — hovering a chip here pops the same parchment-
|
||||
// and-gilt popover the cards on the left side use.
|
||||
var popover = _s.AsidePopover;
|
||||
|
||||
var col = new Column { Spacing = 14, HAlignChildren = HAlign.Stretch };
|
||||
col.Add(new CodexLabel("THE SUBJECT", CodexFonts.MonoTag, CodexColors.InkMute));
|
||||
|
||||
col.Add(NameBlock());
|
||||
col.Add(LineageBlock(popover));
|
||||
col.Add(CallingBlock(popover));
|
||||
col.Add(HistoryBlock(popover));
|
||||
col.Add(BuildStatStrip());
|
||||
col.Add(SkillsBlock(popover));
|
||||
return col;
|
||||
}
|
||||
|
||||
private CodexWidget NameBlock()
|
||||
{
|
||||
var col = new Column { Spacing = 4 };
|
||||
col.Add(new CodexLabel("NAME", CodexFonts.MonoTag, CodexColors.InkMute));
|
||||
col.Add(new CodexLabel(string.IsNullOrWhiteSpace(_s.Name) ? "(unnamed)" : _s.Name,
|
||||
CodexFonts.DisplayMedium, CodexColors.Ink));
|
||||
return col;
|
||||
}
|
||||
|
||||
private CodexWidget LineageBlock(CodexHoverPopover popover)
|
||||
{
|
||||
var col = new Column { Spacing = 4 };
|
||||
col.Add(new CodexLabel("LINEAGE", CodexFonts.MonoTag, CodexColors.InkMute));
|
||||
col.Add(new CodexLabel(_s.Species?.Name ?? "—",
|
||||
CodexFonts.DisplayMedium, CodexColors.Ink));
|
||||
if (_s.Clade is not null || _s.Species is not null)
|
||||
{
|
||||
string sub = (_s.Clade?.Name ?? "—").ToUpperInvariant()
|
||||
+ (_s.Clade is not null ? " · " + _s.Clade.Kind.ToUpperInvariant() : "")
|
||||
+ (_s.Species is not null ? " · " + CodexCopy.SizeLabel(_s.Species.Size).ToUpperInvariant() : "");
|
||||
col.Add(new CodexLabel(sub, CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
}
|
||||
|
||||
// Trait chips — clade traits + species traits (each hover-popover'd).
|
||||
var chips = new WrapRow();
|
||||
if (_s.Clade is not null)
|
||||
{
|
||||
foreach (var t in _s.Clade.Traits)
|
||||
chips.Add(new HoverableChip(_atlas, popover, t.Name, t.Name, t.Description, null, ChipKind.Trait));
|
||||
foreach (var t in _s.Clade.Detriments)
|
||||
chips.Add(new HoverableChip(_atlas, popover, t.Name, t.Name, t.Description, "DETRIMENT", ChipKind.TraitDetriment));
|
||||
}
|
||||
if (_s.Species is not null)
|
||||
{
|
||||
foreach (var t in _s.Species.Traits)
|
||||
chips.Add(new HoverableChip(_atlas, popover, t.Name, t.Name, t.Description, null, ChipKind.Trait));
|
||||
foreach (var t in _s.Species.Detriments)
|
||||
chips.Add(new HoverableChip(_atlas, popover, t.Name, t.Name, t.Description, "DETRIMENT", ChipKind.TraitDetriment));
|
||||
}
|
||||
if (chips.Children.Count > 0) col.Add(chips);
|
||||
return col;
|
||||
}
|
||||
|
||||
private CodexWidget CallingBlock(CodexHoverPopover popover)
|
||||
{
|
||||
var col = new Column { Spacing = 4 };
|
||||
col.Add(new CodexLabel("CALLING", CodexFonts.MonoTag, CodexColors.InkMute));
|
||||
col.Add(new CodexLabel(_s.Class?.Name ?? "—", CodexFonts.DisplayMedium, CodexColors.Ink));
|
||||
if (_s.Class is not null)
|
||||
col.Add(new CodexLabel($"D{_s.Class.HitDie} · {string.Join("/", _s.Class.PrimaryAbility)}",
|
||||
CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
|
||||
// Level-1 feature chips for the chosen class (hover surfaces description).
|
||||
if (_s.Class is not null)
|
||||
{
|
||||
var lvl1 = System.Array.Find(_s.Class.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 (!_s.Class.FeatureDefinitions.TryGetValue(k, out var fd)) continue;
|
||||
chips.Add(new HoverableChip(_atlas, popover, fd.Name, fd.Name, fd.Description, fd.Kind?.ToUpperInvariant(), ChipKind.Trait));
|
||||
}
|
||||
if (chips.Children.Count > 0) col.Add(chips);
|
||||
}
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
private CodexWidget HistoryBlock(CodexHoverPopover popover)
|
||||
{
|
||||
var col = new Column { Spacing = 4 };
|
||||
col.Add(new CodexLabel("HISTORY", CodexFonts.MonoTag, CodexColors.InkMute));
|
||||
col.Add(new CodexLabel(_s.Background?.Name ?? "—", CodexFonts.DisplayMedium, CodexColors.Ink));
|
||||
if (_s.Background is not null && !string.IsNullOrEmpty(_s.Background.FeatureName))
|
||||
{
|
||||
var chips = new WrapRow();
|
||||
chips.Add(new HoverableChip(_atlas, popover,
|
||||
_s.Background.FeatureName, _s.Background.FeatureName, _s.Background.FeatureDescription,
|
||||
"FEATURE", ChipKind.BgFeature));
|
||||
col.Add(chips);
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
private CodexWidget SkillsBlock(CodexHoverPopover popover)
|
||||
{
|
||||
int total = _s.ChosenSkills.Count + (_s.Background?.SkillProficiencies.Length ?? 0);
|
||||
var col = new Column { Spacing = 4 };
|
||||
col.Add(new CodexLabel("SKILLS · " + total, CodexFonts.MonoTag, CodexColors.InkMute));
|
||||
var chips = new WrapRow();
|
||||
if (_s.Background is not null)
|
||||
foreach (var s in _s.Background.SkillProficiencies)
|
||||
chips.Add(new HoverableChip(_atlas, popover,
|
||||
CodexCopy.SkillName(s),
|
||||
CodexCopy.SkillName(s),
|
||||
CodexCopy.SkillDescription(s),
|
||||
"BACKGROUND",
|
||||
ChipKind.SkillFromBg));
|
||||
foreach (var s in _s.ChosenSkills.OrderBy(x => x.ToString()))
|
||||
{
|
||||
// Map enum → snake_case JSON id; otherwise SleightOfHand /
|
||||
// AnimalHandling lose their hover descriptions because the
|
||||
// CodexCopy switch is keyed on the JSON form.
|
||||
string id = CodexCopy.SkillIdToJson(s);
|
||||
chips.Add(new HoverableChip(_atlas, popover,
|
||||
CodexCopy.SkillName(id),
|
||||
CodexCopy.SkillName(id),
|
||||
CodexCopy.SkillDescription(id),
|
||||
"CLASS",
|
||||
ChipKind.SkillFromClass));
|
||||
}
|
||||
col.Add(chips);
|
||||
return col;
|
||||
}
|
||||
|
||||
private CodexWidget BuildStatStrip()
|
||||
{
|
||||
var col = new Column { Spacing = 4 };
|
||||
col.Add(new CodexLabel("ABILITIES", CodexFonts.MonoTag, CodexColors.InkMute));
|
||||
col.Add(new StatStrip(_s, _atlas));
|
||||
return col;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Six-cell strip (one per ability) with name, final score, and signed
|
||||
/// modifier. Used in the aside panel and on the review step.
|
||||
/// </summary>
|
||||
internal sealed class StatStrip : CodexWidget
|
||||
{
|
||||
private readonly CodexCharacterCreationScreen _s;
|
||||
private readonly CodexAtlas _atlas;
|
||||
|
||||
public StatStrip(CodexCharacterCreationScreen s, CodexAtlas atlas) { _s = s; _atlas = atlas; }
|
||||
|
||||
protected override Point MeasureCore(Point available) => new(available.X, 50);
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
var abFont = CodexFonts.MonoTagSmall;
|
||||
var scoreFont = CodexFonts.DisplayMedium;
|
||||
var modFont = CodexFonts.MonoTagSmall;
|
||||
int cellW = (Bounds.Width - 5 * 4) / 6;
|
||||
for (int i = 0; i < CodexCopy.AbilityOrder.Length; i++)
|
||||
{
|
||||
var ab = CodexCopy.AbilityOrder[i];
|
||||
int x = Bounds.X + i * (cellW + 4);
|
||||
var rect = new Rectangle(x, Bounds.Y, cellW, Bounds.Height);
|
||||
sb.Draw(_atlas.Pixel, rect, CodexColors.Bg);
|
||||
DrawBorder(sb, rect, CodexColors.Rule, 1);
|
||||
|
||||
string ablab = ab.ToString();
|
||||
var s1 = abFont.MeasureString(ablab);
|
||||
abFont.DrawText(sb, ablab, new Vector2(rect.X + (rect.Width - s1.X) / 2f, rect.Y + 4), CodexColors.InkMute);
|
||||
|
||||
int? base_ = _s.StatAssign.TryGetValue(ab, out var v) ? v : (int?)null;
|
||||
int total = (base_ ?? 0) + _s.TotalBonus(ab);
|
||||
int mod = AbilityScores.Mod(total);
|
||||
string scoreText = base_ is null ? "—" : total.ToString();
|
||||
string modText = base_ is null ? "" : ((mod >= 0 ? "+" : "") + mod);
|
||||
|
||||
var s2 = scoreFont.MeasureString(scoreText);
|
||||
scoreFont.DrawText(sb, scoreText, new Vector2(rect.X + (rect.Width - s2.X) / 2f, rect.Y + 16), CodexColors.Ink);
|
||||
if (modText.Length > 0)
|
||||
{
|
||||
var s3 = modFont.MeasureString(modText);
|
||||
modFont.DrawText(sb, modText, new Vector2(rect.X + (rect.Width - s3.X) / 2f, rect.Bottom - modFont.LineHeight - 4),
|
||||
mod >= 0 ? CodexColors.Seal : CodexColors.InkMute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t)
|
||||
{
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, r.Width, t), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Bottom - t, r.Width, t), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, t, r.Height), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.Right - t, r.Y, t, r.Height), c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
using Theriapolis.Game.CodexUI.Drag;
|
||||
using Theriapolis.Game.CodexUI.Steps;
|
||||
using Theriapolis.Game.CodexUI.Widgets;
|
||||
using Theriapolis.Game.Screens;
|
||||
using Theriapolis.Game.UI;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Custom-rendered character-creation wizard. State model is a verbatim port
|
||||
/// of <see cref="CharacterCreationScreen"/> (the Myra version); only the
|
||||
/// rendering swaps to CodexUI primitives. Layout: codex header, 7-step
|
||||
/// stepper, two-column body (page main + aside summary), nav bar at the
|
||||
/// bottom. Each step is a separate file under <c>Steps/</c> that builds
|
||||
/// its own widget subtree against this screen's mutable state.
|
||||
/// </summary>
|
||||
public sealed class CodexCharacterCreationScreen : CodexScreen
|
||||
{
|
||||
public ulong Seed { get; }
|
||||
|
||||
// Loaded content
|
||||
public ContentResolver Content { get; private set; } = null!;
|
||||
public CladeDef[] Clades { get; private set; } = null!;
|
||||
public SpeciesDef[] AllSpecies { get; private set; } = null!;
|
||||
public ClassDef[] Classes { get; private set; } = null!;
|
||||
public BackgroundDef[] Backgrounds { get; private set; } = null!;
|
||||
|
||||
// Wizard state
|
||||
public int Step;
|
||||
public CladeDef? Clade;
|
||||
public SpeciesDef? Species;
|
||||
public ClassDef? Class;
|
||||
public BackgroundDef? Background;
|
||||
public string Name = "Wanderer";
|
||||
|
||||
// Stat assignment state
|
||||
public bool UseRoll;
|
||||
public readonly System.Collections.Generic.List<int> StatPool = new();
|
||||
public readonly System.Collections.Generic.Dictionary<AbilityId, int> StatAssign = new();
|
||||
public readonly System.Collections.Generic.List<int[]> StatHistory = new();
|
||||
public int? PendingPoolIdx;
|
||||
|
||||
// Skill state
|
||||
public readonly System.Collections.Generic.HashSet<SkillId> ChosenSkills = new();
|
||||
|
||||
// Scroll position for the body's scroll panel. Survives any
|
||||
// InvalidateLayout-triggered rebuild within the same step (so
|
||||
// selecting a clade or dropping an ability die doesn't bounce the
|
||||
// page back to the top); reset to zero on step change.
|
||||
private int _bodyScrollOffset;
|
||||
|
||||
// Same idea for the right-column aside summary, which can grow taller
|
||||
// than the viewport once enough trait/feature/skill chips appear.
|
||||
// Persisted across rebuilds; not reset on step change because the
|
||||
// aside content keeps growing as more folios are completed.
|
||||
private int _asideScrollOffset;
|
||||
|
||||
/// <summary>
|
||||
/// Popover layer for hover trigger widgets in the right-column aside.
|
||||
/// Exposed so <see cref="CodexAside"/> can attach hover triggers to
|
||||
/// the same parchment-and-gilt popover the page-main cards use.
|
||||
/// </summary>
|
||||
public CodexHoverPopover AsidePopover => Popover ?? throw new System.InvalidOperationException("AsidePopover accessed before BuildRoot.");
|
||||
|
||||
// Stat-roll seeding (Phase 5 plan §4.2)
|
||||
private readonly long _gameStartMs;
|
||||
private long _msAtScreenOpen;
|
||||
|
||||
public static readonly string[] StepNames = new[]
|
||||
{
|
||||
"Clade", "Species", "Calling", "History", "Abilities", "Skills", "Sign",
|
||||
};
|
||||
|
||||
public CodexCharacterCreationScreen(ulong seed)
|
||||
{
|
||||
Seed = seed;
|
||||
_gameStartMs = System.Environment.TickCount64;
|
||||
}
|
||||
|
||||
public override void Initialize(Game1 game)
|
||||
{
|
||||
var loader = new ContentLoader(game.ContentDataDirectory);
|
||||
Content = new ContentResolver(loader);
|
||||
Clades = Content.Clades.Values.OrderBy(c => c.Id).ToArray();
|
||||
AllSpecies = Content.Species.Values.OrderBy(s => s.Id).ToArray();
|
||||
Classes = Content.Classes.Values.OrderBy(c => c.Id).ToArray();
|
||||
Backgrounds = Content.Backgrounds.Values.OrderBy(b => b.Id).ToArray();
|
||||
_msAtScreenOpen = System.Environment.TickCount64 - _gameStartMs;
|
||||
|
||||
// No pre-filled clade / species / class / background. Defaults
|
||||
// would let the player jump straight to the review step without
|
||||
// ever interacting with the earlier folios; explicit selection
|
||||
// gates each step via ValidateStep + the stepper's lock logic.
|
||||
// Stat pool is initialised so the abilities folio has values to
|
||||
// drag from once the player reaches it.
|
||||
InitStandardArrayPool();
|
||||
|
||||
DragDrop.OnDrop += HandleDrop;
|
||||
DragDrop.OnDropAnywhere += HandleDropAnywhere;
|
||||
DragDrop.OnCancel += _ => { };
|
||||
|
||||
base.Initialize(game);
|
||||
}
|
||||
|
||||
// ── Layout ───────────────────────────────────────────────────────────
|
||||
|
||||
protected override CodexWidget BuildRoot()
|
||||
{
|
||||
Popover ??= new CodexHoverPopover(Atlas);
|
||||
Popover.UpdateViewport(Game.GraphicsDevice.Viewport.Bounds);
|
||||
|
||||
// Header
|
||||
var headerRow = new Row { Spacing = 16, VAlignChildren = VAlign.Bottom, Padding = new Thickness(36, 22, 36, 18) };
|
||||
headerRow.Add(new CodexLabel("THERIAPOLIS — Codex of Becoming", CodexFonts.DisplayLarge, CodexColors.Ink));
|
||||
headerRow.Add(new CodexLabel($"FOLIO {Romanize(Step + 1)} OF VII · SEED 0x{Seed:X}",
|
||||
CodexFonts.MonoTagSmall, CodexColors.InkMute));
|
||||
|
||||
// Stepper — locked steps are anything beyond the first incomplete folio.
|
||||
// We deliberately ignore the player's current `Step` here: the lock
|
||||
// depends only on whether earlier folios are valid. Pre-filled defaults
|
||||
// would make every step pass validation without interaction; removing
|
||||
// them in Initialize is what makes this gate actually gate.
|
||||
var stepper = new CodexStepper(StepNames, Atlas) { Current = Step };
|
||||
int firstIncomplete = -1;
|
||||
for (int j = 0; j < StepNames.Length; j++)
|
||||
if (ValidateStep(j) is not null) { firstIncomplete = j; break; }
|
||||
for (int i = 0; i < StepNames.Length; i++)
|
||||
{
|
||||
stepper.Complete[i] = ValidateStep(i) is null && i != Step;
|
||||
stepper.Locked[i] = firstIncomplete != -1 && firstIncomplete < i;
|
||||
}
|
||||
stepper.OnPick = NavigateTo;
|
||||
|
||||
// Body — two-column layout, both columns are independently
|
||||
// scrollable. The body offsets are restored from saved state so
|
||||
// an interaction that triggers InvalidateLayout (selecting a
|
||||
// clade, dropping a die into an ability slot) doesn't bounce
|
||||
// the user to the top of either column.
|
||||
//
|
||||
// Bottom padding is zero on purpose: the ScrollPanel's mouse clip
|
||||
// matches its own bounds, so any column-padding gap below the
|
||||
// panel becomes a region where chips can render visibly (no
|
||||
// scissor) but reject hover (cursor outside the clip). With the
|
||||
// panel reaching all the way down to the body's bottom edge —
|
||||
// which sits exactly atop the nav bar's hairline rule — chips
|
||||
// that scroll past it disappear under the nav bar's opaque mask
|
||||
// instead of leaking into a padding strip.
|
||||
var body = new TwoColumn(Atlas) { LeftPad = new Thickness(36, 28, 36, 0), RightPad = new Thickness(28, 28, 28, 0) };
|
||||
|
||||
var leftScroll = new ScrollPanel(Atlas, BuildCurrentStep());
|
||||
leftScroll.SetInitialScroll(_bodyScrollOffset);
|
||||
leftScroll.OnScrollChanged = o => _bodyScrollOffset = o;
|
||||
body.Left = leftScroll;
|
||||
|
||||
var rightScroll = new ScrollPanel(Atlas, new CodexAside(this, Atlas).Build());
|
||||
rightScroll.SetInitialScroll(_asideScrollOffset);
|
||||
rightScroll.OnScrollChanged = o => _asideScrollOffset = o;
|
||||
body.Right = rightScroll;
|
||||
|
||||
// Nav bar
|
||||
var navBar = BuildNavBar();
|
||||
|
||||
// Wrap everything in a custom root that gives the body whatever
|
||||
// height is left after header + stepper + nav. Without this the
|
||||
// body's measured height was its full content height (often >2×
|
||||
// the viewport), so cards in row 2/3 sat below the visible window.
|
||||
return new CodexRootLayout(Atlas, headerRow, stepper, body, navBar);
|
||||
}
|
||||
|
||||
private CodexWidget BuildCurrentStep() => Step switch
|
||||
{
|
||||
0 => StepClade.Build(this, Atlas, Popover!),
|
||||
1 => StepSpecies.Build(this, Atlas, Popover!),
|
||||
2 => StepClass.Build(this, Atlas, Popover!),
|
||||
3 => StepBackground.Build(this, Atlas, Popover!),
|
||||
4 => StepStats.Build(this, Atlas, Popover!, DragDrop),
|
||||
5 => StepSkills.Build(this, Atlas, Popover!),
|
||||
6 => StepReview.Build(this, Atlas, Popover!),
|
||||
_ => new CodexLabel("(unknown step)", CodexFonts.SerifBody),
|
||||
};
|
||||
|
||||
private CodexWidget BuildNavBar()
|
||||
{
|
||||
var row = new Row
|
||||
{
|
||||
Spacing = 16,
|
||||
Padding = new Thickness(36, 16, 36, 16),
|
||||
VAlignChildren = VAlign.Middle,
|
||||
};
|
||||
|
||||
var back = new CodexButton("‹ Back", Atlas, CodexButtonVariant.Ghost,
|
||||
onClick: () => NavigateTo(System.Math.Max(0, Step - 1)),
|
||||
fixedWidth: 120);
|
||||
back.Enabled = Step > 0;
|
||||
row.Add(back);
|
||||
|
||||
// Status label
|
||||
var stepError = ValidateStep(Step);
|
||||
bool allValid = AllStepsValid();
|
||||
string status = stepError is not null
|
||||
? "✘ " + stepError
|
||||
: (Step < 6 ? "Folio complete" : (allValid ? "Ready to sign" : "Some folios remain"));
|
||||
Color statusColor = stepError is not null ? CodexColors.Seal : CodexColors.InkMute;
|
||||
var statusLabel = new CodexLabel(status, CodexFonts.MonoTag, statusColor) { HAlign = HAlign.Center };
|
||||
// Make the label expand by wrapping in a stretched Padding
|
||||
var spacer = new Padding(statusLabel, new Thickness(60, 0, 60, 0));
|
||||
row.Add(spacer);
|
||||
|
||||
if (Step < StepNames.Length - 1)
|
||||
{
|
||||
var next = new CodexButton("Next ›", Atlas, CodexButtonVariant.Ghost,
|
||||
onClick: () => NavigateTo(Step + 1), fixedWidth: 140);
|
||||
next.Enabled = stepError is null;
|
||||
row.Add(next);
|
||||
}
|
||||
else
|
||||
{
|
||||
var confirm = new CodexButton("Confirm & Begin", Atlas, CodexButtonVariant.Primary,
|
||||
onClick: OnConfirm, fixedWidth: 220);
|
||||
confirm.Enabled = allValid;
|
||||
row.Add(confirm);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private void NavigateTo(int s)
|
||||
{
|
||||
Step = s;
|
||||
_bodyScrollOffset = 0; // each folio starts at the top
|
||||
InvalidateLayout();
|
||||
}
|
||||
|
||||
public override void Update(GameTime gameTime)
|
||||
{
|
||||
base.Update(gameTime);
|
||||
if (Input.KeyJustPressed(Keys.Escape)) Game.Screens.Pop();
|
||||
}
|
||||
|
||||
// ── State helpers ────────────────────────────────────────────────────
|
||||
|
||||
public void InitStandardArrayPool()
|
||||
{
|
||||
StatPool.Clear();
|
||||
foreach (int v in AbilityScores.StandardArray) StatPool.Add(v);
|
||||
StatAssign.Clear();
|
||||
PendingPoolIdx = null;
|
||||
}
|
||||
|
||||
public void RollAndPool()
|
||||
{
|
||||
ulong msNow = (ulong)(System.Environment.TickCount64 - _gameStartMs);
|
||||
var rng = SeededRng.ForSubsystem(Seed, C.RNG_STAT_ROLL ^ msNow);
|
||||
var vals = new int[6];
|
||||
for (int i = 0; i < 6; i++) vals[i] = CharacterBuilder.Roll4d6DropLowest(rng);
|
||||
StatHistory.Add(vals);
|
||||
StatPool.Clear();
|
||||
foreach (var v in vals) StatPool.Add(v);
|
||||
StatAssign.Clear();
|
||||
PendingPoolIdx = null;
|
||||
}
|
||||
|
||||
public void AutoAssignByClassPriority()
|
||||
{
|
||||
var primary = Class?.PrimaryAbility ?? System.Array.Empty<string>();
|
||||
var order = new System.Collections.Generic.List<string>();
|
||||
foreach (var p in primary) order.Add(p.ToUpperInvariant());
|
||||
foreach (var a in new[] { "CON", "DEX", "STR", "WIS", "INT", "CHA" })
|
||||
if (!order.Contains(a)) order.Add(a);
|
||||
|
||||
var available = StatPool.OrderByDescending(x => x).ToList();
|
||||
var emptyAbilities = new System.Collections.Generic.List<AbilityId>();
|
||||
foreach (var s in order)
|
||||
if (TryParseAbility(s, out var ab) && !StatAssign.ContainsKey(ab))
|
||||
emptyAbilities.Add(ab);
|
||||
|
||||
for (int i = 0; i < emptyAbilities.Count && i < available.Count; i++)
|
||||
StatAssign[emptyAbilities[i]] = available[i];
|
||||
|
||||
StatPool.Clear();
|
||||
for (int i = emptyAbilities.Count; i < available.Count; i++) StatPool.Add(available[i]);
|
||||
PendingPoolIdx = null;
|
||||
}
|
||||
|
||||
public void ClearAssignments()
|
||||
{
|
||||
foreach (var v in StatAssign.Values) StatPool.Add(v);
|
||||
StatAssign.Clear();
|
||||
PendingPoolIdx = null;
|
||||
}
|
||||
|
||||
private void HandleDrop(object payload, string targetId)
|
||||
{
|
||||
if (payload is not StatPoolPayload p) return;
|
||||
if (targetId.StartsWith("ability:"))
|
||||
{
|
||||
string abStr = targetId.Substring("ability:".Length);
|
||||
if (!System.Enum.TryParse<AbilityId>(abStr, out var dest)) return;
|
||||
if (p.Source == "pool" && p.PoolIdx is int idx && idx < StatPool.Count)
|
||||
{
|
||||
if (StatAssign.TryGetValue(dest, out var existing))
|
||||
StatPool.Add(existing);
|
||||
StatPool.RemoveAt(idx);
|
||||
StatAssign[dest] = p.Value;
|
||||
}
|
||||
else if (p.Source == "slot" && p.Ability is AbilityId src)
|
||||
{
|
||||
if (src == dest) return;
|
||||
int srcVal = p.Value;
|
||||
if (StatAssign.TryGetValue(dest, out var destVal))
|
||||
{
|
||||
StatAssign[dest] = srcVal;
|
||||
StatAssign[src] = destVal;
|
||||
}
|
||||
else
|
||||
{
|
||||
StatAssign[dest] = srcVal;
|
||||
StatAssign.Remove(src);
|
||||
}
|
||||
}
|
||||
InvalidateLayout();
|
||||
}
|
||||
else if (targetId == "pool")
|
||||
{
|
||||
if (p.Source == "slot" && p.Ability is AbilityId src && StatAssign.TryGetValue(src, out var v))
|
||||
{
|
||||
StatPool.Add(v);
|
||||
StatAssign.Remove(src);
|
||||
InvalidateLayout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDropAnywhere(object payload, Point screenPos)
|
||||
{
|
||||
// No-op — payload silently bounces back when dropped outside any registered target.
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────────
|
||||
|
||||
public string? ValidateStep(int i)
|
||||
{
|
||||
if (i == 0) return Clade is null ? "Pick a clade." : null;
|
||||
if (i == 1) return Species is null ? "Pick a species." : null;
|
||||
if (i == 2) return Class is null ? "Pick a calling." : null;
|
||||
if (i == 3) return Background is null ? "Pick a background." : null;
|
||||
if (i == 4) return StatAssign.Count == 6 ? null : $"Assign all six abilities ({StatAssign.Count}/6).";
|
||||
if (i == 5)
|
||||
{
|
||||
int need = Class?.SkillsChoose ?? 0;
|
||||
return ChosenSkills.Count == need ? null : $"Pick exactly {need} skill(s) ({ChosenSkills.Count}/{need}).";
|
||||
}
|
||||
if (i == 6) return string.IsNullOrWhiteSpace(Name) ? "Enter a name." : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool AllStepsValid()
|
||||
{
|
||||
for (int i = 0; i < StepNames.Length; i++) if (ValidateStep(i) is not null) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnConfirm()
|
||||
{
|
||||
if (!AllStepsValid()) return;
|
||||
var b = new CharacterBuilder
|
||||
{
|
||||
Clade = Clade,
|
||||
Species = Species,
|
||||
ClassDef = Class,
|
||||
Background = Background,
|
||||
BaseAbilities = new AbilityScores(
|
||||
StatAssign.GetValueOrDefault(AbilityId.STR),
|
||||
StatAssign.GetValueOrDefault(AbilityId.DEX),
|
||||
StatAssign.GetValueOrDefault(AbilityId.CON),
|
||||
StatAssign.GetValueOrDefault(AbilityId.INT),
|
||||
StatAssign.GetValueOrDefault(AbilityId.WIS),
|
||||
StatAssign.GetValueOrDefault(AbilityId.CHA)),
|
||||
Name = Name,
|
||||
};
|
||||
foreach (var s in ChosenSkills) b.ChooseSkill(s);
|
||||
var character = b.Build(Content.Items);
|
||||
|
||||
Game.Screens.Pop();
|
||||
Game.Screens.Push(new WorldGenProgressScreen(Seed, pendingCharacter: character, pendingName: Name));
|
||||
}
|
||||
|
||||
// Helpers
|
||||
public int CladeMod(AbilityId ab) => ModFromDict(Clade?.AbilityMods, ab);
|
||||
public int SpeciesMod(AbilityId ab) => ModFromDict(Species?.AbilityMods, ab);
|
||||
public int TotalBonus(AbilityId ab) => CladeMod(ab) + SpeciesMod(ab);
|
||||
public bool IsPrimary(AbilityId ab) => Class?.PrimaryAbility.Contains(ab.ToString()) == true;
|
||||
|
||||
public static int ModFromDict(System.Collections.Generic.IReadOnlyDictionary<string, int>? d, AbilityId ab)
|
||||
{
|
||||
if (d is null) return 0;
|
||||
return d.TryGetValue(ab.ToString(), out var v) ? v : 0;
|
||||
}
|
||||
|
||||
private static bool TryParseAbility(string raw, out AbilityId id)
|
||||
{
|
||||
switch (raw.ToUpperInvariant())
|
||||
{
|
||||
case "STR": id = AbilityId.STR; return true;
|
||||
case "DEX": id = AbilityId.DEX; return true;
|
||||
case "CON": id = AbilityId.CON; return true;
|
||||
case "INT": id = AbilityId.INT; return true;
|
||||
case "WIS": id = AbilityId.WIS; return true;
|
||||
case "CHA": id = AbilityId.CHA; return true;
|
||||
default: id = AbilityId.STR; return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static System.Collections.Generic.IEnumerable<string> AllSkillIds() => new[]
|
||||
{
|
||||
"athletics", "acrobatics", "sleight_of_hand", "stealth",
|
||||
"arcana", "history", "investigation", "nature", "religion",
|
||||
"animal_handling", "insight", "medicine", "perception", "survival",
|
||||
"deception", "intimidation", "performance", "persuasion",
|
||||
};
|
||||
|
||||
public static string Romanize(int n) => CodexCopy.Romanize(n);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two-column body layout: page-main (flex) on the left, fixed-width aside
|
||||
/// panel on the right. Mirrors the React design's <c>.page</c> grid. Takes
|
||||
/// whatever height the parent assigns (so the page-main scroll panel
|
||||
/// scrolls within a viewport-bounded region) rather than expanding to its
|
||||
/// content's natural height.
|
||||
/// </summary>
|
||||
internal sealed class TwoColumn : CodexWidget
|
||||
{
|
||||
public CodexWidget? Left;
|
||||
public CodexWidget? Right;
|
||||
public Thickness LeftPad;
|
||||
public Thickness RightPad;
|
||||
private readonly CodexAtlas _atlas;
|
||||
public TwoColumn(CodexAtlas atlas) { _atlas = atlas; }
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
int rightW = CodexDensity.AsideWidth;
|
||||
int leftW = available.X - rightW;
|
||||
Left?.Measure(new Point(leftW - LeftPad.HorizontalSum(), available.Y - LeftPad.VerticalSum()));
|
||||
Right?.Measure(new Point(rightW - RightPad.HorizontalSum(), available.Y - RightPad.VerticalSum()));
|
||||
return new Point(available.X, available.Y);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
int rightW = CodexDensity.AsideWidth;
|
||||
int leftW = bounds.Width - rightW;
|
||||
Left?.Arrange(new Rectangle(
|
||||
bounds.X + LeftPad.Left,
|
||||
bounds.Y + LeftPad.Top,
|
||||
leftW - LeftPad.HorizontalSum(),
|
||||
bounds.Height - LeftPad.VerticalSum()));
|
||||
Right?.Arrange(new Rectangle(
|
||||
bounds.X + leftW + RightPad.Left,
|
||||
bounds.Y + RightPad.Top,
|
||||
rightW - RightPad.HorizontalSum(),
|
||||
bounds.Height - RightPad.VerticalSum()));
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
Left?.Update(gt, input);
|
||||
Right?.Update(gt, input);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
// Body fill — paint the lighter parchment Bg behind both columns
|
||||
// so cards (Bg2) sit on a flat surface with clear contrast,
|
||||
// independent of the screen's BgDeep clear.
|
||||
sb.Draw(_atlas.Pixel, Bounds, CodexColors.Bg);
|
||||
|
||||
// Vertical rule between left and right
|
||||
int rightW = CodexDensity.AsideWidth;
|
||||
int leftW = Bounds.Width - rightW;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X + leftW, Bounds.Y, 1, Bounds.Height), CodexColors.Rule);
|
||||
|
||||
Left?.Draw(sb, gt);
|
||||
Right?.Draw(sb, gt);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RuleLine : CodexWidget
|
||||
{
|
||||
private readonly CodexAtlas _atlas;
|
||||
public RuleLine(CodexAtlas atlas) { _atlas = atlas; }
|
||||
protected override Point MeasureCore(Point available) => new(available.X, 1);
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
public override void Draw(SpriteBatch sb, GameTime gt) => sb.Draw(_atlas.Pixel, Bounds, CodexColors.Rule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wizard-shaped root layout: header at the top, stepper under it, body
|
||||
/// fills the middle, nav bar at the bottom. Header / stepper / nav take
|
||||
/// their measured size; body gets whatever height is left over so it
|
||||
/// always fits the viewport (instead of overflowing when its content is
|
||||
/// taller than the window). The stepper and nav bar paint opaque
|
||||
/// parchment backgrounds that mask any scroll-panel overflow above /
|
||||
/// below the body's clipped region — cheaper than configuring a scissor-
|
||||
/// enabled rasterizer and re-Begin-ing the SpriteBatch.
|
||||
/// </summary>
|
||||
internal sealed class CodexRootLayout : CodexWidget
|
||||
{
|
||||
private readonly CodexAtlas _atlas;
|
||||
public CodexWidget Header;
|
||||
public CodexWidget Stepper;
|
||||
public CodexWidget Body;
|
||||
public CodexWidget NavBar;
|
||||
|
||||
public CodexRootLayout(CodexAtlas atlas, CodexWidget header, CodexWidget stepper, CodexWidget body, CodexWidget navBar)
|
||||
{
|
||||
_atlas = atlas;
|
||||
Header = header;
|
||||
Stepper = stepper;
|
||||
Body = body;
|
||||
NavBar = navBar;
|
||||
Header.Parent = this; Stepper.Parent = this; Body.Parent = this; NavBar.Parent = this;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
var hs = Header.Measure(available);
|
||||
var ss = Stepper.Measure(available);
|
||||
var ns = NavBar.Measure(available);
|
||||
int bodyH = System.Math.Max(0, available.Y - hs.Y - ss.Y - ns.Y - 2); // 2 = top/bottom rules
|
||||
Body.Measure(new Point(available.X, bodyH));
|
||||
return new Point(available.X, available.Y);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
int y = bounds.Y;
|
||||
Header.Arrange(new Rectangle(bounds.X, y, bounds.Width, Header.DesiredSize.Y));
|
||||
y += Header.DesiredSize.Y;
|
||||
// Header bottom rule
|
||||
y += 1;
|
||||
Stepper.Arrange(new Rectangle(bounds.X, y, bounds.Width, Stepper.DesiredSize.Y));
|
||||
y += Stepper.DesiredSize.Y;
|
||||
int navY = bounds.Bottom - NavBar.DesiredSize.Y;
|
||||
Body.Arrange(new Rectangle(bounds.X, y, bounds.Width, navY - y - 1));
|
||||
// Nav top rule on (navY - 1).
|
||||
NavBar.Arrange(new Rectangle(bounds.X, navY, bounds.Width, NavBar.DesiredSize.Y));
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
// Clip the body's mouse hit-testing to its own rectangle so cards
|
||||
// scrolled under the stepper / nav bar don't receive clicks that
|
||||
// visually land on the chrome above or below them. Without this,
|
||||
// clicking a stepper bullet would also fire the OnClick of any
|
||||
// card whose bounds (at their scroll-offset position) happen to
|
||||
// intersect the cursor — even though the card is masked from view.
|
||||
input.SetMouseClip(Body.Bounds);
|
||||
Body.Update(gt, input);
|
||||
input.ClearMouseClip();
|
||||
|
||||
Header.Update(gt, input);
|
||||
Stepper.Update(gt, input);
|
||||
NavBar.Update(gt, input);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
// Body draws first; chrome paints over any scroll overflow.
|
||||
Body.Draw(sb, gt);
|
||||
|
||||
// Header: opaque parchment band + bottom hairline rule + text.
|
||||
var headerRect = new Rectangle(Header.Bounds.X, Header.Bounds.Y, Header.Bounds.Width, Header.Bounds.Height);
|
||||
sb.Draw(_atlas.Pixel, headerRect, CodexColors.Bg);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(headerRect.X, headerRect.Bottom, headerRect.Width, 1), CodexColors.Rule);
|
||||
Header.Draw(sb, gt);
|
||||
|
||||
// Stepper paints its own opaque parchment background.
|
||||
Stepper.Draw(sb, gt);
|
||||
|
||||
// Nav bar: opaque parchment band + top hairline rule + buttons.
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(NavBar.Bounds.X, NavBar.Bounds.Y - 1, NavBar.Bounds.Width, 1), CodexColors.Rule);
|
||||
sb.Draw(_atlas.Pixel, NavBar.Bounds, CodexColors.Bg);
|
||||
NavBar.Draw(sb, gt);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
using Theriapolis.Game.CodexUI.Drag;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// One row of the stat-assignment grid. Composite layout:
|
||||
/// [ Ability name + bonus pill ] [ Slot ] [ formula ] [ Final + mod ] [ progress bar ]
|
||||
/// The slot is both a drop target (for pool dice) and a drag source (when
|
||||
/// filled — drag the value back to the pool, or onto another slot to swap).
|
||||
/// Click on a filled slot also returns to pool, mirroring the React design.
|
||||
/// </summary>
|
||||
public sealed class CodexAbilityRow : CodexWidget
|
||||
{
|
||||
public AbilityId Ability { get; }
|
||||
public string LongName { get; set; } = "";
|
||||
public int? Assigned { get; set; }
|
||||
public int Bonus { get; set; } // total clade + species mod
|
||||
public string BonusSourceText { get; set; } = "";
|
||||
public bool IsPrimary { get; set; }
|
||||
|
||||
public System.Action? OnSlotClick { get; set; }
|
||||
public System.Action<StatPoolPayload>? OnDragStart { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly DragDropController _drag;
|
||||
|
||||
private readonly SpriteFontBase _nameFont = CodexFonts.DisplaySmall;
|
||||
private readonly SpriteFontBase _tagFont = CodexFonts.MonoTagSmall;
|
||||
private readonly SpriteFontBase _slotFont = CodexFonts.DisplayMedium;
|
||||
private readonly SpriteFontBase _modFont = CodexFonts.MonoTag;
|
||||
|
||||
private CodexBonusPill? _bonusPill;
|
||||
|
||||
public CodexAbilityRow(AbilityId ability, CodexAtlas atlas, DragDropController drag)
|
||||
{
|
||||
Ability = ability;
|
||||
_atlas = atlas;
|
||||
_drag = drag;
|
||||
}
|
||||
|
||||
public CodexBonusPill? BonusPillRef => _bonusPill;
|
||||
|
||||
protected override Point MeasureCore(Point available) => new(available.X, 56);
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
// Lay out the bonus pill within the name column so we can hover-test it.
|
||||
if (Bonus != 0)
|
||||
{
|
||||
_bonusPill ??= new CodexBonusPill(Bonus, _atlas);
|
||||
var pillSize = _bonusPill.Measure(new Point(60, bounds.Height));
|
||||
_bonusPill.Arrange(new Rectangle(
|
||||
bounds.X + 60,
|
||||
bounds.Y + (bounds.Height - pillSize.Y) / 2,
|
||||
pillSize.X, pillSize.Y));
|
||||
}
|
||||
else _bonusPill = null;
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
var slotRect = SlotRect();
|
||||
|
||||
// Dragging a value out of a filled slot.
|
||||
if (Assigned is int v && slotRect.Contains(input.MousePosition) && input.LeftJustPressed && !_drag.IsDragging)
|
||||
{
|
||||
OnDragStart?.Invoke(new StatPoolPayload { Source = "slot", Value = v, Ability = Ability });
|
||||
return;
|
||||
}
|
||||
// Click-to-return (no drag) when a slot is filled and the pool is not currently active.
|
||||
if (Assigned is not null && slotRect.Contains(input.MousePosition) && input.LeftJustReleased && !_drag.IsDragging)
|
||||
{
|
||||
OnSlotClick?.Invoke();
|
||||
}
|
||||
// Empty slot click — also fires OnSlotClick so the screen can use click-to-place semantics if drag isn't active.
|
||||
else if (Assigned is null && slotRect.Contains(input.MousePosition) && input.LeftJustReleased && !_drag.IsDragging)
|
||||
{
|
||||
OnSlotClick?.Invoke();
|
||||
}
|
||||
|
||||
// Register the slot as a drop target.
|
||||
if (_drag.IsDragging)
|
||||
_drag.RegisterTarget("ability:" + Ability, slotRect);
|
||||
|
||||
_bonusPill?.Update(gt, input);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
// Bottom rule
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1),
|
||||
new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)100));
|
||||
|
||||
// Name column
|
||||
_nameFont.DrawText(sb, Ability.ToString(), new Vector2(Bounds.X, Bounds.Y + 8), CodexColors.Ink);
|
||||
string sub = LongName + (IsPrimary ? " · primary" : "");
|
||||
_tagFont.DrawText(sb, sub.ToUpperInvariant(), new Vector2(Bounds.X, Bounds.Y + 8 + _nameFont.LineHeight), CodexColors.InkMute);
|
||||
_bonusPill?.Draw(sb, gt);
|
||||
|
||||
// Slot
|
||||
var slotRect = SlotRect();
|
||||
bool dragHover = _drag.IsDragging && slotRect.Contains(_drag.CursorPosition);
|
||||
bool filled = Assigned is not null;
|
||||
Color slotBorder = dragHover ? CodexColors.Seal : (filled ? CodexColors.InkSoft : CodexColors.InkMute);
|
||||
Color slotFill = dragHover
|
||||
? new Color(CodexColors.Seal.R, CodexColors.Seal.G, CodexColors.Seal.B, (byte)28)
|
||||
: (filled ? new Color(180, 138, 60, 22) : CodexColors.Bg);
|
||||
sb.Draw(_atlas.Pixel, slotRect, slotFill);
|
||||
DrawBorder(sb, slotRect, slotBorder, filled ? 1 : 1, dashed: !filled);
|
||||
|
||||
if (filled)
|
||||
{
|
||||
string label = Assigned!.Value.ToString();
|
||||
var s = _slotFont.MeasureString(label);
|
||||
_slotFont.DrawText(sb, label, new Vector2(slotRect.X + (slotRect.Width - s.X) / 2f,
|
||||
slotRect.Y + (slotRect.Height - _slotFont.LineHeight) / 2f),
|
||||
CodexColors.Ink);
|
||||
}
|
||||
else
|
||||
{
|
||||
string dash = "—";
|
||||
var s = _slotFont.MeasureString(dash);
|
||||
_slotFont.DrawText(sb, dash, new Vector2(slotRect.X + (slotRect.Width - s.X) / 2f,
|
||||
slotRect.Y + (slotRect.Height - _slotFont.LineHeight) / 2f),
|
||||
CodexColors.InkMute);
|
||||
}
|
||||
|
||||
// Formula column ("base 13" when bonus is non-zero)
|
||||
var formulaRect = new Rectangle(slotRect.Right + 14, Bounds.Y, 80, Bounds.Height);
|
||||
if (filled && Bonus != 0)
|
||||
{
|
||||
string text = "base " + Assigned!.Value;
|
||||
_modFont.DrawText(sb, text, new Vector2(formulaRect.X, formulaRect.Y + (formulaRect.Height - _modFont.LineHeight) / 2f), CodexColors.InkMute);
|
||||
}
|
||||
|
||||
// Final score + modifier
|
||||
var finalRect = new Rectangle(formulaRect.Right + 8, Bounds.Y, 90, Bounds.Height);
|
||||
if (filled)
|
||||
{
|
||||
int final = Assigned!.Value + Bonus;
|
||||
int mod = AbilityScores.Mod(final);
|
||||
string fLabel = final.ToString();
|
||||
string mLabel = (mod >= 0 ? "+" : "") + mod;
|
||||
var fSize = _slotFont.MeasureString(fLabel);
|
||||
_slotFont.DrawText(sb, fLabel, new Vector2(finalRect.X, finalRect.Y + 4), CodexColors.Ink);
|
||||
_modFont.DrawText(sb, mLabel, new Vector2(finalRect.X + fSize.X + 6, finalRect.Y + 14),
|
||||
mod >= 0 ? CodexColors.Seal : CodexColors.InkMute);
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
var barRect = new Rectangle(finalRect.Right + 12, Bounds.Y + 24, Bounds.Right - finalRect.Right - 16, 6);
|
||||
sb.Draw(_atlas.Pixel, barRect, CodexColors.Bg);
|
||||
DrawBorder(sb, barRect, CodexColors.Rule, 1, dashed: false);
|
||||
if (filled)
|
||||
{
|
||||
int final = System.Math.Clamp(Assigned!.Value + Bonus, 0, 20);
|
||||
int fillW = (int)(barRect.Width * (final / 20f));
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(barRect.X, barRect.Y, fillW, barRect.Height), CodexColors.Gild);
|
||||
}
|
||||
}
|
||||
|
||||
private Rectangle SlotRect() => new(Bounds.X + 160, Bounds.Y + 6, 60, Bounds.Height - 12);
|
||||
|
||||
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t, bool dashed)
|
||||
{
|
||||
if (!dashed)
|
||||
{
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, r.Width, t), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Bottom - t, r.Width, t), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, t, r.Height), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.Right - t, r.Y, t, r.Height), c);
|
||||
return;
|
||||
}
|
||||
// Dashed: draw 4-px dashes with 3-px gaps
|
||||
for (int x = r.X; x < r.Right; x += 7)
|
||||
{
|
||||
int w = System.Math.Min(4, r.Right - x);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(x, r.Y, w, t), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(x, r.Bottom - t, w, t), c);
|
||||
}
|
||||
for (int y = r.Y; y < r.Bottom; y += 7)
|
||||
{
|
||||
int h = System.Math.Min(4, r.Bottom - y);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, y, t, h), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.Right - t, y, t, h), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One draggable value in the stat pool. Renders a small parchment tile
|
||||
/// with the rolled / standard-array number. Drag begins on left-mouse-down;
|
||||
/// the drag-drop controller then takes over the visual via its ghost
|
||||
/// callback.
|
||||
/// </summary>
|
||||
public sealed class CodexPoolDie : CodexWidget
|
||||
{
|
||||
public int Value { get; }
|
||||
public int IndexInPool { get; set; }
|
||||
public bool IsSelected { get; set; }
|
||||
public System.Action<StatPoolPayload>? OnDragStart { get; set; }
|
||||
public System.Action? OnClick { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly DragDropController _drag;
|
||||
private readonly SpriteFontBase _font = CodexFonts.DisplayMedium;
|
||||
|
||||
public CodexPoolDie(int value, int indexInPool, CodexAtlas atlas, DragDropController drag)
|
||||
{
|
||||
Value = value;
|
||||
IndexInPool = indexInPool;
|
||||
_atlas = atlas;
|
||||
_drag = drag;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available) => new(56, 56);
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
if (input.LeftJustPressed && ContainsPoint(input.MousePosition) && !_drag.IsDragging)
|
||||
{
|
||||
// Begin drag.
|
||||
OnDragStart?.Invoke(new StatPoolPayload { Source = "pool", Value = Value, PoolIdx = IndexInPool });
|
||||
_drag.BeginDrag(
|
||||
new StatPoolPayload { Source = "pool", Value = Value, PoolIdx = IndexInPool },
|
||||
(sb, p) => DrawGhost(sb, p));
|
||||
}
|
||||
if (input.LeftJustReleased && ContainsPoint(input.MousePosition) && !_drag.IsDragging)
|
||||
OnClick?.Invoke();
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
Color border = IsSelected ? 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);
|
||||
|
||||
string label = Value.ToString();
|
||||
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),
|
||||
CodexColors.Ink);
|
||||
}
|
||||
|
||||
private void DrawGhost(SpriteBatch sb, Point cursor)
|
||||
{
|
||||
var rect = new Rectangle(cursor.X - 28, cursor.Y - 28, 56, 56);
|
||||
var c = new Color(180, 138, 60, 200);
|
||||
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), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(rect.X, rect.Bottom - 1, rect.Width, 1), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(rect.X, rect.Y, 1, rect.Height), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(rect.Right - 1, rect.Y, 1, rect.Height), c);
|
||||
|
||||
string label = Value.ToString();
|
||||
var s = _font.MeasureString(label);
|
||||
_font.DrawText(sb, label, new Vector2(rect.X + (rect.Width - s.X) / 2f,
|
||||
rect.Y + (rect.Height - _font.LineHeight) / 2f),
|
||||
CodexColors.Ink);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
public enum CodexButtonVariant { Primary, Ghost, Small }
|
||||
|
||||
/// <summary>
|
||||
/// Codex-styled push button. The three variants match the React design's
|
||||
/// <c>.btn.primary / .btn.ghost / .btn.small</c>:
|
||||
/// - Primary: gilded fill on parchment, used for Confirm + Next-style actions.
|
||||
/// - Ghost: ink border, transparent fill, used for Back + secondary actions.
|
||||
/// - Small: smaller padding/font, used inline (Reroll / Auto-assign / Clear).
|
||||
/// </summary>
|
||||
public sealed class CodexButton : CodexWidget
|
||||
{
|
||||
public string Text { get; set; }
|
||||
public CodexButtonVariant Variant { get; set; }
|
||||
public System.Action? OnClick { get; set; }
|
||||
public int? FixedWidth { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly SpriteFontBase _font;
|
||||
private bool _hovered;
|
||||
private bool _pressed;
|
||||
|
||||
public CodexButton(string text, CodexAtlas atlas, CodexButtonVariant variant = CodexButtonVariant.Ghost,
|
||||
System.Action? onClick = null, int? fixedWidth = null)
|
||||
{
|
||||
Text = text;
|
||||
_atlas = atlas;
|
||||
Variant = variant;
|
||||
OnClick = onClick;
|
||||
FixedWidth = fixedWidth;
|
||||
_font = variant == CodexButtonVariant.Small ? CodexFonts.MonoTag : CodexFonts.DisplaySmall;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
var s = _font.MeasureString(Text);
|
||||
int padX = Variant == CodexButtonVariant.Small ? 12 : 22;
|
||||
int padY = Variant == CodexButtonVariant.Small ? 6 : 10;
|
||||
int w = FixedWidth ?? ((int)s.X + padX * 2);
|
||||
int h = (int)System.MathF.Ceiling(_font.LineHeight) + padY * 2;
|
||||
return new Point(System.Math.Min(w, available.X), h);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
bool wasHovered = _hovered;
|
||||
_hovered = ContainsPoint(input.MousePosition);
|
||||
if (_hovered && input.LeftJustPressed) _pressed = true;
|
||||
if (input.LeftJustReleased)
|
||||
{
|
||||
if (_pressed && _hovered && Enabled) OnClick?.Invoke();
|
||||
_pressed = false;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
Color fill, border, textColor;
|
||||
switch (Variant)
|
||||
{
|
||||
case CodexButtonVariant.Primary:
|
||||
fill = _hovered ? CodexColors.Seal2 : CodexColors.Seal;
|
||||
border = CodexColors.Seal2;
|
||||
textColor = CodexColors.Bg;
|
||||
break;
|
||||
case CodexButtonVariant.Ghost:
|
||||
fill = _hovered ? CodexColors.Ink : CodexColors.Bg;
|
||||
border = CodexColors.Ink;
|
||||
textColor = _hovered ? CodexColors.Bg : CodexColors.Ink;
|
||||
break;
|
||||
default:
|
||||
fill = _hovered ? CodexColors.Bg2 : CodexColors.Bg;
|
||||
border = CodexColors.Rule;
|
||||
textColor = CodexColors.InkSoft;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!Enabled)
|
||||
{
|
||||
// 40% opacity per .btn[disabled]
|
||||
fill = new Color(fill.R, fill.G, fill.B, (byte)(fill.A * 0.4f));
|
||||
textColor = new Color(textColor.R, textColor.G, textColor.B, (byte)(textColor.A * 0.6f));
|
||||
}
|
||||
|
||||
// Body fill
|
||||
sb.Draw(_atlas.Pixel, Bounds, fill);
|
||||
// 1-px border outline
|
||||
DrawBorder(sb, Bounds, border, 1);
|
||||
|
||||
var s = _font.MeasureString(Text);
|
||||
float tx = Bounds.X + (Bounds.Width - s.X) / 2f;
|
||||
float ty = Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f;
|
||||
_font.DrawText(sb, Text, new Vector2(tx, ty), textColor);
|
||||
}
|
||||
|
||||
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t)
|
||||
{
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, r.Width, t), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Bottom - t, r.Width, t), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, t, r.Height), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.Right - t, r.Y, t, r.Height), c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Selectable card. The clade / species / class / background pickers each
|
||||
/// render a grid of these. Visual states track the React design:
|
||||
/// - Default: rule-coloured 1-px border, parchment fill.
|
||||
/// - Hover: gilded border, subtle gild halo overlay.
|
||||
/// - Selected: seal-red border + inner glow + corner wax-seal accent.
|
||||
///
|
||||
/// Click toggles selection by calling <see cref="OnClick"/>; the parent step
|
||||
/// owns the "what is selected" state and rebuilds. The card's content tree
|
||||
/// is whatever <see cref="Content"/> child is supplied — typically a small
|
||||
/// Column with name + meta + chips.
|
||||
/// </summary>
|
||||
public sealed class CodexCard : CodexWidget
|
||||
{
|
||||
public CodexWidget? Content { get; set; }
|
||||
public bool IsSelected { get; set; }
|
||||
public System.Action? OnClick { get; set; }
|
||||
public Texture2D? CornerSigil { get; set; }
|
||||
public string? CornerLetter { get; set; } // overlay glyph drawn on the sigil placeholder
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private bool _hovered;
|
||||
private const int Pad = CodexDensity.CardPad;
|
||||
|
||||
public CodexCard(CodexAtlas atlas, CodexWidget? content = null, bool selected = false, System.Action? onClick = null)
|
||||
{
|
||||
_atlas = atlas;
|
||||
Content = content;
|
||||
if (content is not null) content.Parent = this;
|
||||
IsSelected = selected;
|
||||
OnClick = onClick;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
if (Content is null) return new Point(System.Math.Min(CodexDensity.CardWidth, available.X), 80);
|
||||
var inner = new Point(available.X - Pad * 2, available.Y - Pad * 2);
|
||||
var s = Content.Measure(inner);
|
||||
return new Point(s.X + Pad * 2, s.Y + Pad * 2);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
Content?.Arrange(new Rectangle(
|
||||
bounds.X + Pad, bounds.Y + Pad,
|
||||
bounds.Width - Pad * 2,
|
||||
bounds.Height - Pad * 2));
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
_hovered = ContainsPoint(input.MousePosition);
|
||||
if (_hovered && input.LeftJustReleased) OnClick?.Invoke();
|
||||
Content?.Update(gt, input);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
// Body fill — parchment shade
|
||||
sb.Draw(_atlas.Pixel, Bounds, CodexColors.Bg2);
|
||||
// Subtle top-down lift overlay (2-px gradient strip) to match `linear-gradient(180deg, rgba(255,250,235,0.05), transparent 30%)`.
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 2), new Color(255, 250, 235, 18));
|
||||
|
||||
// Border colour by state.
|
||||
Color border = IsSelected ? CodexColors.Seal : (_hovered ? CodexColors.Gild : CodexColors.Rule);
|
||||
int thickness = IsSelected ? 2 : 1;
|
||||
DrawBorder(sb, Bounds, border, thickness);
|
||||
|
||||
if (IsSelected)
|
||||
{
|
||||
// Inner glow strip — 1px inside the border.
|
||||
DrawBorder(sb, new Rectangle(Bounds.X + thickness, Bounds.Y + thickness, Bounds.Width - thickness * 2, Bounds.Height - thickness * 2),
|
||||
new Color(border.R, border.G, border.B, (byte)40), 1);
|
||||
// Corner wax-seal accent
|
||||
int sealSize = 28;
|
||||
sb.Draw(_atlas.WaxSeal, new Rectangle(Bounds.Right - sealSize / 2 - 4, Bounds.Y - sealSize / 2 + 4, sealSize, sealSize), Color.White);
|
||||
}
|
||||
else if (_hovered)
|
||||
{
|
||||
sb.Draw(_atlas.Pixel, Bounds, new Color(CodexColors.Gild.R, CodexColors.Gild.G, CodexColors.Gild.B, (byte)10));
|
||||
}
|
||||
|
||||
Content?.Draw(sb, gt);
|
||||
}
|
||||
|
||||
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t)
|
||||
{
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, r.Width, t), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Bottom - t, r.Width, t), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, t, r.Height), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.Right - t, r.Y, t, r.Height), c);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 7-step horizontal stepper at the top of the wizard. Each step exposes a
|
||||
/// locked / active / complete state with the matching marker (✕ / Roman /
|
||||
/// ✓). Clicking a non-locked step navigates there.
|
||||
/// </summary>
|
||||
public sealed class CodexStepper : CodexWidget
|
||||
{
|
||||
public string[] Names { get; }
|
||||
public int Current { get; set; }
|
||||
public bool[] Complete { get; set; }
|
||||
public bool[] Locked { get; set; }
|
||||
public System.Action<int>? OnPick { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly SpriteFontBase _romanFont = CodexFonts.DisplayMedium;
|
||||
private readonly SpriteFontBase _labelFont = CodexFonts.MonoTagSmall;
|
||||
|
||||
public CodexStepper(string[] names, CodexAtlas atlas)
|
||||
{
|
||||
Names = names;
|
||||
Complete = new bool[names.Length];
|
||||
Locked = new bool[names.Length];
|
||||
_atlas = atlas;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available) => new(available.X, 64);
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
private static readonly string[] Roman = new[] { "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX" };
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
if (!input.LeftJustReleased) return;
|
||||
int colW = Bounds.Width / Names.Length;
|
||||
for (int i = 0; i < Names.Length; i++)
|
||||
{
|
||||
var cell = new Rectangle(Bounds.X + i * colW, Bounds.Y, colW, Bounds.Height);
|
||||
if (cell.Contains(input.MousePosition) && !Locked[i] && i != Current) { OnPick?.Invoke(i); return; }
|
||||
}
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
// Opaque parchment background so the stepper masks any body
|
||||
// scroll-overflow that drew under it.
|
||||
sb.Draw(_atlas.Pixel, Bounds, CodexColors.Bg);
|
||||
// Top + bottom rule
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 1), CodexColors.Rule);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1), CodexColors.Rule);
|
||||
sb.Draw(_atlas.Pixel, Bounds, new Color(0, 0, 0, 6));
|
||||
|
||||
int colW = Bounds.Width / Names.Length;
|
||||
for (int i = 0; i < Names.Length; i++)
|
||||
{
|
||||
var cell = new Rectangle(Bounds.X + i * colW, Bounds.Y, colW, Bounds.Height);
|
||||
// Vertical separator between cells.
|
||||
if (i < Names.Length - 1)
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(cell.Right - 1, cell.Y + 4, 1, cell.Height - 8),
|
||||
new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)128));
|
||||
|
||||
bool isCurrent = i == Current;
|
||||
bool isComplete = Complete[i] && !isCurrent;
|
||||
bool isLocked = Locked[i];
|
||||
// Numeral / mark
|
||||
string mark = isLocked ? "✕" : (isComplete ? "✓" : Roman[i]);
|
||||
Color numColor = isLocked ? CodexColors.InkMute
|
||||
: isComplete ? CodexColors.Seal
|
||||
: isCurrent ? CodexColors.Ink
|
||||
: CodexColors.InkMute;
|
||||
|
||||
var ms = _romanFont.MeasureString(mark);
|
||||
float numX = cell.X + (cell.Width - ms.X) / 2f;
|
||||
float numY = cell.Y + 12;
|
||||
_romanFont.DrawText(sb, mark, new Vector2(numX, numY), numColor);
|
||||
|
||||
// Step name
|
||||
var ls = _labelFont.MeasureString(Names[i]);
|
||||
float lx = cell.X + (cell.Width - ls.X) / 2f;
|
||||
float ly = cell.Y + cell.Height - _labelFont.LineHeight - 8;
|
||||
Color labelColor = isLocked ? new Color(CodexColors.InkMute.R, CodexColors.InkMute.G, CodexColors.InkMute.B, (byte)115)
|
||||
: isCurrent ? CodexColors.Ink : CodexColors.InkMute;
|
||||
_labelFont.DrawText(sb, Names[i].ToUpperInvariant(), new Vector2(lx, ly), labelColor);
|
||||
|
||||
if (isCurrent)
|
||||
sb.Draw(_atlas.Pixel, new Rectangle((int)(cell.X + cell.Width * 0.14f), cell.Bottom - 2, (int)(cell.Width * 0.72f), 2), CodexColors.Gild);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
public enum CheckboxState { Default, Checked, LockedFromBg, Unavailable }
|
||||
|
||||
/// <summary>
|
||||
/// One row of the skill picker. Visual states mirror the React design:
|
||||
/// - Default: small ink-mute checkbox, hover gilds
|
||||
/// - Checked: seal-red filled checkbox + ✓
|
||||
/// - LockedFromBg: gild-filled checkbox + ✓ (sealed by background, can't toggle)
|
||||
/// - Unavailable: dashed underline, faded text — class doesn't offer it
|
||||
/// Click toggles only when state is Default or Checked.
|
||||
/// </summary>
|
||||
public sealed class CodexCheckboxRow : CodexWidget
|
||||
{
|
||||
public string Label { get; set; }
|
||||
public string SourceTag { get; set; }
|
||||
public CheckboxState State { get; set; }
|
||||
public System.Action? OnClick { get; set; }
|
||||
public System.Action? OnHover { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly SpriteFontBase _font = CodexFonts.SerifBody;
|
||||
private readonly SpriteFontBase _tagFont = CodexFonts.MonoTagSmall;
|
||||
private bool _hovered;
|
||||
|
||||
public CodexCheckboxRow(string label, string sourceTag, CheckboxState state, CodexAtlas atlas)
|
||||
{
|
||||
Label = label;
|
||||
SourceTag = sourceTag;
|
||||
State = state;
|
||||
_atlas = atlas;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available) => new(available.X, 28);
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
_hovered = ContainsPoint(input.MousePosition);
|
||||
// OnHover fires every frame the cursor is over the row, not just
|
||||
// on hover-enter. The popover is shown only while a trigger calls
|
||||
// Show() each frame (CodexHoverPopover.IsShown decays in one tick
|
||||
// when no trigger requests it), so a single transition-only call
|
||||
// would flash the popover for one frame and then hide it.
|
||||
if (_hovered) OnHover?.Invoke();
|
||||
if (_hovered && input.LeftJustReleased)
|
||||
{
|
||||
if (State == CheckboxState.Default || State == CheckboxState.Checked) OnClick?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
// Bottom rule
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1),
|
||||
new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)80));
|
||||
|
||||
// Checkbox
|
||||
int boxSize = 18;
|
||||
var box = new Rectangle(Bounds.X + 2, Bounds.Y + (Bounds.Height - boxSize) / 2, boxSize, boxSize);
|
||||
Color boxFill, boxBorder, checkColor = CodexColors.Bg;
|
||||
switch (State)
|
||||
{
|
||||
case CheckboxState.Checked:
|
||||
boxFill = CodexColors.Seal;
|
||||
boxBorder = CodexColors.Seal;
|
||||
break;
|
||||
case CheckboxState.LockedFromBg:
|
||||
boxFill = CodexColors.Gild;
|
||||
boxBorder = CodexColors.Gild;
|
||||
break;
|
||||
case CheckboxState.Unavailable:
|
||||
boxFill = Color.Transparent;
|
||||
boxBorder = new Color(CodexColors.InkMute.R, CodexColors.InkMute.G, CodexColors.InkMute.B, (byte)90);
|
||||
break;
|
||||
default:
|
||||
boxFill = Color.Transparent;
|
||||
boxBorder = _hovered ? CodexColors.Gild : CodexColors.InkMute;
|
||||
break;
|
||||
}
|
||||
sb.Draw(_atlas.Pixel, box, boxFill);
|
||||
DrawBorder(sb, box, boxBorder, 1);
|
||||
|
||||
if (State == CheckboxState.Checked || State == CheckboxState.LockedFromBg)
|
||||
{
|
||||
string mark = "✓";
|
||||
var s = _font.MeasureString(mark);
|
||||
_font.DrawText(sb, mark, new Vector2(box.X + (box.Width - s.X) / 2f, box.Y + (box.Height - _font.LineHeight) / 2f),
|
||||
checkColor);
|
||||
}
|
||||
|
||||
// Label text
|
||||
Color labelColor = State switch
|
||||
{
|
||||
CheckboxState.Checked => CodexColors.Seal,
|
||||
CheckboxState.LockedFromBg => CodexColors.Gild,
|
||||
CheckboxState.Unavailable => new Color(CodexColors.InkMute.R, CodexColors.InkMute.G, CodexColors.InkMute.B, (byte)90),
|
||||
_ => _hovered ? CodexColors.Gild : CodexColors.Ink,
|
||||
};
|
||||
_font.DrawText(sb, Label, new Vector2(box.Right + 8, Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f), labelColor);
|
||||
|
||||
// Right-aligned source tag
|
||||
if (!string.IsNullOrEmpty(SourceTag))
|
||||
{
|
||||
var ts = _tagFont.MeasureString(SourceTag);
|
||||
_tagFont.DrawText(sb, SourceTag.ToUpperInvariant(),
|
||||
new Vector2(Bounds.Right - ts.X - 4, Bounds.Y + (Bounds.Height - _tagFont.LineHeight) / 2f),
|
||||
CodexColors.InkMute);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawBorder(SpriteBatch sb, Rectangle r, Color c, int t)
|
||||
{
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, r.Width, t), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Bottom - t, r.Width, t), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.X, r.Y, t, r.Height), c);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(r.Right - t, r.Y, t, r.Height), c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
public enum ChipKind
|
||||
{
|
||||
Trait, // gild edge, italic serif, default for clade/species/feature traits
|
||||
TraitDetriment,
|
||||
SkillFromBg, // gild edge — sealed by background
|
||||
SkillFromClass, // seal-red edge — picked from class options
|
||||
Language, // mono-tag pill, ink border
|
||||
BgFeature, // seal-red trait variant for the background card's feature row
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pill-shaped chip used for traits, skills, languages, and background feature
|
||||
/// names. Hovering surfaces a popover with the full description; clicking
|
||||
/// fires <see cref="OnClick"/> for the few cases the screen needs (skill toggle).
|
||||
/// </summary>
|
||||
public sealed class CodexChip : CodexWidget
|
||||
{
|
||||
public string Text { get; set; }
|
||||
public string PopoverTitle { get; set; }
|
||||
public string PopoverBody { get; set; }
|
||||
public string? PopoverTag { get; set; }
|
||||
public ChipKind Kind { get; set; }
|
||||
public System.Action? OnClick { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly SpriteFontBase _font;
|
||||
|
||||
/// <summary>The screen sets this when the user hovers over us; the screen handles popover layout.</summary>
|
||||
public bool IsHovered { get; private set; }
|
||||
|
||||
public CodexChip(string text, ChipKind kind, CodexAtlas atlas,
|
||||
string popoverTitle = "", string popoverBody = "", string? popoverTag = null)
|
||||
{
|
||||
Text = text;
|
||||
Kind = kind;
|
||||
_atlas = atlas;
|
||||
PopoverTitle = popoverTitle;
|
||||
PopoverBody = popoverBody;
|
||||
PopoverTag = popoverTag;
|
||||
_font = kind == ChipKind.Language ? CodexFonts.MonoTagSmall : CodexFonts.SerifItalic;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
var s = _font.MeasureString(Text);
|
||||
return new Point((int)s.X + CodexDensity.ChipPad * 3, (int)System.MathF.Ceiling(_font.LineHeight) + CodexDensity.ChipPad * 2);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
IsHovered = ContainsPoint(input.MousePosition);
|
||||
if (IsHovered && input.LeftJustReleased) OnClick?.Invoke();
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
var (fill, border, text) = GetColors();
|
||||
if (IsHovered) fill = HoverShift(fill);
|
||||
sb.Draw(_atlas.Pixel, Bounds, fill);
|
||||
|
||||
// 1-px outline.
|
||||
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(Text);
|
||||
float tx = Bounds.X + (Bounds.Width - s.X) / 2f;
|
||||
float ty = Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f;
|
||||
_font.DrawText(sb, Text, new Vector2(tx, ty), text);
|
||||
}
|
||||
|
||||
private (Color fill, Color border, Color text) GetColors() => Kind switch
|
||||
{
|
||||
ChipKind.Trait => (Mix(CodexColors.Gild, CodexColors.Bg, 0.07f), Mix(CodexColors.Gild, CodexColors.Rule, 0.55f), CodexColors.Ink),
|
||||
ChipKind.TraitDetriment => (Mix(CodexColors.Seal, CodexColors.Bg, 0.08f), Mix(CodexColors.Seal, CodexColors.Rule, 0.55f), CodexColors.Ink),
|
||||
ChipKind.SkillFromBg => (Mix(CodexColors.Gild, CodexColors.Bg, 0.06f), Mix(CodexColors.Gild, CodexColors.Rule, 0.60f), CodexColors.Gild),
|
||||
ChipKind.SkillFromClass => (Mix(CodexColors.Seal, CodexColors.Bg, 0.06f), Mix(CodexColors.Seal, CodexColors.Rule, 0.55f), CodexColors.Seal),
|
||||
ChipKind.Language => (CodexColors.Bg, CodexColors.Rule, CodexColors.InkSoft),
|
||||
ChipKind.BgFeature => (Mix(CodexColors.Seal, CodexColors.Bg, 0.08f), Mix(CodexColors.Seal, CodexColors.Rule, 0.55f), CodexColors.Seal),
|
||||
_ => (CodexColors.Bg, CodexColors.Rule, CodexColors.Ink),
|
||||
};
|
||||
|
||||
private static Color Mix(Color a, Color b, float t)
|
||||
=> new(
|
||||
(byte)(a.R * t + b.R * (1 - t)),
|
||||
(byte)(a.G * t + b.G * (1 - t)),
|
||||
(byte)(a.B * t + b.B * (1 - t)),
|
||||
(byte)0xFF);
|
||||
|
||||
private static Color HoverShift(Color c)
|
||||
=> new((byte)System.Math.Min(255, c.R + 14), (byte)System.Math.Min(255, c.G + 14), (byte)System.Math.Min(255, c.B + 14), c.A);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Small +N / −N pill that sits next to ability names. Visually a chip with
|
||||
/// monospace text and seal-red (positive) or ink-mute (negative) chrome.
|
||||
/// Hover surfaces a popover listing the contributing sources (clade, species).
|
||||
/// </summary>
|
||||
public sealed class CodexBonusPill : CodexWidget
|
||||
{
|
||||
public int Total { get; }
|
||||
public string PopoverBody { get; set; } = "";
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly SpriteFontBase _font;
|
||||
public bool IsHovered { get; private set; }
|
||||
|
||||
public CodexBonusPill(int total, CodexAtlas atlas, string popoverBody = "")
|
||||
{
|
||||
Total = total;
|
||||
_atlas = atlas;
|
||||
PopoverBody = popoverBody;
|
||||
_font = CodexFonts.MonoTag;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
string label = (Total >= 0 ? "+" : "") + Total.ToString();
|
||||
var s = _font.MeasureString(label);
|
||||
return new Point((int)s.X + 12, (int)System.MathF.Ceiling(_font.LineHeight) + 6);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input) => IsHovered = ContainsPoint(input.MousePosition);
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
var border = Total >= 0 ? CodexColors.Seal : CodexColors.InkMute;
|
||||
var fill = Total >= 0
|
||||
? new Color(CodexColors.Seal.R, CodexColors.Seal.G, CodexColors.Seal.B, (byte)(IsHovered ? 36 : 18))
|
||||
: new Color(CodexColors.InkMute.R, CodexColors.InkMute.G, CodexColors.InkMute.B, (byte)(IsHovered ? 28 : 14));
|
||||
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);
|
||||
|
||||
string label = (Total >= 0 ? "+" : "") + Total.ToString();
|
||||
var s = _font.MeasureString(label);
|
||||
float tx = Bounds.X + (Bounds.Width - s.X) / 2f;
|
||||
float ty = Bounds.Y + (Bounds.Height - _font.LineHeight) / 2f;
|
||||
_font.DrawText(sb, label, new Vector2(tx, ty), border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Single floating popover panel. The screen owns one instance; widgets
|
||||
/// (chips, bonus pills) request it to show by calling <see cref="Show"/>
|
||||
/// with their trigger bounds + content. Visibility decays automatically
|
||||
/// when the trigger no longer reports as hovered. Position is clamped to
|
||||
/// the viewport so popovers near the right/bottom edges flip to fit.
|
||||
///
|
||||
/// Mirrors the React design's <c>.trait-hint</c>: parchment fill, gilded
|
||||
/// border, italic display title + tag pill, body paragraph in serif body
|
||||
/// face. The "Plainly Reading" footnote is supported via <see cref="Reading"/>.
|
||||
/// </summary>
|
||||
public sealed class CodexHoverPopover : CodexWidget
|
||||
{
|
||||
private readonly CodexAtlas _atlas;
|
||||
private string _title = "";
|
||||
private string _body = "";
|
||||
private string? _tag;
|
||||
private string? _reading;
|
||||
private bool _detriment;
|
||||
private Rectangle _triggerBounds;
|
||||
private bool _showRequestedThisFrame;
|
||||
public bool IsShown { get; private set; }
|
||||
public string? Reading { get => _reading; set => _reading = value; }
|
||||
|
||||
public CodexHoverPopover(CodexAtlas atlas)
|
||||
{
|
||||
_atlas = atlas;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request the popover. Called from a widget's Update when it detects
|
||||
/// hover. The popover stays visible only as long as some widget requests
|
||||
/// it each frame.
|
||||
/// </summary>
|
||||
public void Show(Rectangle triggerBounds, string title, string body, string? tag = null, bool detriment = false)
|
||||
{
|
||||
_triggerBounds = triggerBounds;
|
||||
_title = title;
|
||||
_body = body;
|
||||
_tag = tag;
|
||||
_detriment = detriment;
|
||||
_showRequestedThisFrame = true;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available) => Point.Zero;
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
IsShown = _showRequestedThisFrame;
|
||||
_showRequestedThisFrame = false;
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
if (!IsShown) return;
|
||||
|
||||
const int width = 320;
|
||||
var titleFont = CodexFonts.SerifItalic;
|
||||
var bodyFont = CodexFonts.SerifBody;
|
||||
var tagFont = CodexFonts.MonoTagSmall;
|
||||
|
||||
// Wrap body text to the width.
|
||||
var titleLines = CodexLabel.WrapText(_title, titleFont, width - 32);
|
||||
var bodyLines = CodexLabel.WrapText(_body, bodyFont, width - 32);
|
||||
var readingLines = string.IsNullOrEmpty(_reading) ? System.Array.Empty<string>() : CodexLabel.WrapText(_reading!, bodyFont, width - 32);
|
||||
|
||||
int height = 14
|
||||
+ (int)(titleFont.LineHeight * titleLines.Length)
|
||||
+ 6
|
||||
+ (int)(bodyFont.LineHeight * bodyLines.Length)
|
||||
+ (readingLines.Length > 0 ? 8 + (int)(bodyFont.LineHeight * readingLines.Length) + 4 : 0)
|
||||
+ 12;
|
||||
|
||||
// Position — prefer below the trigger, flip above if it doesn't
|
||||
// fit there, and as a last resort clamp to whichever edge gives
|
||||
// more room. Earlier code clamped only with `if (y < 8) y = 8`,
|
||||
// which would push the popover off the bottom whenever the
|
||||
// trigger sat near the viewport's bottom edge and the popover
|
||||
// didn't fit above either.
|
||||
int x = _triggerBounds.X;
|
||||
if (x + width > _viewport.Width) x = _viewport.Width - width - 8;
|
||||
if (x < 8) x = 8;
|
||||
|
||||
int spaceBelow = _viewport.Height - _triggerBounds.Bottom - 6;
|
||||
int spaceAbove = _triggerBounds.Y - 6;
|
||||
int y;
|
||||
if (height <= spaceBelow)
|
||||
{
|
||||
y = _triggerBounds.Bottom + 6;
|
||||
}
|
||||
else if (height <= spaceAbove)
|
||||
{
|
||||
y = _triggerBounds.Y - height - 6;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Doesn't fit either side; clamp so the popover sits within
|
||||
// the viewport with at least an 8-px margin on the limiting side.
|
||||
y = System.Math.Max(8, _viewport.Height - height - 8);
|
||||
}
|
||||
|
||||
var rect = new Rectangle(x, y, width, height);
|
||||
|
||||
// Background
|
||||
sb.Draw(_atlas.Pixel, rect, CodexColors.Bg2);
|
||||
Color border = _detriment ? CodexColors.Seal : CodexColors.Gild;
|
||||
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);
|
||||
|
||||
int cy = rect.Y + 12;
|
||||
// Title (+ optional tag)
|
||||
foreach (var line in titleLines)
|
||||
{
|
||||
titleFont.DrawText(sb, line, new Vector2(rect.X + 16, cy), CodexColors.Ink);
|
||||
cy += (int)titleFont.LineHeight;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(_tag))
|
||||
{
|
||||
var tagSize = tagFont.MeasureString(_tag);
|
||||
int tagX = rect.X + 16;
|
||||
int tagY = cy;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY, (int)tagSize.X + 12, (int)tagFont.LineHeight + 4), Color.Transparent);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY, (int)tagSize.X + 12, 1), CodexColors.Seal);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY + (int)tagFont.LineHeight + 3, (int)tagSize.X + 12, 1), CodexColors.Seal);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY, 1, (int)tagFont.LineHeight + 4), CodexColors.Seal);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(tagX + (int)tagSize.X + 11, tagY, 1, (int)tagFont.LineHeight + 4), CodexColors.Seal);
|
||||
tagFont.DrawText(sb, _tag, new Vector2(tagX + 6, tagY + 2), CodexColors.Seal);
|
||||
cy += (int)tagFont.LineHeight + 6;
|
||||
}
|
||||
else cy += 6;
|
||||
|
||||
// Body
|
||||
foreach (var line in bodyLines)
|
||||
{
|
||||
bodyFont.DrawText(sb, line, new Vector2(rect.X + 16, cy), CodexColors.InkSoft);
|
||||
cy += (int)bodyFont.LineHeight;
|
||||
}
|
||||
|
||||
if (readingLines.Length > 0)
|
||||
{
|
||||
cy += 4;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(rect.X + 16, cy, rect.Width - 32, 1), new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)128));
|
||||
cy += 6;
|
||||
foreach (var line in readingLines)
|
||||
{
|
||||
bodyFont.DrawText(sb, line, new Vector2(rect.X + 16, cy), CodexColors.InkMute);
|
||||
cy += (int)bodyFont.LineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
Reading = null; // consumed
|
||||
}
|
||||
|
||||
private Rectangle _viewport = new(0, 0, 1280, 800);
|
||||
public void UpdateViewport(Rectangle vp) => _viewport = vp;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Single- or multi-line text widget. Wraps to <see cref="MaxWidth"/> if set;
|
||||
/// otherwise to its arrange width. Color and font are explicit so the same
|
||||
/// widget can render the codex header (DisplayLarge / Ink), an eyebrow
|
||||
/// (MonoTag / InkMute), or a body paragraph (SerifBody / InkSoft).
|
||||
/// </summary>
|
||||
public sealed class CodexLabel : CodexWidget
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public SpriteFontBase Font { get; set; }
|
||||
public Color Color { get; set; } = CodexColors.Ink;
|
||||
public HAlign HAlign { get; set; } = HAlign.Left;
|
||||
|
||||
/// <summary>If set, text wraps when its measured width would exceed this.</summary>
|
||||
public int? MaxWidth { get; set; }
|
||||
|
||||
public CodexLabel(string text, SpriteFontBase font, Color? color = null, HAlign hAlign = HAlign.Left)
|
||||
{
|
||||
Text = text;
|
||||
Font = font;
|
||||
if (color.HasValue) Color = color.Value;
|
||||
HAlign = hAlign;
|
||||
}
|
||||
|
||||
private string[] _wrappedLines = System.Array.Empty<string>();
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
int wrapW = MaxWidth ?? available.X;
|
||||
_wrappedLines = WrapText(Text, Font, wrapW);
|
||||
int width = 0;
|
||||
foreach (var line in _wrappedLines)
|
||||
{
|
||||
var s = Font.MeasureString(line);
|
||||
if (s.X > width) width = (int)System.MathF.Ceiling(s.X);
|
||||
}
|
||||
int height = (int)System.MathF.Ceiling(Font.LineHeight * (_wrappedLines.Length == 0 ? 1 : _wrappedLines.Length));
|
||||
return new Point(System.Math.Min(width, wrapW), height);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
float y = Bounds.Y;
|
||||
foreach (var line in _wrappedLines)
|
||||
{
|
||||
var s = Font.MeasureString(line);
|
||||
float x = HAlign switch
|
||||
{
|
||||
HAlign.Center => Bounds.X + (Bounds.Width - s.X) / 2f,
|
||||
HAlign.Right => Bounds.X + Bounds.Width - s.X,
|
||||
_ => Bounds.X,
|
||||
};
|
||||
Font.DrawText(sb, line, new Vector2(x, y), Color);
|
||||
y += Font.LineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Greedy word wrap. Splits on spaces, fits as many words as possible per
|
||||
/// line, hard-breaks oversize words. Honours embedded \n.
|
||||
/// </summary>
|
||||
public static string[] WrapText(string text, SpriteFontBase font, int maxWidth)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return new[] { "" };
|
||||
var lines = new System.Collections.Generic.List<string>();
|
||||
foreach (var paragraph in text.Split('\n'))
|
||||
{
|
||||
var words = paragraph.Split(' ');
|
||||
var current = new System.Text.StringBuilder();
|
||||
foreach (var word in words)
|
||||
{
|
||||
string trial = current.Length == 0 ? word : current + " " + word;
|
||||
if (font.MeasureString(trial).X > maxWidth && current.Length > 0)
|
||||
{
|
||||
lines.Add(current.ToString());
|
||||
current.Clear();
|
||||
current.Append(word);
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Clear();
|
||||
current.Append(trial);
|
||||
}
|
||||
}
|
||||
lines.Add(current.ToString());
|
||||
}
|
||||
return lines.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decorative horizontal rule with a small central diamond glyph. Mirrors
|
||||
/// the React design's <c>.divider</c> / <c>.clade-group-label::after</c>
|
||||
/// hairline + ornament pattern.
|
||||
/// </summary>
|
||||
public sealed class CodexOrnamentRule : CodexWidget
|
||||
{
|
||||
public Color RuleColor { get; set; } = CodexColors.Rule;
|
||||
public string? Label { get; set; }
|
||||
public SpriteFontBase Font { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
|
||||
public CodexOrnamentRule(CodexAtlas atlas, SpriteFontBase font, string? label = null)
|
||||
{
|
||||
_atlas = atlas;
|
||||
Font = font;
|
||||
Label = label;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available) => new(available.X, 16);
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
int midY = Bounds.Y + Bounds.Height / 2;
|
||||
|
||||
if (!string.IsNullOrEmpty(Label))
|
||||
{
|
||||
var s = Font.MeasureString(Label);
|
||||
int padX = 12;
|
||||
int textX = Bounds.X + 0;
|
||||
// Left rule before the text.
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, midY, 16, 1), RuleColor);
|
||||
int textXStart = Bounds.X + 16 + padX / 2;
|
||||
Font.DrawText(sb, Label, new Vector2(textXStart, midY - Font.LineHeight / 2f), CodexColors.InkMute);
|
||||
int afterText = textXStart + (int)s.X + padX / 2;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(afterText, midY, Bounds.Right - afterText, 1), RuleColor);
|
||||
}
|
||||
else
|
||||
{
|
||||
int half = (Bounds.Width - 16) / 2;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, midY, half, 1), RuleColor);
|
||||
sb.Draw(_atlas.OrnamentDiamond, new Rectangle(Bounds.X + half, midY - 8, 16, 16), CodexColors.Gild);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X + half + 16, midY, half, 1), RuleColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Single-child container that paints a parchment fill and a 1-px ink rule
|
||||
/// border. The review-step summary blocks and the aside container both use
|
||||
/// this; for borderless wrappers (e.g. the page's main column) just nest in
|
||||
/// a <see cref="Column"/> instead.
|
||||
/// </summary>
|
||||
public sealed class CodexPanel : CodexWidget
|
||||
{
|
||||
public CodexWidget? Child { get; set; }
|
||||
public Color BackgroundColor { get; set; } = CodexColors.Bg2;
|
||||
public Color BorderColor { get; set; } = CodexColors.Rule;
|
||||
public bool Bordered { get; set; } = true;
|
||||
public Thickness Inset { get; set; } = new(18, 18, 20, 18);
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
|
||||
public CodexPanel(CodexAtlas atlas, CodexWidget? child = null)
|
||||
{
|
||||
_atlas = atlas;
|
||||
Child = child;
|
||||
if (child is not null) child.Parent = this;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
if (Child is null) return new Point(Inset.HorizontalSum(), Inset.VerticalSum());
|
||||
var inner = new Point(available.X - Inset.HorizontalSum(), available.Y - Inset.VerticalSum());
|
||||
var s = Child.Measure(inner);
|
||||
return new Point(s.X + Inset.HorizontalSum(), s.Y + Inset.VerticalSum());
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
Child?.Arrange(new Rectangle(
|
||||
bounds.X + Inset.Left,
|
||||
bounds.Y + Inset.Top,
|
||||
bounds.Width - Inset.HorizontalSum(),
|
||||
bounds.Height - Inset.VerticalSum()));
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input) => Child?.Update(gt, input);
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
sb.Draw(_atlas.Pixel, Bounds, BackgroundColor);
|
||||
if (Bordered)
|
||||
{
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, Bounds.Width, 1), BorderColor);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 1, Bounds.Width, 1), BorderColor);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Y, 1, Bounds.Height), BorderColor);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.Right - 1, Bounds.Y, 1, Bounds.Height), BorderColor);
|
||||
}
|
||||
Child?.Draw(sb, gt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using FontStashSharp;
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Single-line text input. The display matches the React design's
|
||||
/// <c>input[type=text]</c>: serif-display font, transparent background,
|
||||
/// gilded underline rule that lights up while focused. Used for the name
|
||||
/// field in the Sign step.
|
||||
///
|
||||
/// Receives characters via <see cref="CodexInput.TextEnteredThisFrame"/>;
|
||||
/// the parent screen subscribes to <c>Window.TextInput</c> in Initialize
|
||||
/// and routes them through <see cref="CodexInput.OnTextInput"/>. Backspace,
|
||||
/// Enter and arrow keys are handled in <see cref="Update"/>.
|
||||
/// </summary>
|
||||
public sealed class CodexTextBox : CodexWidget
|
||||
{
|
||||
public string Text { get; set; } = "";
|
||||
public string Placeholder { get; set; } = "";
|
||||
public bool IsFocused { get; set; }
|
||||
public int? FixedWidth { get; set; }
|
||||
public System.Action<string>? OnChanged { get; set; }
|
||||
|
||||
private readonly CodexAtlas _atlas;
|
||||
private readonly SpriteFontBase _font;
|
||||
private float _caretBlink;
|
||||
|
||||
public CodexTextBox(string initial, CodexAtlas atlas, int? fixedWidth = null, System.Action<string>? onChanged = null)
|
||||
{
|
||||
Text = initial;
|
||||
_atlas = atlas;
|
||||
FixedWidth = fixedWidth;
|
||||
OnChanged = onChanged;
|
||||
_font = CodexFonts.DisplayMedium;
|
||||
}
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
int w = FixedWidth ?? System.Math.Min(480, available.X);
|
||||
int h = (int)System.MathF.Ceiling(_font.LineHeight) + 14;
|
||||
return new Point(w, h);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds) { }
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
if (input.LeftJustPressed) IsFocused = ContainsPoint(input.MousePosition);
|
||||
_caretBlink = (_caretBlink + (float)gt.ElapsedGameTime.TotalSeconds) % 1f;
|
||||
if (!IsFocused) return;
|
||||
|
||||
bool changed = false;
|
||||
if (!string.IsNullOrEmpty(input.TextEnteredThisFrame))
|
||||
{
|
||||
Text += input.TextEnteredThisFrame;
|
||||
changed = true;
|
||||
}
|
||||
if (input.KeyJustPressed(Keys.Back) && Text.Length > 0)
|
||||
{
|
||||
Text = Text.Substring(0, Text.Length - 1);
|
||||
changed = true;
|
||||
}
|
||||
if (input.KeyJustPressed(Keys.Enter)) IsFocused = false;
|
||||
if (changed) OnChanged?.Invoke(Text);
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
// Background — none (transparent) per design; we just paint the underline.
|
||||
Color underline = IsFocused ? CodexColors.Gild : CodexColors.Rule;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, Bounds.Bottom - 2, Bounds.Width, 2), underline);
|
||||
|
||||
bool empty = string.IsNullOrEmpty(Text);
|
||||
string display = empty ? Placeholder : Text;
|
||||
Color textColor = empty ? CodexColors.InkMute : CodexColors.Ink;
|
||||
_font.DrawText(sb, display, new Vector2(Bounds.X + 4, Bounds.Y + 6), textColor);
|
||||
|
||||
// Caret blink — top-aligned, follows the end of the text.
|
||||
if (IsFocused && _caretBlink < 0.5f)
|
||||
{
|
||||
float caretX = Bounds.X + 4 + _font.MeasureString(Text).X;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle((int)caretX, Bounds.Y + 6, 1, (int)_font.LineHeight), CodexColors.Gild);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Vertical scroll container. Measures its child at unbounded height to
|
||||
/// learn the full content size, then arranges the child shifted by
|
||||
/// <see cref="ScrollOffset"/>. Mouse-wheel input changes the offset; bounds
|
||||
/// hit-testing for hover/click still uses screen-space, so widgets that
|
||||
/// scroll out of view simply stop receiving cursor events.
|
||||
///
|
||||
/// Drawing is uncapped — child widgets draw in their offset positions, so
|
||||
/// content above/below the visible band can spill into adjacent regions.
|
||||
/// The screen's stepper and nav bar are painted with opaque backgrounds
|
||||
/// to mask this overflow, which is cheaper than scissor clipping and avoids
|
||||
/// the SpriteBatch end/restart dance.
|
||||
/// </summary>
|
||||
public sealed class ScrollPanel : CodexWidget
|
||||
{
|
||||
public CodexWidget? Child { get; set; }
|
||||
public int ScrollOffset { get; private set; }
|
||||
private int _contentHeight;
|
||||
private readonly CodexAtlas _atlas;
|
||||
|
||||
/// <summary>
|
||||
/// Fires whenever the wheel changes the scroll offset. The wizard
|
||||
/// uses this to persist offset across <c>InvalidateLayout</c>: the
|
||||
/// rebuilt tree creates a new <see cref="ScrollPanel"/>, but the
|
||||
/// stored value gets re-applied via <see cref="SetInitialScroll"/>.
|
||||
/// </summary>
|
||||
public System.Action<int>? OnScrollChanged { get; set; }
|
||||
|
||||
public ScrollPanel(CodexAtlas atlas, CodexWidget? child = null)
|
||||
{
|
||||
_atlas = atlas;
|
||||
Child = child;
|
||||
if (child is not null) child.Parent = this;
|
||||
}
|
||||
|
||||
/// <summary>Restore a saved offset before the first measure-arrange pass runs.</summary>
|
||||
public void SetInitialScroll(int offset) => ScrollOffset = offset;
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
if (Child is null)
|
||||
{
|
||||
_contentHeight = 0;
|
||||
return new Point(available.X, available.Y);
|
||||
}
|
||||
var s = Child.Measure(new Point(System.Math.Max(0, available.X - 8), int.MaxValue / 2));
|
||||
_contentHeight = s.Y;
|
||||
return new Point(available.X, available.Y);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
ClampScroll();
|
||||
Child?.Arrange(new Rectangle(bounds.X, bounds.Y - ScrollOffset,
|
||||
System.Math.Max(0, bounds.Width - 8), _contentHeight));
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
if (Bounds.Contains(input.MousePosition) && input.ScrollDelta != 0)
|
||||
{
|
||||
ScrollOffset -= input.ScrollDelta / 2;
|
||||
ClampScroll();
|
||||
Child?.Arrange(new Rectangle(Bounds.X, Bounds.Y - ScrollOffset,
|
||||
System.Math.Max(0, Bounds.Width - 8), _contentHeight));
|
||||
OnScrollChanged?.Invoke(ScrollOffset);
|
||||
}
|
||||
|
||||
// Nest a clip into the visible viewport so children scrolled out
|
||||
// of view don't register hover/click. Intersect with any outer
|
||||
// clip the parent already set so we never widen its scope.
|
||||
var prevClip = input.GetMouseClip();
|
||||
var newClip = prevClip is Rectangle p ? Rectangle.Intersect(p, Bounds) : Bounds;
|
||||
input.SetMouseClip(newClip);
|
||||
Child?.Update(gt, input);
|
||||
if (prevClip is Rectangle r) input.SetMouseClip(r);
|
||||
else input.ClearMouseClip();
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
Child?.Draw(sb, gt);
|
||||
|
||||
// Scrollbar thumb on the right edge — only when content overflows.
|
||||
if (_contentHeight > Bounds.Height)
|
||||
{
|
||||
int trackX = Bounds.Right - 4;
|
||||
int trackH = Bounds.Height;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(trackX, Bounds.Y, 2, trackH),
|
||||
new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)80));
|
||||
|
||||
int thumbH = System.Math.Max(24, (int)((float)trackH * trackH / _contentHeight));
|
||||
float t = (float)ScrollOffset / System.Math.Max(1, _contentHeight - trackH);
|
||||
int thumbY = Bounds.Y + (int)((trackH - thumbH) * t);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(trackX, thumbY, 2, thumbH), CodexColors.Gild);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClampScroll()
|
||||
{
|
||||
int max = System.Math.Max(0, _contentHeight - Bounds.Height);
|
||||
if (ScrollOffset < 0) ScrollOffset = 0;
|
||||
if (ScrollOffset > max) ScrollOffset = max;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user