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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user