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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
using Theriapolis.Game.Screens;
|
||||
|
||||
namespace Theriapolis.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Root MonoGame game class.
|
||||
/// Owns the screen stack, SpriteBatch, and the path to content data files.
|
||||
/// </summary>
|
||||
public sealed class Game1 : Microsoft.Xna.Framework.Game
|
||||
{
|
||||
private readonly GraphicsDeviceManager _graphics;
|
||||
private SpriteBatch _spriteBatch = null!;
|
||||
|
||||
public ScreenManager Screens { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the Content/Data directory containing JSON data files.
|
||||
/// Defaults to "Data" relative to the executable; Desktop shell overrides this.
|
||||
/// </summary>
|
||||
public string ContentDataDirectory { get; set; } = "Data";
|
||||
|
||||
/// <summary>
|
||||
/// Path to the Content/Gfx directory containing sprite assets. Defaults to
|
||||
/// "Gfx" relative to the executable; Desktop shell overrides this. The
|
||||
/// TacticalAtlas reads <c>Gfx/tactical/surface/*.png</c> and
|
||||
/// <c>Gfx/tactical/deco/*.png</c> from here, falling back to procedural
|
||||
/// placeholders for any tile that has no PNG yet.
|
||||
/// </summary>
|
||||
public string ContentGfxDirectory { get; set; } = "Gfx";
|
||||
|
||||
/// <summary>
|
||||
/// Strongly-typed asset bundle for CodexUI screens. Loaded once during
|
||||
/// <see cref="LoadContent"/> so every CodexScreen can reference the
|
||||
/// atlas without re-reading PNGs from disk.
|
||||
/// </summary>
|
||||
public CodexAtlas CodexAtlas { get; private set; } = new();
|
||||
|
||||
// Dev background colour — distinctive so it's obvious when nothing is drawn
|
||||
private static readonly Color DevClear = new(18, 24, 48);
|
||||
|
||||
public Game1()
|
||||
{
|
||||
_graphics = new GraphicsDeviceManager(this)
|
||||
{
|
||||
PreferredBackBufferWidth = 1280,
|
||||
PreferredBackBufferHeight = 800,
|
||||
IsFullScreen = false,
|
||||
};
|
||||
Content.RootDirectory = "Content";
|
||||
IsMouseVisible = true;
|
||||
Window.Title = "Theriapolis";
|
||||
Window.AllowUserResizing = true;
|
||||
}
|
||||
|
||||
protected override void Initialize()
|
||||
{
|
||||
// Initialise Myra before any screen tries to use it
|
||||
MyraEnvironment.Game = this;
|
||||
|
||||
Screens = new ScreenManager(this);
|
||||
Screens.Push(new TitleScreen());
|
||||
|
||||
base.Initialize();
|
||||
}
|
||||
|
||||
protected override void LoadContent()
|
||||
{
|
||||
_spriteBatch = new SpriteBatch(GraphicsDevice);
|
||||
|
||||
// CodexUI assets + fonts — must load before any CodexScreen is pushed.
|
||||
// Fonts live under a sibling directory of Gfx; resolve via the parent.
|
||||
CodexAtlas.LoadAll(GraphicsDevice, ContentGfxDirectory);
|
||||
string contentRoot = System.IO.Path.GetDirectoryName(ContentGfxDirectory.TrimEnd('/', '\\')) ?? ".";
|
||||
CodexFonts.LoadAll(GraphicsDevice, contentRoot);
|
||||
}
|
||||
|
||||
protected override void Update(GameTime gameTime)
|
||||
{
|
||||
// Global ESC from title → exit (ScreenManager handles in-game ESC)
|
||||
if (Screens.Current is TitleScreen && Keyboard.GetState().IsKeyDown(Keys.Escape))
|
||||
Exit();
|
||||
|
||||
Screens.Update(gameTime);
|
||||
base.Update(gameTime);
|
||||
}
|
||||
|
||||
protected override void Draw(GameTime gameTime)
|
||||
{
|
||||
GraphicsDevice.Clear(DevClear);
|
||||
Screens.Draw(gameTime, _spriteBatch);
|
||||
base.Draw(gameTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
|
||||
namespace Theriapolis.Game.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Single-frame input snapshot with helper methods.
|
||||
/// Call Update() once per frame before any input queries.
|
||||
/// </summary>
|
||||
public sealed class InputManager
|
||||
{
|
||||
private KeyboardState _prevKeys;
|
||||
private KeyboardState _currKeys;
|
||||
private MouseState _prevMouse;
|
||||
private MouseState _currMouse;
|
||||
|
||||
public void Update()
|
||||
{
|
||||
_prevKeys = _currKeys;
|
||||
_currKeys = Keyboard.GetState();
|
||||
_prevMouse = _currMouse;
|
||||
_currMouse = Mouse.GetState();
|
||||
}
|
||||
|
||||
// ── Keyboard ──────────────────────────────────────────────────────────────
|
||||
public bool IsDown(Keys key) => _currKeys.IsKeyDown(key);
|
||||
public bool JustPressed(Keys key) => _currKeys.IsKeyDown(key) && _prevKeys.IsKeyUp(key);
|
||||
public bool JustReleased(Keys key) => _currKeys.IsKeyUp(key) && _prevKeys.IsKeyDown(key);
|
||||
|
||||
// ── Mouse ─────────────────────────────────────────────────────────────────
|
||||
public Vector2 MousePosition => new(_currMouse.X, _currMouse.Y);
|
||||
public bool LeftDown => _currMouse.LeftButton == ButtonState.Pressed;
|
||||
public bool LeftJustDown => _currMouse.LeftButton == ButtonState.Pressed && _prevMouse.LeftButton == ButtonState.Released;
|
||||
public bool LeftJustUp => _currMouse.LeftButton == ButtonState.Released && _prevMouse.LeftButton == ButtonState.Pressed;
|
||||
public bool RightDown => _currMouse.RightButton == ButtonState.Pressed;
|
||||
public bool RightJustDown => _currMouse.RightButton == ButtonState.Pressed && _prevMouse.RightButton == ButtonState.Released;
|
||||
|
||||
/// <summary>Mouse wheel delta in scroll "ticks" (positive = forward/up).</summary>
|
||||
public int ScrollDelta => _currMouse.ScrollWheelValue - _prevMouse.ScrollWheelValue;
|
||||
|
||||
private Vector2 _dragStart;
|
||||
private bool _dragging;
|
||||
private bool _dragActivated;
|
||||
private const float DragActivationPixels = 4f;
|
||||
public bool IsDragging => _dragging;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the world-space pan delta from mouse dragging. Panning is
|
||||
/// suppressed until the mouse moves more than <see cref="DragActivationPixels"/>
|
||||
/// from the press position, so hand-jitter during a click doesn't pan the
|
||||
/// camera (at low zoom, one screen pixel can be many world pixels).
|
||||
/// </summary>
|
||||
public Vector2 ConsumeDragDelta(Rendering.Camera2D camera)
|
||||
{
|
||||
if (LeftJustDown)
|
||||
{
|
||||
_dragStart = MousePosition;
|
||||
_dragging = true;
|
||||
_dragActivated = false;
|
||||
}
|
||||
if (LeftJustUp)
|
||||
{
|
||||
_dragging = false;
|
||||
_dragActivated = false;
|
||||
}
|
||||
|
||||
if (!_dragging || !LeftDown) return Vector2.Zero;
|
||||
|
||||
if (!_dragActivated)
|
||||
{
|
||||
if (Vector2.Distance(MousePosition, _dragStart) < DragActivationPixels)
|
||||
return Vector2.Zero;
|
||||
_dragActivated = true;
|
||||
_dragStart = MousePosition; // start panning from here, not from press
|
||||
}
|
||||
|
||||
Vector2 delta = MousePosition - _dragStart;
|
||||
_dragStart = MousePosition;
|
||||
return -delta / camera.Zoom;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Time;
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Game.Rendering;
|
||||
|
||||
namespace Theriapolis.Game.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the player. World-map mode: click a destination, A* the path,
|
||||
/// and animate the player along it while the WorldClock advances. Tactical
|
||||
/// step input is added in M3 once the chunk streamer is in place — until then,
|
||||
/// tactical mode is a passive observer (zoom and look around).
|
||||
/// </summary>
|
||||
public sealed class PlayerController
|
||||
{
|
||||
private readonly PlayerActor _player;
|
||||
private readonly WorldState _world;
|
||||
private readonly WorldClock _clock;
|
||||
private readonly WorldTravelPlanner _planner;
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback installed by PlayScreen once tactical streaming is up
|
||||
/// (M3+). Returns whether the given tactical-tile coord is walkable.
|
||||
/// </summary>
|
||||
public Func<int, int, bool>? TacticalIsWalkable { get; set; }
|
||||
|
||||
private List<(int X, int Y)>? _path; // tile waypoints
|
||||
private int _pathIndex; // index of the next waypoint
|
||||
|
||||
// Sub-second carry for the world clock — tactical motion is continuous,
|
||||
// so a single frame may advance fewer than one in-game second; without
|
||||
// this carry, slow movement would never tick the clock past 0.
|
||||
private float _tacticalClockCarry;
|
||||
|
||||
public bool IsTraveling => _path is not null && _pathIndex < _path.Count;
|
||||
|
||||
public PlayerController(PlayerActor player, WorldState world, WorldClock clock)
|
||||
{
|
||||
_player = player;
|
||||
_world = world;
|
||||
_clock = clock;
|
||||
_planner = new WorldTravelPlanner(world);
|
||||
}
|
||||
|
||||
public void CancelTravel()
|
||||
{
|
||||
_path = null;
|
||||
_pathIndex = 0;
|
||||
}
|
||||
|
||||
/// <summary>Queue a click destination as a new travel plan. Returns true if a path was found.</summary>
|
||||
public bool RequestTravelTo(int tileX, int tileY)
|
||||
{
|
||||
int sx = (int)MathF.Floor(_player.Position.X / C.WORLD_TILE_PIXELS);
|
||||
int sy = (int)MathF.Floor(_player.Position.Y / C.WORLD_TILE_PIXELS);
|
||||
sx = Math.Clamp(sx, 0, C.WORLD_WIDTH_TILES - 1);
|
||||
sy = Math.Clamp(sy, 0, C.WORLD_HEIGHT_TILES - 1);
|
||||
|
||||
var path = _planner.PlanTilePath(sx, sy, tileX, tileY);
|
||||
if (path is null || path.Count < 2) return false;
|
||||
_path = path;
|
||||
_pathIndex = 1; // we're already at the start tile
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Update(GameTime gt, InputManager input, Camera2D camera, bool isWindowFocused)
|
||||
{
|
||||
float dt = (float)gt.ElapsedGameTime.TotalSeconds;
|
||||
if (camera.Mode == ViewMode.WorldMap)
|
||||
UpdateWorldMap(dt);
|
||||
else
|
||||
UpdateTactical(dt, input, isWindowFocused);
|
||||
}
|
||||
|
||||
private void UpdateWorldMap(float dt)
|
||||
{
|
||||
if (_path is null) return;
|
||||
if (_pathIndex >= _path.Count) { _path = null; return; }
|
||||
|
||||
var (tx, ty) = _path[_pathIndex];
|
||||
var target = WorldTravelPlanner.TileCenterToWorldPixel(tx, ty);
|
||||
var curPos = _player.Position;
|
||||
var diff = target - curPos;
|
||||
float dist = diff.Length;
|
||||
float move = _player.SpeedWorldPxPerSec * dt;
|
||||
|
||||
if (move >= dist)
|
||||
{
|
||||
int prevTileX = (int)MathF.Floor(curPos.X / C.WORLD_TILE_PIXELS);
|
||||
int prevTileY = (int)MathF.Floor(curPos.Y / C.WORLD_TILE_PIXELS);
|
||||
_player.Position = target;
|
||||
if (dist > 1e-3f) _player.FacingAngleRad = MathF.Atan2(diff.Y, diff.X);
|
||||
float legSeconds = _planner.EstimateSecondsForLeg(prevTileX, prevTileY, tx, ty);
|
||||
_clock.Advance((long)MathF.Round(legSeconds));
|
||||
_pathIndex++;
|
||||
if (_pathIndex >= _path.Count) _path = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var step = diff.Normalized * move;
|
||||
_player.Position = curPos + step;
|
||||
_player.FacingAngleRad = MathF.Atan2(diff.Y, diff.X);
|
||||
ref var dst = ref _world.TileAt(tx, ty);
|
||||
float secondsThisFrame = move * _planner.SecondsPerPixel(dst);
|
||||
_clock.Advance((long)MathF.Round(secondsThisFrame));
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTactical(float dt, InputManager input, bool isWindowFocused)
|
||||
{
|
||||
// M3 will install TacticalIsWalkable; until then there's nothing to do.
|
||||
if (!isWindowFocused || TacticalIsWalkable is null) return;
|
||||
|
||||
int dx = 0, dy = 0;
|
||||
if (input.IsDown(Keys.W) || input.IsDown(Keys.Up)) dy = -1;
|
||||
if (input.IsDown(Keys.S) || input.IsDown(Keys.Down)) dy = +1;
|
||||
if (input.IsDown(Keys.A) || input.IsDown(Keys.Left)) dx = -1;
|
||||
if (input.IsDown(Keys.D) || input.IsDown(Keys.Right)) dx = +1;
|
||||
if (dx == 0 && dy == 0) return;
|
||||
|
||||
// Normalize so diagonal isn't √2 faster than cardinal.
|
||||
float invLen = (dx != 0 && dy != 0) ? 0.70710678f : 1f;
|
||||
float vx = dx * invLen;
|
||||
float vy = dy * invLen;
|
||||
|
||||
// Phase 5 M3: apply encumbrance multiplier when a Character is attached.
|
||||
// Carrying ≤ 100% of capacity walks at full speed; >100% is heavy
|
||||
// (×0.66); >150% is over-encumbered (×0.50).
|
||||
float encMult = _player.Character is not null
|
||||
? Theriapolis.Core.Rules.Stats.DerivedStats.TacticalSpeedMult(_player.Character)
|
||||
: 1f;
|
||||
float speed = C.TACTICAL_PLAYER_PX_PER_SEC * encMult;
|
||||
float moveX = vx * speed * dt;
|
||||
float moveY = vy * speed * dt;
|
||||
|
||||
var pos = _player.Position;
|
||||
|
||||
// Axis-separated motion gives wall-sliding for free: if X is blocked,
|
||||
// Y still moves, and vice versa. Each axis tests the destination tile
|
||||
// (with a small body radius so the player doesn't visibly clip walls).
|
||||
const float BodyRadius = 0.35f; // tactical tiles
|
||||
float newX = pos.X + moveX;
|
||||
if (CanOccupy(newX, pos.Y, BodyRadius)) pos = new Vec2(newX, pos.Y);
|
||||
float newY = pos.Y + moveY;
|
||||
if (CanOccupy(pos.X, newY, BodyRadius)) pos = new Vec2(pos.X, newY);
|
||||
|
||||
_player.Position = pos;
|
||||
_player.FacingAngleRad = MathF.Atan2(vy, vx);
|
||||
|
||||
// Clock: 1 tactical pixel walked = TACTICAL_STEP_SECONDS in-game seconds.
|
||||
// Sub-second motion accumulates in _tacticalClockCarry so slow walking
|
||||
// still ticks the clock cumulatively.
|
||||
float walked = MathF.Sqrt(moveX * moveX + moveY * moveY);
|
||||
float secondsThisFrame = walked * C.TACTICAL_STEP_SECONDS + _tacticalClockCarry;
|
||||
long whole = (long)MathF.Floor(secondsThisFrame);
|
||||
_tacticalClockCarry = secondsThisFrame - whole;
|
||||
if (whole > 0) _clock.Advance(whole);
|
||||
}
|
||||
|
||||
private bool CanOccupy(float x, float y, float r)
|
||||
{
|
||||
// Sample the four corners of the player's body AABB so we don't slip
|
||||
// into walls when sliding past corners.
|
||||
return TacticalIsWalkable!((int)MathF.Floor(x - r), (int)MathF.Floor(y - r))
|
||||
&& TacticalIsWalkable!((int)MathF.Floor(x + r), (int)MathF.Floor(y - r))
|
||||
&& TacticalIsWalkable!((int)MathF.Floor(x - r), (int)MathF.Floor(y + r))
|
||||
&& TacticalIsWalkable!((int)MathF.Floor(x + r), (int)MathF.Floor(y + r));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Theriapolis.Game.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Cross-platform clipboard writer via SDL2, which MonoGame.Framework.DesktopGL
|
||||
/// already loads. Silently no-ops if the native call fails so debug-only callers
|
||||
/// never crash the game.
|
||||
/// </summary>
|
||||
public static class Clipboard
|
||||
{
|
||||
[DllImport("SDL2", EntryPoint = "SDL_SetClipboardText", CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern int SDL_SetClipboardText([MarshalAs(UnmanagedType.LPUTF8Str)] string text);
|
||||
|
||||
public static bool TrySetText(string text)
|
||||
{
|
||||
try { return SDL_SetClipboardText(text) == 0; }
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Theriapolis.Game.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// OS-aware save directory resolution. Per the implementation plan §4.2,
|
||||
/// saves live under the platform-appropriate user data directory.
|
||||
/// </summary>
|
||||
public static class SavePaths
|
||||
{
|
||||
/// <summary>Top-level Theriapolis save directory. Created on first call if missing.</summary>
|
||||
public static string SavesDir
|
||||
{
|
||||
get
|
||||
{
|
||||
string dir = ResolveBase();
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
public static string SlotPath(int slot) => Path.Combine(SavesDir, $"slot_{slot:D2}.trps");
|
||||
public static string AutosavePath() => Path.Combine(SavesDir, "autosave.trps");
|
||||
|
||||
private static string ResolveBase()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Theriapolis", "Saves");
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Library", "Application Support", "Theriapolis", "Saves");
|
||||
// Linux + others: respect XDG_DATA_HOME, fall back to ~/.local/share.
|
||||
string xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME") ?? "";
|
||||
if (string.IsNullOrEmpty(xdg))
|
||||
xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
".local", "share");
|
||||
return Path.Combine(xdg, "Theriapolis", "saves");
|
||||
}
|
||||
|
||||
/// <summary>Atomic-rename file write so a crash mid-save can't corrupt the slot.</summary>
|
||||
public static void WriteAtomic(string path, byte[] bytes)
|
||||
{
|
||||
string dir = Path.GetDirectoryName(path)!;
|
||||
Directory.CreateDirectory(dir);
|
||||
string tmp = path + ".tmp";
|
||||
File.WriteAllBytes(tmp, bytes);
|
||||
if (File.Exists(path)) File.Replace(tmp, path, destinationBackupFileName: null);
|
||||
else File.Move(tmp, path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
|
||||
namespace Theriapolis.Game.Rendering;
|
||||
|
||||
public enum ViewMode { WorldMap, Tactical }
|
||||
|
||||
/// <summary>
|
||||
/// 2D orthographic camera. Position is in world-pixel space.
|
||||
/// Both WorldMap and Tactical views share the same camera; only the renderer changes.
|
||||
/// </summary>
|
||||
public sealed class Camera2D
|
||||
{
|
||||
private readonly GraphicsDeviceWrapper _gd;
|
||||
|
||||
/// <summary>Camera position in world-pixel space (top-left of view at zoom 1).</summary>
|
||||
public Vector2 Position { get; set; } = Vector2.Zero;
|
||||
|
||||
/// <summary>Zoom level. 1.0 = 1 world pixel per screen pixel.</summary>
|
||||
public float Zoom { get; private set; } = 1f / Theriapolis.Core.C.WORLD_TILE_PIXELS;
|
||||
|
||||
public ViewMode Mode { get; set; } = ViewMode.WorldMap;
|
||||
|
||||
// Re-exports of the canonical zoom constants in C.* so existing call sites
|
||||
// (Camera2D.MinZoom, etc.) keep working without churn.
|
||||
public const float MinZoom = Theriapolis.Core.C.CAMERA_MIN_ZOOM;
|
||||
public const float MaxZoom = Theriapolis.Core.C.CAMERA_MAX_ZOOM;
|
||||
public const float TacticalThreshold = Theriapolis.Core.C.CAMERA_TACTICAL_THRESHOLD;
|
||||
|
||||
public Camera2D(GraphicsDeviceWrapper gd)
|
||||
{
|
||||
_gd = gd;
|
||||
}
|
||||
|
||||
public int ScreenWidth => _gd.Width;
|
||||
public int ScreenHeight => _gd.Height;
|
||||
|
||||
/// <summary>SpriteBatch transform matrix for this camera.</summary>
|
||||
public Matrix TransformMatrix =>
|
||||
Matrix.CreateTranslation(-Position.X, -Position.Y, 0f)
|
||||
* Matrix.CreateScale(Zoom, Zoom, 1f)
|
||||
* Matrix.CreateTranslation(ScreenWidth * 0.5f, ScreenHeight * 0.5f, 0f);
|
||||
|
||||
public Vector2 WorldToScreen(Vector2 world)
|
||||
{
|
||||
var v = Vector2.Transform(world, TransformMatrix);
|
||||
return v;
|
||||
}
|
||||
|
||||
public Vector2 ScreenToWorld(Vector2 screen)
|
||||
{
|
||||
var inv = Matrix.Invert(TransformMatrix);
|
||||
return Vector2.Transform(screen, inv);
|
||||
}
|
||||
|
||||
public void AdjustZoom(float delta, Vector2 screenFocus)
|
||||
{
|
||||
// Keep the world point under screenFocus stationary
|
||||
var worldFocus = ScreenToWorld(screenFocus);
|
||||
Zoom = Math.Clamp(Zoom * (1f + delta), MinZoom, MaxZoom);
|
||||
var newScreen = WorldToScreen(worldFocus);
|
||||
Position += (screenFocus - newScreen) / Zoom;
|
||||
|
||||
// Update view mode based on zoom threshold
|
||||
Mode = Zoom >= TacticalThreshold ? ViewMode.Tactical : ViewMode.WorldMap;
|
||||
}
|
||||
|
||||
public void Pan(Vector2 worldDelta)
|
||||
{
|
||||
Position += worldDelta;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the visible rectangle in world-tile coordinates.
|
||||
/// </summary>
|
||||
public (int x0, int y0, int x1, int y1) VisibleTileRect()
|
||||
{
|
||||
var tl = ScreenToWorld(Vector2.Zero);
|
||||
var br = ScreenToWorld(new Vector2(ScreenWidth, ScreenHeight));
|
||||
int px = Theriapolis.Core.C.WORLD_TILE_PIXELS;
|
||||
int x0 = Math.Max(0, (int)MathF.Floor(tl.X / px));
|
||||
int y0 = Math.Max(0, (int)MathF.Floor(tl.Y / px));
|
||||
int x1 = Math.Min(Theriapolis.Core.C.WORLD_WIDTH_TILES - 1, (int)MathF.Ceiling(br.X / px));
|
||||
int y1 = Math.Min(Theriapolis.Core.C.WORLD_HEIGHT_TILES - 1, (int)MathF.Ceiling(br.Y / px));
|
||||
return (x0, y0, x1, y1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thin wrapper so Camera2D doesn't reference the MonoGame GraphicsDevice directly.</summary>
|
||||
public sealed class GraphicsDeviceWrapper
|
||||
{
|
||||
private readonly Microsoft.Xna.Framework.Graphics.GraphicsDevice _device;
|
||||
public int Width => _device.Viewport.Width;
|
||||
public int Height => _device.Viewport.Height;
|
||||
public GraphicsDeviceWrapper(Microsoft.Xna.Framework.Graphics.GraphicsDevice device) => _device = device;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace Theriapolis.Game.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Common interface for world-map and tactical renderers.
|
||||
/// Both share the same Camera2D and render the same polyline data.
|
||||
/// </summary>
|
||||
public interface IMapView
|
||||
{
|
||||
void Draw(SpriteBatch spriteBatch, Camera2D camera, GameTime gameTime);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
using Theriapolis.Core.World.Polylines;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Game.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Renders rivers, roads, and rail lines as thick world-space polylines.
|
||||
/// Uses simplified LOD geometry at low zoom levels.
|
||||
/// </summary>
|
||||
public sealed class LineFeatureRenderer : IDisposable
|
||||
{
|
||||
private readonly GraphicsDevice _gd;
|
||||
private readonly WorldGenContext _ctx;
|
||||
private Texture2D _pixel = null!;
|
||||
private bool _disposed;
|
||||
|
||||
// Zoom threshold below which SimplifiedPoints are used
|
||||
private const float LodSwitchZoom = 0.15f;
|
||||
|
||||
// Line widths in world pixels at full zoom (scaled by camera zoom)
|
||||
private const float RiverMajorWidth = 6f;
|
||||
private const float RiverWidth = 3.5f;
|
||||
private const float StreamWidth = 1.5f;
|
||||
private const float RailWidth = 3f;
|
||||
private const float HighwayWidth = 4f;
|
||||
private const float PostRoadWidth = 2.5f;
|
||||
private const float DirtRoadWidth = 1.5f;
|
||||
|
||||
// Line colors
|
||||
private static readonly Color RiverColor = new(60, 120, 200);
|
||||
private static readonly Color RailColor = new(120, 100, 80);
|
||||
private static readonly Color RailTieColor = new(80, 70, 60);
|
||||
private static readonly Color HighwayColor = new(210, 180, 80);
|
||||
private static readonly Color PostRoadColor = new(180, 155, 70);
|
||||
private static readonly Color DirtRoadColor = new(150, 130, 90);
|
||||
private static readonly Color BridgeDeckColor = new(160, 140, 100);
|
||||
private static readonly Color BridgeRailColor = new(100, 85, 60);
|
||||
private const float BridgeDeckWidth = 6f; // wider than HighwayWidth so the deck fully covers the road underneath
|
||||
|
||||
public LineFeatureRenderer(GraphicsDevice gd, WorldGenContext ctx)
|
||||
{
|
||||
_gd = gd;
|
||||
_ctx = ctx;
|
||||
BuildPixel();
|
||||
}
|
||||
|
||||
private void BuildPixel()
|
||||
{
|
||||
_pixel = new Texture2D(_gd, 1, 1);
|
||||
_pixel.SetData(new[] { Color.White });
|
||||
}
|
||||
|
||||
public void Draw(SpriteBatch sb, Camera2D camera)
|
||||
{
|
||||
bool useLod = camera.Zoom < LodSwitchZoom;
|
||||
|
||||
sb.Begin(
|
||||
transformMatrix: camera.TransformMatrix,
|
||||
samplerState: SamplerState.PointClamp,
|
||||
sortMode: SpriteSortMode.Deferred,
|
||||
blendState: BlendState.AlphaBlend);
|
||||
|
||||
// Draw order: roads → rivers → bridges → rail (rail on top)
|
||||
DrawRoads(sb, useLod, camera.Zoom);
|
||||
DrawRivers(sb, useLod, camera.Zoom);
|
||||
DrawBridges(sb, camera.Zoom);
|
||||
DrawRail(sb, useLod, camera.Zoom);
|
||||
|
||||
sb.End();
|
||||
}
|
||||
|
||||
private void DrawRoads(SpriteBatch sb, bool useLod, float zoom)
|
||||
{
|
||||
// Smallest first, biggest last — so when a smaller road has been merged
|
||||
// onto a larger road by PolylineCleanupStage, the larger road's wider
|
||||
// stroke covers the overdraw and the junction looks clean.
|
||||
foreach (var road in _ctx.World.Roads.OrderBy(RoadDrawRank))
|
||||
{
|
||||
var (color, width) = road.RoadClassification switch
|
||||
{
|
||||
RoadType.Highway => (HighwayColor, HighwayWidth),
|
||||
RoadType.PostRoad => (PostRoadColor, PostRoadWidth),
|
||||
_ => (DirtRoadColor, DirtRoadWidth),
|
||||
};
|
||||
DrawPolyline(sb, road, color, width, useLod);
|
||||
}
|
||||
}
|
||||
|
||||
private static int RoadDrawRank(Polyline r) => r.RoadClassification switch
|
||||
{
|
||||
RoadType.Footpath => 0,
|
||||
RoadType.DirtRoad => 1,
|
||||
RoadType.PostRoad => 2,
|
||||
RoadType.Highway => 3,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
private void DrawRivers(SpriteBatch sb, bool useLod, float zoom)
|
||||
{
|
||||
foreach (var river in _ctx.World.Rivers)
|
||||
{
|
||||
var (color, width) = river.RiverClassification switch
|
||||
{
|
||||
RiverClass.MajorRiver => (RiverColor, RiverMajorWidth),
|
||||
RiverClass.River => (RiverColor, RiverWidth),
|
||||
_ => (RiverColor, StreamWidth),
|
||||
};
|
||||
// Scale river width slightly by flow for a natural look
|
||||
float flowScale = 1f + (river.FlowAccumulation / (float)C.RIVER_MAJOR_THRESHOLD) * 0.3f;
|
||||
DrawPolyline(sb, river, color, Math.Min(width * flowScale, RiverMajorWidth * 1.5f), useLod);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawRail(SpriteBatch sb, bool useLod, float zoom)
|
||||
{
|
||||
foreach (var rail in _ctx.World.Rails)
|
||||
{
|
||||
// Draw tie marks underneath, then the rail line on top
|
||||
DrawPolyline(sb, rail, RailTieColor, RailWidth + 2f, useLod);
|
||||
DrawPolyline(sb, rail, RailColor, RailWidth * 0.5f, useLod);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawBridges(SpriteBatch sb, float zoom)
|
||||
{
|
||||
foreach (var bridge in _ctx.World.Bridges)
|
||||
{
|
||||
Vector2 start = new(bridge.Start.X, bridge.Start.Y);
|
||||
Vector2 end = new(bridge.End.X, bridge.End.Y);
|
||||
Vector2 span = end - start;
|
||||
float len = span.Length();
|
||||
if (len < 0.5f) continue;
|
||||
|
||||
Vector2 roadDir = span / len;
|
||||
Vector2 perpDir = new(-roadDir.Y, roadDir.X);
|
||||
|
||||
// Bridge deck: follows the actual road polyline at the crossing.
|
||||
DrawSegment(sb, start, end, BridgeDeckColor, BridgeDeckWidth);
|
||||
|
||||
// Bridge abutments: short perpendicular bars at each end.
|
||||
float halfBar = BridgeDeckWidth * 1.5f;
|
||||
DrawSegment(sb, start - perpDir * halfBar, start + perpDir * halfBar, BridgeRailColor, 1f);
|
||||
DrawSegment(sb, end - perpDir * halfBar, end + perpDir * halfBar, BridgeRailColor, 1f);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPolyline(SpriteBatch sb, Polyline polyline, Color color, float worldWidth, bool useLod)
|
||||
{
|
||||
var pts = (useLod && polyline.SimplifiedPoints is { Count: >= 2 })
|
||||
? polyline.SimplifiedPoints
|
||||
: polyline.Points;
|
||||
|
||||
if (pts.Count < 2) return;
|
||||
|
||||
for (int i = 0; i < pts.Count - 1; i++)
|
||||
{
|
||||
DrawSegment(sb,
|
||||
new Vector2(pts[i].X, pts[i].Y),
|
||||
new Vector2(pts[i + 1].X, pts[i + 1].Y),
|
||||
color, worldWidth);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSegment(SpriteBatch sb, Vector2 from, Vector2 to, Color color, float width)
|
||||
{
|
||||
Vector2 diff = to - from;
|
||||
float len = diff.Length();
|
||||
if (len < 0.5f) return;
|
||||
|
||||
// Extend segment by half-width at both ends so consecutive segments
|
||||
// overlap at joints, filling the triangular gap that appears when
|
||||
// two thick rotated rectangles meet at an angle.
|
||||
float extend = width * 0.5f;
|
||||
Vector2 dir = diff / len;
|
||||
Vector2 start = from - dir * extend;
|
||||
float extLen = len + 2f * extend;
|
||||
float angle = MathF.Atan2(diff.Y, diff.X);
|
||||
|
||||
sb.Draw(
|
||||
texture: _pixel,
|
||||
position: start,
|
||||
sourceRectangle: null,
|
||||
color: color,
|
||||
rotation: angle,
|
||||
origin: new Vector2(0f, 0.5f),
|
||||
scale: new Vector2(extLen, width),
|
||||
effects: SpriteEffects.None,
|
||||
layerDepth: 0f);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_pixel?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
|
||||
namespace Theriapolis.Game.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M4 — renders <see cref="NpcActor"/>s on the tactical map (and
|
||||
/// as muted dots on the world map at low zoom). Mirrors
|
||||
/// <see cref="PlayerSprite"/>'s counter-scale-by-1/Zoom approach so NPCs
|
||||
/// stay constant-on-screen-size at any zoom level.
|
||||
///
|
||||
/// Body colour encodes allegiance:
|
||||
/// Hostile → red
|
||||
/// Neutral → grey
|
||||
/// Friendly → green
|
||||
/// Allied → cyan
|
||||
/// Player allegiance never shows here (only the player sprite renders).
|
||||
///
|
||||
/// A coloured outer ring is drawn beneath each body so NPCs remain
|
||||
/// visible against similar-toned terrain (settlement cobble, rock,
|
||||
/// snow). Generic placeholder art — Phase 9 polish swaps for real
|
||||
/// per-species sprites.
|
||||
/// </summary>
|
||||
public sealed class NpcSprite : IDisposable
|
||||
{
|
||||
private readonly GraphicsDevice _gd;
|
||||
private Texture2D _hostile = null!;
|
||||
private Texture2D _neutral = null!;
|
||||
private Texture2D _friendly = null!;
|
||||
private Texture2D _allied = null!;
|
||||
private Texture2D _ring = null!;
|
||||
private bool _disposed;
|
||||
|
||||
private const int MarkerPx = C.PLAYER_MARKER_SCREEN_PX;
|
||||
|
||||
public NpcSprite(GraphicsDevice gd)
|
||||
{
|
||||
_gd = gd;
|
||||
BuildTextures();
|
||||
}
|
||||
|
||||
private void BuildTextures()
|
||||
{
|
||||
_hostile = BuildBody(new Color(220, 60, 50));
|
||||
_neutral = BuildBody(new Color(180, 180, 180));
|
||||
_friendly = BuildBody(new Color( 90, 180, 90));
|
||||
_allied = BuildBody(new Color( 90, 200, 220));
|
||||
_ring = BuildRing();
|
||||
}
|
||||
|
||||
private Texture2D BuildBody(Color body)
|
||||
{
|
||||
int s = MarkerPx;
|
||||
var pixels = new Color[s * s];
|
||||
float cx = (s - 1) * 0.5f, cy = (s - 1) * 0.5f, r = s * 0.5f - 1f;
|
||||
// Slightly smaller body than player (0.78 vs 0.85) so player reads as
|
||||
// the protagonist when next to NPCs.
|
||||
for (int y = 0; y < s; y++)
|
||||
for (int x = 0; x < s; x++)
|
||||
{
|
||||
float nx = x - cx, ny = y - cy;
|
||||
float dist = MathF.Sqrt(nx * nx + ny * ny);
|
||||
pixels[y * s + x] = dist <= r * 0.78f ? body : Color.Transparent;
|
||||
}
|
||||
var tex = new Texture2D(_gd, s, s);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private Texture2D BuildRing()
|
||||
{
|
||||
int s = MarkerPx;
|
||||
var pixels = new Color[s * s];
|
||||
float cx = (s - 1) * 0.5f, cy = (s - 1) * 0.5f, r = s * 0.5f - 1f;
|
||||
for (int y = 0; y < s; y++)
|
||||
for (int x = 0; x < s; x++)
|
||||
{
|
||||
float nx = x - cx, ny = y - cy;
|
||||
float dist = MathF.Sqrt(nx * nx + ny * ny);
|
||||
pixels[y * s + x] = (dist > r * 0.78f && dist <= r) ? new Color(0, 0, 0, 200) : Color.Transparent;
|
||||
}
|
||||
var tex = new Texture2D(_gd, s, s);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
/// <summary>Draw every live NPC. Caller wraps SpriteBatch.Begin/End around it.</summary>
|
||||
public void Draw(SpriteBatch sb, Camera2D camera, IEnumerable<NpcActor> npcs)
|
||||
{
|
||||
sb.Begin(
|
||||
transformMatrix: camera.TransformMatrix,
|
||||
samplerState: SamplerState.PointClamp,
|
||||
sortMode: SpriteSortMode.Deferred,
|
||||
blendState: BlendState.AlphaBlend);
|
||||
|
||||
int s = MarkerPx;
|
||||
var origin = new Vector2(s * 0.5f, s * 0.5f);
|
||||
float screenScale = camera.Zoom > 1e-6f ? 1f / camera.Zoom : 1f;
|
||||
|
||||
foreach (var npc in npcs)
|
||||
{
|
||||
if (!npc.IsAlive) continue;
|
||||
var pos = new Vector2(npc.Position.X, npc.Position.Y);
|
||||
var body = TextureFor(npc.Allegiance);
|
||||
|
||||
sb.Draw(_ring, pos, null, Color.White, 0f, origin, screenScale, SpriteEffects.None, 0f);
|
||||
sb.Draw(body, pos, null, Color.White, 0f, origin, screenScale, SpriteEffects.None, 0f);
|
||||
}
|
||||
|
||||
sb.End();
|
||||
}
|
||||
|
||||
private Texture2D TextureFor(Allegiance a) => a switch
|
||||
{
|
||||
Allegiance.Hostile => _hostile,
|
||||
Allegiance.Allied => _allied,
|
||||
Allegiance.Friendly => _friendly,
|
||||
Allegiance.Neutral => _neutral,
|
||||
_ => _neutral,
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_hostile?.Dispose();
|
||||
_neutral?.Dispose();
|
||||
_friendly?.Dispose();
|
||||
_allied?.Dispose();
|
||||
_ring?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Entities;
|
||||
|
||||
namespace Theriapolis.Game.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Renders the player actor in either view. Both world-map and tactical use
|
||||
/// the same camera (world-pixel space), but the sprite is counter-scaled by
|
||||
/// 1/camera.Zoom so it stays a constant on-screen size at every zoom level.
|
||||
/// Otherwise the marker would become tiny when zoomed out and screen-filling
|
||||
/// at CAMERA_MAX_ZOOM.
|
||||
/// </summary>
|
||||
public sealed class PlayerSprite : IDisposable
|
||||
{
|
||||
private readonly GraphicsDevice _gd;
|
||||
private Texture2D _arrow = null!;
|
||||
private Texture2D _ring = null!;
|
||||
private bool _disposed;
|
||||
|
||||
// Texture size = target on-screen size. Convenient because the
|
||||
// counter-scale formula reduces to (1 / camera.Zoom) at draw time.
|
||||
private const int MarkerPx = C.PLAYER_MARKER_SCREEN_PX;
|
||||
|
||||
public PlayerSprite(GraphicsDevice gd)
|
||||
{
|
||||
_gd = gd;
|
||||
BuildTextures();
|
||||
}
|
||||
|
||||
private void BuildTextures()
|
||||
{
|
||||
// Filled arrow — orientation comes from sprite rotation at draw time.
|
||||
int s = MarkerPx;
|
||||
var arrow = new Color[s * s];
|
||||
var ring = new Color[s * s];
|
||||
float cx = (s - 1) * 0.5f, cy = (s - 1) * 0.5f, r = s * 0.5f - 1f;
|
||||
for (int y = 0; y < s; y++)
|
||||
for (int x = 0; x < s; x++)
|
||||
{
|
||||
float nx = x - cx, ny = y - cy;
|
||||
float dist = MathF.Sqrt(nx * nx + ny * ny);
|
||||
// Filled disc (body) plus a small notch on the +X side to indicate facing.
|
||||
bool body = dist <= r * 0.85f;
|
||||
bool notch = nx > 0 && MathF.Abs(ny) < (r - nx) * 0.6f;
|
||||
arrow[y * s + x] = body
|
||||
? (notch ? new Color(255, 230, 180) : new Color(220, 80, 60))
|
||||
: Color.Transparent;
|
||||
// Outer ring drawn beneath the body so it remains visible against
|
||||
// similarly-coloured terrain at low zoom (settlement icons sit
|
||||
// close to the marker on the world map).
|
||||
ring[y * s + x] = (dist > r * 0.85f && dist <= r) ? new Color(0, 0, 0, 200) : Color.Transparent;
|
||||
}
|
||||
_arrow = new Texture2D(_gd, s, s); _arrow.SetData(arrow);
|
||||
_ring = new Texture2D(_gd, s, s); _ring.SetData(ring);
|
||||
}
|
||||
|
||||
public void Draw(SpriteBatch sb, Camera2D camera, Actor a)
|
||||
{
|
||||
sb.Begin(
|
||||
transformMatrix: camera.TransformMatrix,
|
||||
samplerState: SamplerState.PointClamp,
|
||||
sortMode: SpriteSortMode.Deferred,
|
||||
blendState: BlendState.AlphaBlend);
|
||||
|
||||
int s = MarkerPx;
|
||||
var origin = new Vector2(s * 0.5f, s * 0.5f);
|
||||
var pos = new Vector2(a.Position.X, a.Position.Y);
|
||||
|
||||
// Counter-scale by 1/Zoom so the camera transform's Zoom multiplier
|
||||
// cancels out, leaving a constant MarkerPx-pixel on-screen size.
|
||||
// Guard against zero just in case (shouldn't happen — clamped by C.CAMERA_MIN_ZOOM).
|
||||
float screenScale = camera.Zoom > 1e-6f ? 1f / camera.Zoom : 1f;
|
||||
|
||||
sb.Draw(_ring, pos, null, Color.White, 0f, origin, screenScale, SpriteEffects.None, 0f);
|
||||
sb.Draw(_arrow, pos, null, Color.White, a.FacingAngleRad, origin, screenScale, SpriteEffects.None, 0f);
|
||||
|
||||
sb.End();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_arrow?.Dispose();
|
||||
_ring?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Tactical;
|
||||
|
||||
namespace Theriapolis.Game.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Holds one 32×32 sprite per <see cref="TacticalSurface"/> and
|
||||
/// <see cref="TacticalDeco"/> value, with optional per-variant alternates.
|
||||
///
|
||||
/// On construction, looks for PNGs under <c><gfxRoot>/surface/<name>.png</c>
|
||||
/// and <c><gfxRoot>/deco/<name>.png</c> (lowercase enum names). For
|
||||
/// variants, drop in <c><name>_0.png</c>, <c><name>_1.png</c>, … —
|
||||
/// the chunk's per-tile <c>Variant</c> nibble picks one. Missing files fall
|
||||
/// back to a procedurally generated solid-color placeholder so the renderer
|
||||
/// always has something to draw, even with no art on disk.
|
||||
/// </summary>
|
||||
public sealed class TacticalAtlas : IDisposable
|
||||
{
|
||||
private const int Px = C.TACTICAL_TILE_SPRITE_PX;
|
||||
|
||||
private readonly GraphicsDevice _gd;
|
||||
private readonly Dictionary<TacticalSurface, Texture2D[]> _surfaces = new();
|
||||
private readonly Dictionary<TacticalDeco, Texture2D[]> _decos = new();
|
||||
private readonly Dictionary<TacticalSurface, Color> _surfaceAvg = new();
|
||||
private readonly List<Texture2D> _owned = new();
|
||||
private bool _disposed;
|
||||
|
||||
public TacticalAtlas(GraphicsDevice gd, string? gfxRoot = null)
|
||||
{
|
||||
_gd = gd;
|
||||
LoadAll(gfxRoot);
|
||||
}
|
||||
|
||||
/// <summary>Sprite for the given surface + per-tile variant. Always non-null.</summary>
|
||||
public Texture2D GetSurface(TacticalSurface s, byte variant)
|
||||
{
|
||||
if (!_surfaces.TryGetValue(s, out var arr) || arr.Length == 0)
|
||||
arr = _surfaces[TacticalSurface.None];
|
||||
return arr[variant % arr.Length];
|
||||
}
|
||||
|
||||
/// <summary>Sprite for the given deco + variant, or null for <see cref="TacticalDeco.None"/>.</summary>
|
||||
public Texture2D? GetDeco(TacticalDeco d, byte variant)
|
||||
{
|
||||
if (d == TacticalDeco.None) return null;
|
||||
if (!_decos.TryGetValue(d, out var arr) || arr.Length == 0) return null;
|
||||
return arr[variant % arr.Length];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Average opaque-pixel RGB of a surface, cached on first request. Used by
|
||||
/// the renderer's edge-blend pass to soften the seam between adjacent
|
||||
/// dissimilar surfaces (Option B autotiling — see <see cref="TacticalRenderer"/>).
|
||||
/// </summary>
|
||||
public Color GetSurfaceAverageColor(TacticalSurface s)
|
||||
{
|
||||
if (_surfaceAvg.TryGetValue(s, out var cached)) return cached;
|
||||
var tex = GetSurface(s, 0);
|
||||
var pixels = new Color[tex.Width * tex.Height];
|
||||
tex.GetData(pixels);
|
||||
long r = 0, g = 0, b = 0, n = 0;
|
||||
foreach (var p in pixels)
|
||||
{
|
||||
if (p.A < 128) continue;
|
||||
r += p.R; g += p.G; b += p.B; n++;
|
||||
}
|
||||
Color avg = n == 0 ? Color.Transparent : new Color((byte)(r / n), (byte)(g / n), (byte)(b / n));
|
||||
_surfaceAvg[s] = avg;
|
||||
return avg;
|
||||
}
|
||||
|
||||
private void LoadAll(string? gfxRoot)
|
||||
{
|
||||
// Always create a magenta sentinel for missing surfaces.
|
||||
_surfaces[TacticalSurface.None] = new[] { MakeSolid(new Color(255, 0, 255)) };
|
||||
|
||||
foreach (TacticalSurface s in Enum.GetValues<TacticalSurface>())
|
||||
{
|
||||
if (s == TacticalSurface.None) continue;
|
||||
_surfaces[s] = LoadVariants(gfxRoot, "surface", s.ToString().ToLowerInvariant(),
|
||||
() => MakeSurfacePlaceholder(s));
|
||||
}
|
||||
foreach (TacticalDeco d in Enum.GetValues<TacticalDeco>())
|
||||
{
|
||||
if (d == TacticalDeco.None) continue;
|
||||
_decos[d] = LoadVariants(gfxRoot, "deco", d.ToString().ToLowerInvariant(),
|
||||
() => MakeDecoPlaceholder(d));
|
||||
}
|
||||
}
|
||||
|
||||
private Texture2D[] LoadVariants(string? root, string subdir, string name, Func<Texture2D> placeholder)
|
||||
{
|
||||
var found = new List<Texture2D>();
|
||||
if (root is not null)
|
||||
{
|
||||
string dir = Path.Combine(root, subdir);
|
||||
if (Directory.Exists(dir))
|
||||
{
|
||||
// Variant suffix files: name_0.png, name_1.png, ...
|
||||
for (int i = 0; ; i++)
|
||||
{
|
||||
string p = Path.Combine(dir, $"{name}_{i}.png");
|
||||
if (!File.Exists(p)) break;
|
||||
found.Add(LoadFile(p));
|
||||
}
|
||||
// Fallback to a single name.png if no _N variants exist.
|
||||
if (found.Count == 0)
|
||||
{
|
||||
string p = Path.Combine(dir, $"{name}.png");
|
||||
if (File.Exists(p)) found.Add(LoadFile(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found.Count == 0) found.Add(placeholder());
|
||||
return found.ToArray();
|
||||
}
|
||||
|
||||
private Texture2D LoadFile(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
var tex = Texture2D.FromStream(_gd, stream);
|
||||
StripBorderPixels(tex);
|
||||
_owned.Add(tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Many AI-generated tile sources (Pixellab in particular) bake a uniform
|
||||
/// dark border into each tile — sometimes pure black, sometimes a dark
|
||||
/// purple/grey, and 1–3 pixels deep. Adjacent tiles in the world then
|
||||
/// show as grid-lined rectangles instead of seamless terrain.
|
||||
///
|
||||
/// This pass detects each side's border depth (rows/cols where ≥80% of
|
||||
/// pixels are uniformly dark) and replaces those rows/cols with a copy of
|
||||
/// the first interior row/col, restoring the seamless look without
|
||||
/// touching tile interiors. No-ops on tiles that don't have a detectable
|
||||
/// border, so it's safe to run on any input.
|
||||
/// </summary>
|
||||
private static void StripBorderPixels(Texture2D tex)
|
||||
{
|
||||
const int BorderChannelMax = 80; // every channel ≤ this counts as "dark"
|
||||
const float BorderRowFrac = 0.80f; // fraction of dark pixels for a row to be a border
|
||||
|
||||
int w = tex.Width, h = tex.Height;
|
||||
if (w < 3 || h < 3) return;
|
||||
var pixels = new Color[w * h];
|
||||
tex.GetData(pixels);
|
||||
|
||||
// Only opaque dark pixels count — otherwise transparent perimeter
|
||||
// (the norm for decoration sprites with see-through backgrounds)
|
||||
// would be misread as a border and trigger a damaging strip.
|
||||
bool IsDark(Color p) => p.A >= 128 && p.R <= BorderChannelMax && p.G <= BorderChannelMax && p.B <= BorderChannelMax;
|
||||
bool RowIsBorder(int y)
|
||||
{
|
||||
int dark = 0;
|
||||
for (int x = 0; x < w; x++) if (IsDark(pixels[y * w + x])) dark++;
|
||||
return dark >= w * BorderRowFrac;
|
||||
}
|
||||
bool ColIsBorder(int x)
|
||||
{
|
||||
int dark = 0;
|
||||
for (int y = 0; y < h; y++) if (IsDark(pixels[y * w + x])) dark++;
|
||||
return dark >= h * BorderRowFrac;
|
||||
}
|
||||
|
||||
int top = 0; while (top < h / 2 && RowIsBorder(top)) top++;
|
||||
int bot = h - 1; while (bot > h / 2 && RowIsBorder(bot)) bot--;
|
||||
int lef = 0; while (lef < w / 2 && ColIsBorder(lef)) lef++;
|
||||
int rig = w - 1; while (rig > w / 2 && ColIsBorder(rig)) rig--;
|
||||
|
||||
if (top == 0 && bot == h - 1 && lef == 0 && rig == w - 1) return; // no border
|
||||
|
||||
// Replace top/bottom border rows with the first interior row's pixels.
|
||||
for (int y = 0; y < top; y++)
|
||||
for (int x = 0; x < w; x++) pixels[y * w + x] = pixels[top * w + x];
|
||||
for (int y = bot + 1; y < h; y++)
|
||||
for (int x = 0; x < w; x++) pixels[y * w + x] = pixels[bot * w + x];
|
||||
// Replace left/right columns from each row's first interior pixel
|
||||
// (after the top/bottom rows have been refreshed, so corners inherit
|
||||
// the cleaned-up content).
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
var fillL = pixels[y * w + lef];
|
||||
for (int x = 0; x < lef; x++) pixels[y * w + x] = fillL;
|
||||
var fillR = pixels[y * w + rig];
|
||||
for (int x = rig + 1; x < w; x++) pixels[y * w + x] = fillR;
|
||||
}
|
||||
tex.SetData(pixels);
|
||||
}
|
||||
|
||||
private Texture2D MakeSolid(Color c)
|
||||
{
|
||||
var tex = new Texture2D(_gd, Px, Px);
|
||||
var p = new Color[Px * Px];
|
||||
Array.Fill(p, c);
|
||||
tex.SetData(p);
|
||||
_owned.Add(tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private Texture2D MakeSurfacePlaceholder(TacticalSurface s)
|
||||
{
|
||||
// Solid fill — no border. (Earlier versions drew a 1-px darker edge
|
||||
// as a debug aid, but it baked visible grid lines into adjacent
|
||||
// placeholder tiles in-game.)
|
||||
var c = SurfaceColor(s);
|
||||
var tex = new Texture2D(_gd, Px, Px);
|
||||
var pixels = new Color[Px * Px];
|
||||
Array.Fill(pixels, c);
|
||||
tex.SetData(pixels);
|
||||
_owned.Add(tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private Texture2D MakeDecoPlaceholder(TacticalDeco d)
|
||||
{
|
||||
var (color, fillFraction) = DecoStyle(d);
|
||||
var tex = new Texture2D(_gd, Px, Px);
|
||||
var pixels = new Color[Px * Px];
|
||||
float cx = (Px - 1) * 0.5f;
|
||||
float r = Px * 0.5f * fillFraction;
|
||||
for (int y = 0; y < Px; y++)
|
||||
for (int x = 0; x < Px; x++)
|
||||
{
|
||||
float dx = x - cx, dy = y - cx;
|
||||
pixels[y * Px + x] = (dx * dx + dy * dy) <= r * r ? color : Color.Transparent;
|
||||
}
|
||||
tex.SetData(pixels);
|
||||
_owned.Add(tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private static Color SurfaceColor(TacticalSurface s) => s switch
|
||||
{
|
||||
TacticalSurface.DeepWater => new Color(20, 60, 130),
|
||||
TacticalSurface.ShallowWater => new Color(60, 120, 180),
|
||||
TacticalSurface.Marsh => new Color(70, 100, 80),
|
||||
TacticalSurface.Mud => new Color(100, 80, 60),
|
||||
TacticalSurface.Sand => new Color(220, 200, 150),
|
||||
TacticalSurface.Snow => new Color(230, 235, 240),
|
||||
TacticalSurface.Rock => new Color(120, 115, 110),
|
||||
TacticalSurface.Cobble => new Color(170, 150, 120),
|
||||
TacticalSurface.Gravel => new Color(150, 140, 110),
|
||||
TacticalSurface.Wall => new Color(60, 55, 50),
|
||||
TacticalSurface.Floor => new Color(180, 160, 130),
|
||||
TacticalSurface.Dirt => new Color(120, 95, 60),
|
||||
TacticalSurface.TroddenDirt => new Color(140, 110, 70), // worn / lighter than wild dirt
|
||||
TacticalSurface.TallGrass => new Color(80, 140, 60),
|
||||
TacticalSurface.Grass => new Color(110, 160, 70),
|
||||
_ => new Color(255, 0, 255),
|
||||
};
|
||||
|
||||
private static (Color color, float fillFraction) DecoStyle(TacticalDeco d) => d switch
|
||||
{
|
||||
TacticalDeco.Tree => (new Color(20, 80, 30), 0.85f),
|
||||
TacticalDeco.Bush => (new Color(70, 110, 50), 0.55f),
|
||||
TacticalDeco.Boulder => (new Color(110,100, 90), 0.65f),
|
||||
TacticalDeco.Rock => (new Color(140,130,110), 0.35f),
|
||||
TacticalDeco.Flower => (new Color(220,180,210), 0.25f),
|
||||
TacticalDeco.Crop => (new Color(180,160, 60), 0.40f),
|
||||
TacticalDeco.Reed => (new Color(120,140, 60), 0.40f),
|
||||
TacticalDeco.Snag => (new Color(80, 60, 40), 0.45f),
|
||||
_ => (Color.Magenta, 0.5f),
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
foreach (var t in _owned) t.Dispose();
|
||||
_owned.Clear();
|
||||
_surfaces.Clear();
|
||||
_decos.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Tactical;
|
||||
|
||||
namespace Theriapolis.Game.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Renders the streamed tactical view: per-tile surface + decoration sprites,
|
||||
/// with a soft edge-blend pass between dissimilar adjacent surfaces.
|
||||
///
|
||||
/// Each tactical tile occupies 1×1 world pixel (the canonical coord system).
|
||||
/// Sprites authored at <see cref="C.TACTICAL_TILE_SPRITE_PX"/>² are drawn at
|
||||
/// scale = 1 / TACTICAL_TILE_SPRITE_PX so the source texture fits inside that
|
||||
/// 1×1 cell.
|
||||
///
|
||||
/// Render order, per visible chunk:
|
||||
/// 1. Base surface tile (its own texture).
|
||||
/// 2. Edge blend overlays — for each cardinal neighbour with a different
|
||||
/// surface, draw a per-edge alpha-gradient mask tinted with the
|
||||
/// neighbour's average color. This softens the otherwise hard seam
|
||||
/// between dissimilar surfaces (the "wallpaper grid" look).
|
||||
/// 3. Decoration sprite, if any.
|
||||
///
|
||||
/// This is "Option B" autotiling per the Phase 4 plan — short-term smoothing
|
||||
/// using flat color blends. The longer-term plan is "Option C": full Wang
|
||||
/// corner-based autotiling driven by Pixellab's `create_topdown_tileset`,
|
||||
/// where each cell picks one of 16 transition tiles based on its 4-corner
|
||||
/// terrain sample. Tracked in `theriapolis-tactical-tile-art-request.md`.
|
||||
///
|
||||
/// Rivers/roads/rail are NOT redrawn here; the polyline burn-in already
|
||||
/// embedded them in the chunk's surface tiles, and LineFeatureRenderer keeps
|
||||
/// drawing the source polylines on top so the shared visual is unbroken.
|
||||
/// </summary>
|
||||
public sealed class TacticalRenderer : IMapView, IDisposable
|
||||
{
|
||||
private readonly GraphicsDevice _gd;
|
||||
private readonly ChunkStreamer _streamer;
|
||||
private readonly TacticalAtlas _atlas;
|
||||
private bool _disposed;
|
||||
|
||||
// 1/SpritePx — multiplied by sprite source size (32) to land at 1×1 world pixel.
|
||||
private static readonly Vector2 SpriteScale =
|
||||
new(1f / C.TACTICAL_TILE_SPRITE_PX, 1f / C.TACTICAL_TILE_SPRITE_PX);
|
||||
|
||||
// Toggle for the Option B edge-blend pass. Currently OFF — the first
|
||||
// tuning produced washed-out tiles when many neighbouring surfaces had
|
||||
// saturated placeholder colours (snow≈white, sand=cream). Two issues to
|
||||
// fix before re-enabling:
|
||||
// 1. 4 overlapping masks compound into ~4× alpha at tile corners with
|
||||
// 4 different neighbours — needs cap or non-overlapping geometry.
|
||||
// 2. Tint alpha (0.55) and 16-px falloff are both too aggressive when
|
||||
// the neighbour colour is nothing like our own.
|
||||
// Defer re-tuning until the per-tile art set is filled in; the placeholder
|
||||
// colour palette isn't a fair test bed.
|
||||
// static readonly (not const) so the guard evaluates at runtime — avoids
|
||||
// CS0162 unreachable-code warnings on the gated branch.
|
||||
private static readonly bool EdgeBlendEnabled = false;
|
||||
|
||||
// Edge masks — 32×32 textures with white RGB and an alpha gradient that
|
||||
// fades from the named edge inward. Drawn over a tile (tinted with the
|
||||
// neighbour's avg color) to bleed neighbour colour over our own.
|
||||
private Texture2D _edgeN = null!, _edgeE = null!, _edgeS = null!, _edgeW = null!;
|
||||
|
||||
public TacticalRenderer(GraphicsDevice gd, ChunkStreamer streamer, TacticalAtlas atlas)
|
||||
{
|
||||
_gd = gd;
|
||||
_streamer = streamer;
|
||||
_atlas = atlas;
|
||||
BuildEdgeMasks();
|
||||
}
|
||||
|
||||
private void BuildEdgeMasks()
|
||||
{
|
||||
_edgeN = MakeMask((x, y, sz) => 1f - (float)y / (sz / 2));
|
||||
_edgeS = MakeMask((x, y, sz) => 1f - (float)(sz - 1 - y) / (sz / 2));
|
||||
_edgeW = MakeMask((x, y, sz) => 1f - (float)x / (sz / 2));
|
||||
_edgeE = MakeMask((x, y, sz) => 1f - (float)(sz - 1 - x) / (sz / 2));
|
||||
}
|
||||
|
||||
private Texture2D MakeMask(Func<int, int, int, float> alphaAt)
|
||||
{
|
||||
int sz = C.TACTICAL_TILE_SPRITE_PX;
|
||||
var pixels = new Color[sz * sz];
|
||||
for (int y = 0; y < sz; y++)
|
||||
for (int x = 0; x < sz; x++)
|
||||
{
|
||||
float a = MathHelper.Clamp(alphaAt(x, y, sz), 0f, 1f);
|
||||
// Quadratic falloff feels softer than linear at the seam itself.
|
||||
a = a * a;
|
||||
pixels[y * sz + x] = new Color((byte)255, (byte)255, (byte)255, (byte)(a * 255));
|
||||
}
|
||||
var tex = new Texture2D(_gd, sz, sz);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
public void Draw(SpriteBatch sb, Camera2D camera, GameTime gameTime)
|
||||
{
|
||||
// Visible AABB in tactical-tile (world-pixel) coords.
|
||||
var tl = camera.ScreenToWorld(Vector2.Zero);
|
||||
var br = camera.ScreenToWorld(new Vector2(camera.ScreenWidth, camera.ScreenHeight));
|
||||
int x0 = (int)MathF.Floor(tl.X) - 1;
|
||||
int y0 = (int)MathF.Floor(tl.Y) - 1;
|
||||
int x1 = (int)MathF.Ceiling(br.X) + 1;
|
||||
int y1 = (int)MathF.Ceiling(br.Y) + 1;
|
||||
|
||||
// Clamp to the world.
|
||||
int worldPxW = C.WORLD_WIDTH_TILES * C.TACTICAL_PER_WORLD_TILE;
|
||||
int worldPxH = C.WORLD_HEIGHT_TILES * C.TACTICAL_PER_WORLD_TILE;
|
||||
x0 = Math.Clamp(x0, 0, worldPxW);
|
||||
y0 = Math.Clamp(y0, 0, worldPxH);
|
||||
x1 = Math.Clamp(x1, 0, worldPxW);
|
||||
y1 = Math.Clamp(y1, 0, worldPxH);
|
||||
if (x0 >= x1 || y0 >= y1) return;
|
||||
|
||||
sb.Begin(
|
||||
transformMatrix: camera.TransformMatrix,
|
||||
samplerState: SamplerState.PointClamp,
|
||||
sortMode: SpriteSortMode.Deferred,
|
||||
blendState: BlendState.AlphaBlend);
|
||||
|
||||
// Iterate chunk-by-chunk so we touch each cached chunk array directly.
|
||||
var ccTl = ChunkCoord.ForTactical(x0, y0);
|
||||
var ccBr = ChunkCoord.ForTactical(x1 - 1, y1 - 1);
|
||||
|
||||
// Pass 1: base surfaces.
|
||||
for (int cy = ccTl.Y; cy <= ccBr.Y; cy++)
|
||||
for (int cx = ccTl.X; cx <= ccBr.X; cx++)
|
||||
{
|
||||
var chunk = _streamer.Get(new ChunkCoord(cx, cy));
|
||||
DrawChunkSurfaces(sb, chunk, x0, y0, x1, y1);
|
||||
}
|
||||
|
||||
// Pass 2: edge blends — gated on EdgeBlendEnabled (currently false).
|
||||
if (EdgeBlendEnabled)
|
||||
{
|
||||
for (int cy = ccTl.Y; cy <= ccBr.Y; cy++)
|
||||
for (int cx = ccTl.X; cx <= ccBr.X; cx++)
|
||||
{
|
||||
var chunk = _streamer.Get(new ChunkCoord(cx, cy));
|
||||
DrawChunkEdgeBlends(sb, chunk, x0, y0, x1, y1);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: decorations.
|
||||
for (int cy = ccTl.Y; cy <= ccBr.Y; cy++)
|
||||
for (int cx = ccTl.X; cx <= ccBr.X; cx++)
|
||||
{
|
||||
var chunk = _streamer.Get(new ChunkCoord(cx, cy));
|
||||
DrawChunkDecos(sb, chunk, x0, y0, x1, y1);
|
||||
}
|
||||
|
||||
sb.End();
|
||||
}
|
||||
|
||||
private void DrawChunkSurfaces(SpriteBatch sb, TacticalChunk chunk, int vx0, int vy0, int vx1, int vy1)
|
||||
{
|
||||
int ox = chunk.OriginX;
|
||||
int oy = chunk.OriginY;
|
||||
int sx = Math.Max(0, vx0 - ox);
|
||||
int sy = Math.Max(0, vy0 - oy);
|
||||
int ex = Math.Min(C.TACTICAL_CHUNK_SIZE, vx1 - ox);
|
||||
int ey = Math.Min(C.TACTICAL_CHUNK_SIZE, vy1 - oy);
|
||||
|
||||
for (int ly = sy; ly < ey; ly++)
|
||||
for (int lx = sx; lx < ex; lx++)
|
||||
{
|
||||
ref var t = ref chunk.Tiles[lx, ly];
|
||||
var tex = _atlas.GetSurface(t.Surface, t.Variant);
|
||||
sb.Draw(tex,
|
||||
position: new Vector2(ox + lx, oy + ly),
|
||||
sourceRectangle: null,
|
||||
color: Color.White,
|
||||
rotation: 0f,
|
||||
origin: Vector2.Zero,
|
||||
scale: SpriteScale,
|
||||
effects: SpriteEffects.None,
|
||||
layerDepth: 0f);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawChunkEdgeBlends(SpriteBatch sb, TacticalChunk chunk, int vx0, int vy0, int vx1, int vy1)
|
||||
{
|
||||
int ox = chunk.OriginX;
|
||||
int oy = chunk.OriginY;
|
||||
int sx = Math.Max(0, vx0 - ox);
|
||||
int sy = Math.Max(0, vy0 - oy);
|
||||
int ex = Math.Min(C.TACTICAL_CHUNK_SIZE, vx1 - ox);
|
||||
int ey = Math.Min(C.TACTICAL_CHUNK_SIZE, vy1 - oy);
|
||||
|
||||
for (int ly = sy; ly < ey; ly++)
|
||||
for (int lx = sx; lx < ex; lx++)
|
||||
{
|
||||
ref var t = ref chunk.Tiles[lx, ly];
|
||||
int tx = ox + lx, ty = oy + ly;
|
||||
// Sample 4 cardinal neighbours via the streamer (handles cross-chunk).
|
||||
var nN = _streamer.SampleTile(tx, ty - 1).Surface;
|
||||
var nS = _streamer.SampleTile(tx, ty + 1).Surface;
|
||||
var nW = _streamer.SampleTile(tx - 1, ty ).Surface;
|
||||
var nE = _streamer.SampleTile(tx + 1, ty ).Surface;
|
||||
|
||||
if (nN != t.Surface) BlendEdge(sb, tx, ty, _edgeN, _atlas.GetSurfaceAverageColor(nN));
|
||||
if (nS != t.Surface) BlendEdge(sb, tx, ty, _edgeS, _atlas.GetSurfaceAverageColor(nS));
|
||||
if (nW != t.Surface) BlendEdge(sb, tx, ty, _edgeW, _atlas.GetSurfaceAverageColor(nW));
|
||||
if (nE != t.Surface) BlendEdge(sb, tx, ty, _edgeE, _atlas.GetSurfaceAverageColor(nE));
|
||||
}
|
||||
}
|
||||
|
||||
private void BlendEdge(SpriteBatch sb, int tx, int ty, Texture2D mask, Color tint)
|
||||
{
|
||||
if (tint.A == 0) return;
|
||||
// Cap blend strength so the seam softens but we don't drown out our own
|
||||
// surface. 0.55 is a comfortable mid-point — stronger than a hint, weaker
|
||||
// than a 50/50 blend.
|
||||
var c = new Color(tint.R, tint.G, tint.B, (byte)(0.55f * 255));
|
||||
sb.Draw(mask,
|
||||
position: new Vector2(tx, ty),
|
||||
sourceRectangle: null,
|
||||
color: c,
|
||||
rotation: 0f,
|
||||
origin: Vector2.Zero,
|
||||
scale: SpriteScale,
|
||||
effects: SpriteEffects.None,
|
||||
layerDepth: 0f);
|
||||
}
|
||||
|
||||
private void DrawChunkDecos(SpriteBatch sb, TacticalChunk chunk, int vx0, int vy0, int vx1, int vy1)
|
||||
{
|
||||
int ox = chunk.OriginX;
|
||||
int oy = chunk.OriginY;
|
||||
int sx = Math.Max(0, vx0 - ox);
|
||||
int sy = Math.Max(0, vy0 - oy);
|
||||
int ex = Math.Min(C.TACTICAL_CHUNK_SIZE, vx1 - ox);
|
||||
int ey = Math.Min(C.TACTICAL_CHUNK_SIZE, vy1 - oy);
|
||||
|
||||
for (int ly = sy; ly < ey; ly++)
|
||||
for (int lx = sx; lx < ex; lx++)
|
||||
{
|
||||
ref var t = ref chunk.Tiles[lx, ly];
|
||||
var tex = _atlas.GetDeco(t.Deco, t.Variant);
|
||||
if (tex is null) continue;
|
||||
sb.Draw(tex,
|
||||
position: new Vector2(ox + lx, oy + ly),
|
||||
sourceRectangle: null,
|
||||
color: Color.White,
|
||||
rotation: 0f,
|
||||
origin: Vector2.Zero,
|
||||
scale: SpriteScale,
|
||||
effects: SpriteEffects.None,
|
||||
layerDepth: 0f);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_edgeN?.Dispose();
|
||||
_edgeE?.Dispose();
|
||||
_edgeS?.Dispose();
|
||||
_edgeW?.Dispose();
|
||||
// _atlas is owned by PlayScreen; do not dispose here.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.World;
|
||||
|
||||
namespace Theriapolis.Game.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the biome tile textures used by the world-map renderer.
|
||||
/// For Phase 0/1, all tiles are generated at runtime as flat-colour 32×32 squares
|
||||
/// with a 1px darker border and a single centered letter.
|
||||
/// Final art will replace these by swapping file contents; no code changes needed.
|
||||
/// </summary>
|
||||
public sealed class TileAtlas : IDisposable
|
||||
{
|
||||
private readonly GraphicsDevice _gd;
|
||||
private readonly Dictionary<BiomeId, Texture2D> _textures = new();
|
||||
private readonly Dictionary<int, Texture2D> _settlementIcons = new(); // keyed by tier
|
||||
private SpriteFont? _font;
|
||||
private bool _disposed;
|
||||
|
||||
public GraphicsDevice GraphicsDevice => _gd;
|
||||
|
||||
// Fallback solid-color texture for any biome that has no dedicated entry
|
||||
private Texture2D? _fallback;
|
||||
|
||||
public TileAtlas(GraphicsDevice gd)
|
||||
{
|
||||
_gd = gd;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate all placeholder textures from the loaded BiomeDef array.
|
||||
/// Call once after content is loaded.
|
||||
/// </summary>
|
||||
public void GeneratePlaceholders(BiomeDef[] biomes, SpriteFont? font = null)
|
||||
{
|
||||
_font = font;
|
||||
foreach (var def in biomes)
|
||||
{
|
||||
var biomeId = BiomeAssignHelper.ParseBiomeId(def.Id);
|
||||
if (_textures.ContainsKey(biomeId)) continue;
|
||||
_textures[biomeId] = MakeTile(def);
|
||||
}
|
||||
_fallback = MakeSolidColor(Color.HotPink); // obvious "missing art" colour
|
||||
GenerateSettlementIcons();
|
||||
}
|
||||
|
||||
/// <summary>Returns the settlement icon texture for the given tier (1–5).</summary>
|
||||
public Texture2D GetSettlementIcon(int tier)
|
||||
{
|
||||
if (_settlementIcons.TryGetValue(tier, out var tex)) return tex;
|
||||
return _fallback ?? _textures.Values.First();
|
||||
}
|
||||
|
||||
private void GenerateSettlementIcons()
|
||||
{
|
||||
// Tier 1: large gold diamond (capital)
|
||||
_settlementIcons[1] = MakeSettlementIcon(20, new Color(255, 215, 0), diamond: true);
|
||||
// Tier 2: white square (city)
|
||||
_settlementIcons[2] = MakeSettlementIcon(14, new Color(230, 230, 230), diamond: false);
|
||||
// Tier 3: light-blue circle (town)
|
||||
_settlementIcons[3] = MakeSettlementIcon(10, new Color(150, 200, 255), diamond: false);
|
||||
// Tier 4: pale dot (village)
|
||||
_settlementIcons[4] = MakeSettlementIcon(6, new Color(200, 200, 200), diamond: false);
|
||||
// Tier 5 (PoI): small red circle
|
||||
_settlementIcons[5] = MakeSettlementIcon(5, new Color(200, 60, 60), diamond: false);
|
||||
}
|
||||
|
||||
private Texture2D MakeSettlementIcon(int size, Color fill, bool diamond)
|
||||
{
|
||||
var tex = new Texture2D(_gd, size, size);
|
||||
var pixels = new Color[size * size];
|
||||
float cx = (size - 1) * 0.5f;
|
||||
float cy = (size - 1) * 0.5f;
|
||||
|
||||
for (int py = 0; py < size; py++)
|
||||
for (int px = 0; px < size; px++)
|
||||
{
|
||||
float nx = px - cx, ny = py - cy;
|
||||
bool inside = diamond
|
||||
? (MathF.Abs(nx) + MathF.Abs(ny)) <= size * 0.5f
|
||||
: (nx * nx + ny * ny) <= (cx * cx);
|
||||
pixels[py * size + px] = inside ? fill : Color.Transparent;
|
||||
}
|
||||
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
/// <summary>Returns the texture for the given biome, falling back to the error texture.</summary>
|
||||
public Texture2D GetTile(BiomeId biome)
|
||||
{
|
||||
if (_textures.TryGetValue(biome, out var tex)) return tex;
|
||||
return _fallback ?? _textures.Values.First();
|
||||
}
|
||||
|
||||
// ── Texture generation helpers ────────────────────────────────────────────
|
||||
|
||||
private Texture2D MakeTile(BiomeDef def)
|
||||
{
|
||||
int size = Theriapolis.Core.C.WORLD_TILE_PIXELS;
|
||||
var (r, g, b) = def.ParsedColor();
|
||||
var fillColor = new Color(r, g, b);
|
||||
|
||||
var tex = new Texture2D(_gd, size, size);
|
||||
var pixels = new Color[size * size];
|
||||
Array.Fill(pixels, fillColor);
|
||||
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
private Texture2D MakeSolidColor(Color color)
|
||||
{
|
||||
int size = Theriapolis.Core.C.WORLD_TILE_PIXELS;
|
||||
var tex = new Texture2D(_gd, size, size);
|
||||
var pixels = new Color[size * size];
|
||||
Array.Fill(pixels, color);
|
||||
tex.SetData(pixels);
|
||||
return tex;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
foreach (var tex in _textures.Values) tex.Dispose();
|
||||
foreach (var tex in _settlementIcons.Values) tex.Dispose();
|
||||
_fallback?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Internal helper to avoid exposing the private static method on BiomeAssignStage.</summary>
|
||||
internal static class BiomeAssignHelper
|
||||
{
|
||||
public static BiomeId ParseBiomeId(string id)
|
||||
=> Theriapolis.Core.World.Generation.Stages.BiomeAssignStage.ParseBiomeId(id);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
|
||||
namespace Theriapolis.Game.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Renders the world map: biome tiles, rivers, roads, rail, and settlement icons.
|
||||
/// Draw order: terrain → roads → rivers → rail → settlements.
|
||||
/// </summary>
|
||||
public sealed class WorldMapRenderer : IMapView, IDisposable
|
||||
{
|
||||
private readonly WorldGenContext _ctx;
|
||||
private readonly TileAtlas _atlas;
|
||||
private readonly LineFeatureRenderer _lineRenderer;
|
||||
private bool _disposed;
|
||||
|
||||
// Zoom level below which settlement labels are hidden to avoid clutter
|
||||
private const float LabelMinZoom = 0.5f;
|
||||
// Min tier to show at low zoom (hide tier 4 villages when zoomed out)
|
||||
private const float SettleHideZoom = 0.08f;
|
||||
|
||||
public WorldMapRenderer(WorldGenContext ctx, TileAtlas atlas)
|
||||
{
|
||||
_ctx = ctx;
|
||||
_atlas = atlas;
|
||||
_lineRenderer = new LineFeatureRenderer(atlas.GraphicsDevice, ctx);
|
||||
}
|
||||
|
||||
public void Draw(SpriteBatch sb, Camera2D camera, GameTime gameTime)
|
||||
{
|
||||
DrawTerrain(sb, camera);
|
||||
_lineRenderer.Draw(sb, camera);
|
||||
DrawSettlements(sb, camera);
|
||||
}
|
||||
|
||||
private void DrawTerrain(SpriteBatch sb, Camera2D camera)
|
||||
{
|
||||
var (x0, y0, x1, y1) = camera.VisibleTileRect();
|
||||
int tilePixels = C.WORLD_TILE_PIXELS;
|
||||
|
||||
sb.Begin(
|
||||
transformMatrix: camera.TransformMatrix,
|
||||
samplerState: SamplerState.PointClamp,
|
||||
sortMode: SpriteSortMode.Deferred);
|
||||
|
||||
for (int ty = y0; ty <= y1; ty++)
|
||||
for (int tx = x0; tx <= x1; tx++)
|
||||
{
|
||||
ref var tile = ref _ctx.World.TileAt(tx, ty);
|
||||
var tex = _atlas.GetTile(tile.Biome);
|
||||
var dest = new Rectangle(tx * tilePixels, ty * tilePixels, tilePixels, tilePixels);
|
||||
sb.Draw(tex, dest, Color.White);
|
||||
}
|
||||
|
||||
sb.End();
|
||||
}
|
||||
|
||||
private void DrawSettlements(SpriteBatch sb, Camera2D camera)
|
||||
{
|
||||
if (_ctx.World.Settlements.Count == 0) return;
|
||||
|
||||
sb.Begin(
|
||||
transformMatrix: camera.TransformMatrix,
|
||||
samplerState: SamplerState.LinearClamp,
|
||||
sortMode: SpriteSortMode.Deferred,
|
||||
blendState: BlendState.AlphaBlend);
|
||||
|
||||
bool hideSmall = camera.Zoom < SettleHideZoom;
|
||||
|
||||
foreach (var s in _ctx.World.Settlements)
|
||||
{
|
||||
if (hideSmall && s.Tier >= 4) continue;
|
||||
|
||||
var icon = _atlas.GetSettlementIcon(s.Tier);
|
||||
float wx = s.TileX * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f;
|
||||
float wy = s.TileY * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f;
|
||||
var origin = new Vector2(icon.Width * 0.5f, icon.Height * 0.5f);
|
||||
|
||||
sb.Draw(icon,
|
||||
position: new Vector2(wx, wy),
|
||||
sourceRectangle: null,
|
||||
color: Color.White,
|
||||
rotation: 0f,
|
||||
origin: origin,
|
||||
scale: 1f,
|
||||
effects: SpriteEffects.None,
|
||||
layerDepth: 0f);
|
||||
}
|
||||
|
||||
sb.End();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_lineRenderer.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,886 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Game.UI;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M5 character-creation wizard. 7-step illuminated-codex flow per
|
||||
/// the Claude Design handoff (`_design_handoff/character_creation/`):
|
||||
/// Clade → Species → Calling → History → Abilities → Skills → Sign.
|
||||
///
|
||||
/// The right-hand aside summarises the character as it builds; an aborted
|
||||
/// run from the back button returns to the title without committing. The
|
||||
/// final Confirm button calls <see cref="CharacterBuilder.Build"/> with the
|
||||
/// resolver's items table so the new character arrives with their starting
|
||||
/// kit equipped.
|
||||
///
|
||||
/// Differences from the React design:
|
||||
/// - Drag-and-drop stat assignment → click-pick-then-click-place, since
|
||||
/// Myra doesn't ship native drag-drop. The pool highlights the selected
|
||||
/// value; the next ability slot click consumes it. Click a filled slot
|
||||
/// to return its value to the pool.
|
||||
/// - Hover popovers with full trait descriptions → "Selected" detail line
|
||||
/// at the bottom of the aside panel that updates on click.
|
||||
/// - Illuminated-codex visual styling → semi-transparent dark panel with
|
||||
/// Myra's default fonts. Full art-direction port (parchment background,
|
||||
/// gilded accents, serif display fonts) is M6+ theme work.
|
||||
/// </summary>
|
||||
public sealed class CharacterCreationScreen : IScreen
|
||||
{
|
||||
private readonly ulong _seed;
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private VerticalStackPanel _root = null!;
|
||||
|
||||
// Loaded content
|
||||
private ContentResolver _content = null!;
|
||||
private CladeDef[] _clades = null!;
|
||||
private SpeciesDef[] _allSpecies = null!;
|
||||
private ClassDef[] _classes = null!;
|
||||
private BackgroundDef[] _backgrounds = null!;
|
||||
|
||||
// Wizard state
|
||||
private int _step;
|
||||
private CladeDef? _clade;
|
||||
private SpeciesDef? _species;
|
||||
private ClassDef? _class;
|
||||
private BackgroundDef? _background;
|
||||
private string _name = "Wanderer";
|
||||
|
||||
// Stat assignment state
|
||||
private bool _useRoll;
|
||||
private readonly List<int> _statPool = new();
|
||||
private readonly Dictionary<AbilityId, int> _statAssign = new();
|
||||
private readonly List<int[]> _statHistory = new();
|
||||
private int? _pendingPoolIdx; // index in _statPool of currently-selected value (click-pick-place)
|
||||
|
||||
// Skill state
|
||||
private readonly HashSet<SkillId> _chosenSkills = new();
|
||||
|
||||
// Stat-roll seeding (per the Phase 5 plan §4.2 / DESIGN_INTENT lock).
|
||||
private readonly long _gameStartMs;
|
||||
private long _msAtScreenOpen;
|
||||
|
||||
// Detail panel (replaces hover popovers) — last clicked trait/skill/feature.
|
||||
private string _detailTitle = "";
|
||||
private string _detailBody = "";
|
||||
|
||||
private static readonly string[] StepNames = new[]
|
||||
{
|
||||
"Clade", "Species", "Calling", "History", "Abilities", "Skills", "Sign",
|
||||
};
|
||||
|
||||
public CharacterCreationScreen(ulong seed)
|
||||
{
|
||||
_seed = seed;
|
||||
_gameStartMs = System.Environment.TickCount64;
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
_msAtScreenOpen = System.Environment.TickCount64 - _gameStartMs;
|
||||
|
||||
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();
|
||||
|
||||
// Defaults so the player can press Confirm immediately.
|
||||
_clade = _clades.FirstOrDefault(c => c.Id == "canidae") ?? _clades[0];
|
||||
_species = _allSpecies.FirstOrDefault(s => s.CladeId == _clade.Id);
|
||||
_class = _classes.FirstOrDefault(c => c.Id == "fangsworn") ?? _classes[0];
|
||||
_background = _backgrounds.FirstOrDefault(b => b.Id == "pack_raised") ?? _backgrounds[0];
|
||||
InitStandardArrayPool();
|
||||
AutoPickSkills();
|
||||
|
||||
BuildUI();
|
||||
}
|
||||
|
||||
// ── Layout ───────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
_root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
Padding = new Thickness(16, 12, 16, 12),
|
||||
};
|
||||
|
||||
// Header
|
||||
_root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"THERIAPOLIS — Codex of Becoming · Folio {CodexCopy.Romanize(_step + 1)} of VII — {StepNames[_step]}",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
_root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"Seed 0x{_seed:X}",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
_root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Stepper
|
||||
_root.Widgets.Add(BuildStepper());
|
||||
_root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Two-column main area: page-main + aside.
|
||||
var twoCol = new HorizontalStackPanel
|
||||
{
|
||||
Spacing = 18,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
};
|
||||
twoCol.Widgets.Add(BuildCurrentStep());
|
||||
twoCol.Widgets.Add(BuildAside());
|
||||
_root.Widgets.Add(twoCol);
|
||||
|
||||
// Nav bar
|
||||
_root.Widgets.Add(new Label { Text = " " });
|
||||
_root.Widgets.Add(BuildNav());
|
||||
|
||||
_desktop = new Desktop { Root = _root };
|
||||
}
|
||||
|
||||
private void Rebuild() => BuildUI();
|
||||
|
||||
private Widget BuildStepper()
|
||||
{
|
||||
var row = new HorizontalStackPanel { Spacing = 4, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
int firstIncomplete = -1;
|
||||
for (int i = 0; i < StepNames.Length; i++) if (ValidateStep(i) is not null) { firstIncomplete = i; break; }
|
||||
|
||||
for (int i = 0; i < StepNames.Length; i++)
|
||||
{
|
||||
bool isCurrent = i == _step;
|
||||
bool isComplete = ValidateStep(i) is null && !isCurrent;
|
||||
bool locked = i > _step && firstIncomplete != -1 && firstIncomplete < i;
|
||||
string mark = locked ? "✕" : (isComplete ? "✓" : CodexCopy.Romanize(i + 1));
|
||||
string label = $"{mark} {StepNames[i]}";
|
||||
int idx = i;
|
||||
var btn = new TextButton
|
||||
{
|
||||
Text = isCurrent ? "→ " + label : " " + label,
|
||||
Padding = new Thickness(8, 4, 8, 4),
|
||||
Enabled = !locked,
|
||||
};
|
||||
if (!locked) btn.Click += (_, _) => { _step = idx; Rebuild(); };
|
||||
row.Widgets.Add(btn);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private Widget BuildCurrentStep()
|
||||
{
|
||||
var panel = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
Width = 720,
|
||||
Padding = new Thickness(12, 10, 12, 10),
|
||||
Background = new SolidBrush(new Color(15, 15, 25, 220)),
|
||||
};
|
||||
switch (_step)
|
||||
{
|
||||
case 0: BuildStepClade(panel); break;
|
||||
case 1: BuildStepSpecies(panel); break;
|
||||
case 2: BuildStepClass(panel); break;
|
||||
case 3: BuildStepBackground(panel); break;
|
||||
case 4: BuildStepStats(panel); break;
|
||||
case 5: BuildStepSkills(panel); break;
|
||||
case 6: BuildStepReview(panel); break;
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
private Widget BuildAside()
|
||||
{
|
||||
var col = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
Width = 320,
|
||||
Padding = new Thickness(12, 10, 12, 10),
|
||||
Background = new SolidBrush(new Color(8, 8, 16, 230)),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
};
|
||||
col.Widgets.Add(new Label { Text = "— THE SUBJECT —", HorizontalAlignment = HorizontalAlignment.Center });
|
||||
col.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
col.Widgets.Add(new Label { Text = "Name" });
|
||||
col.Widgets.Add(new Label
|
||||
{
|
||||
Text = string.IsNullOrWhiteSpace(_name) ? " (unnamed)" : " " + _name,
|
||||
});
|
||||
col.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
col.Widgets.Add(new Label { Text = "Lineage" });
|
||||
col.Widgets.Add(new Label
|
||||
{
|
||||
Text = $" {_species?.Name ?? "—"} ({_clade?.Name ?? "—"} · {CodexCopy.SizeLabel(_species?.Size ?? "")})",
|
||||
});
|
||||
col.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
col.Widgets.Add(new Label { Text = "Calling & History" });
|
||||
col.Widgets.Add(new Label
|
||||
{
|
||||
Text = $" {_class?.Name ?? "—"} (d{_class?.HitDie ?? 0})\n {_background?.Name ?? "—"}",
|
||||
});
|
||||
col.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
col.Widgets.Add(new Label { Text = "Abilities" });
|
||||
col.Widgets.Add(new Label { Text = FormatAbilityStrip() });
|
||||
col.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
int totalSkills = _chosenSkills.Count + (_background?.SkillProficiencies.Length ?? 0);
|
||||
col.Widgets.Add(new Label { Text = $"Skills · {totalSkills}" });
|
||||
col.Widgets.Add(new Label { Text = " " + FormatSkillSummary() });
|
||||
|
||||
if (!string.IsNullOrEmpty(_detailTitle))
|
||||
{
|
||||
col.Widgets.Add(new Label { Text = " " });
|
||||
col.Widgets.Add(new Label { Text = "— Selected —" });
|
||||
col.Widgets.Add(new Label { Text = " " + _detailTitle });
|
||||
col.Widgets.Add(new Label { Text = WordWrap(_detailBody, 38) });
|
||||
}
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
private Widget BuildNav()
|
||||
{
|
||||
var row = new HorizontalStackPanel
|
||||
{
|
||||
Spacing = 16,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
var back = new TextButton { Text = "← Back", Width = 120, Enabled = _step > 0 };
|
||||
back.Click += (_, _) => { _step--; Rebuild(); };
|
||||
row.Widgets.Add(back);
|
||||
|
||||
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"));
|
||||
row.Widgets.Add(new Label { Text = $" {status} " });
|
||||
|
||||
if (_step < StepNames.Length - 1)
|
||||
{
|
||||
var next = new TextButton { Text = "Next ›", Width = 120, Enabled = stepError is null };
|
||||
next.Click += (_, _) => { _step++; Rebuild(); };
|
||||
row.Widgets.Add(next);
|
||||
}
|
||||
else
|
||||
{
|
||||
var confirm = new TextButton { Text = "Confirm & Begin", Width = 200, Enabled = allValid };
|
||||
confirm.Click += (_, _) => OnConfirm();
|
||||
row.Widgets.Add(confirm);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── Step builders ────────────────────────────────────────────────────
|
||||
|
||||
private void BuildStepClade(VerticalStackPanel page)
|
||||
{
|
||||
page.Widgets.Add(new Label { Text = "Folio I — Of Bloodlines" });
|
||||
page.Widgets.Add(new Label { Text = "Choose your Clade. The body you were born to — the broad shape of your gait,\nthe fall of your shadow, the words your scent carries before you speak." });
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Group predator / prey for visual scan.
|
||||
page.Widgets.Add(new Label { Text = "── Predators ──" });
|
||||
AddCladeRow(page, _clades.Where(c => c.Kind == "predator"));
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
page.Widgets.Add(new Label { Text = "── Prey ──" });
|
||||
AddCladeRow(page, _clades.Where(c => c.Kind == "prey"));
|
||||
}
|
||||
|
||||
private void AddCladeRow(VerticalStackPanel page, System.Collections.Generic.IEnumerable<CladeDef> clades)
|
||||
{
|
||||
var row = new HorizontalStackPanel { Spacing = 6 };
|
||||
foreach (var c in clades)
|
||||
{
|
||||
string mods = string.Join(" ", c.AbilityMods.Select(kv => $"{kv.Key} {CodexCopy.Signed(kv.Value)}"));
|
||||
string langs = string.Join(", ", c.Languages.Select(CodexCopy.LanguageName));
|
||||
string label = (_clade == c ? "→ " : " ") + c.Name + "\n " + mods + "\n langs: " + langs;
|
||||
var btn = new TextButton { Text = label, Width = 220, Padding = new Thickness(6, 4, 6, 4) };
|
||||
var clade = c;
|
||||
btn.Click += (_, _) =>
|
||||
{
|
||||
_clade = clade;
|
||||
if (_species is null || _species.CladeId != clade.Id)
|
||||
_species = _allSpecies.FirstOrDefault(s => s.CladeId == clade.Id);
|
||||
if (clade.Traits.Length > 0) ShowDetail(clade.Traits[0].Name, clade.Traits[0].Description);
|
||||
Rebuild();
|
||||
};
|
||||
row.Widgets.Add(btn);
|
||||
}
|
||||
page.Widgets.Add(row);
|
||||
}
|
||||
|
||||
private void BuildStepSpecies(VerticalStackPanel page)
|
||||
{
|
||||
page.Widgets.Add(new Label { Text = $"Folio II — Of Lineage within {_clade?.Name ?? "—"}" });
|
||||
page.Widgets.Add(new Label { Text = "Choose your Species. The species refines what the clade began —\ndifferent statures, ranges, and inheritances." });
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
var filtered = _allSpecies.Where(s => _clade is null || s.CladeId == _clade.Id).ToArray();
|
||||
// Render in rows of 3.
|
||||
for (int i = 0; i < filtered.Length; i += 3)
|
||||
{
|
||||
var row = new HorizontalStackPanel { Spacing = 6 };
|
||||
for (int j = i; j < System.Math.Min(filtered.Length, i + 3); j++)
|
||||
{
|
||||
var s = filtered[j];
|
||||
string mods = string.Join(" ", s.AbilityMods.Select(kv => $"{kv.Key} {CodexCopy.Signed(kv.Value)}"));
|
||||
string traitNames = string.Join(", ", s.Traits.Take(2).Select(t => t.Name));
|
||||
string label = (_species == s ? "→ " : " ") + s.Name + "\n " +
|
||||
$"{CodexCopy.SizeLabel(s.Size)} · {s.BaseSpeedFt} ft\n " +
|
||||
(string.IsNullOrEmpty(mods) ? "(no mods)" : mods) + "\n " +
|
||||
(string.IsNullOrEmpty(traitNames) ? "" : traitNames);
|
||||
var btn = new TextButton { Text = label, Width = 230, Padding = new Thickness(6, 4, 6, 4) };
|
||||
var sp = s;
|
||||
btn.Click += (_, _) =>
|
||||
{
|
||||
_species = sp;
|
||||
if (sp.Traits.Length > 0) ShowDetail(sp.Traits[0].Name, sp.Traits[0].Description);
|
||||
Rebuild();
|
||||
};
|
||||
row.Widgets.Add(btn);
|
||||
}
|
||||
page.Widgets.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildStepClass(VerticalStackPanel page)
|
||||
{
|
||||
page.Widgets.Add(new Label { Text = "Folio III — Of Vocations" });
|
||||
page.Widgets.Add(new Label { Text = "Choose your Calling. Each shapes how you fight, treat, parley, or unmake the world.\n★ Suits Clade marks callings recommended for your chosen clade." });
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
for (int i = 0; i < _classes.Length; i += 2)
|
||||
{
|
||||
var row = new HorizontalStackPanel { Spacing = 6 };
|
||||
for (int j = i; j < System.Math.Min(_classes.Length, i + 2); j++)
|
||||
{
|
||||
var c = _classes[j];
|
||||
bool suits = _clade is not null && CodexCopy.IsSuited(c.Id, _clade.Id);
|
||||
string suitTag = suits ? " ★" : "";
|
||||
var lvl1 = System.Array.Find(c.LevelTable, e => e.Level == 1);
|
||||
string features = lvl1 is null ? "" : string.Join(", ",
|
||||
lvl1.Features.Where(f => f != "asi" && f != "subclass_select" && f != "subclass_feature")
|
||||
.Select(f => c.FeatureDefinitions.TryGetValue(f, out var fd) ? fd.Name : f));
|
||||
string label = (_class == c ? "→ " : " ") + c.Name + suitTag + "\n " +
|
||||
$"d{c.HitDie} · {string.Join("/", c.PrimaryAbility)} · saves {string.Join("/", c.Saves)}\n " +
|
||||
$"Picks {c.SkillsChoose} skill(s)\n " + features;
|
||||
var btn = new TextButton { Text = label, Width = 350, Padding = new Thickness(6, 4, 6, 4) };
|
||||
var cls = c;
|
||||
btn.Click += (_, _) =>
|
||||
{
|
||||
_class = cls;
|
||||
AutoPickSkills();
|
||||
if (lvl1 is not null && lvl1.Features.Length > 0)
|
||||
{
|
||||
var firstReal = lvl1.Features.FirstOrDefault(f => f != "asi" && f != "subclass_select" && f != "subclass_feature");
|
||||
if (firstReal is not null && cls.FeatureDefinitions.TryGetValue(firstReal, out var fd))
|
||||
ShowDetail(fd.Name, fd.Description);
|
||||
}
|
||||
Rebuild();
|
||||
};
|
||||
row.Widgets.Add(btn);
|
||||
}
|
||||
page.Widgets.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildStepBackground(VerticalStackPanel page)
|
||||
{
|
||||
page.Widgets.Add(new Label { Text = "Folio IV — Of Histories" });
|
||||
page.Widgets.Add(new Label { Text = "Choose your Background. The clade gives you body, the calling gives you craft;\nbackground gives you a past — debts, contacts, scars, the way you sleep." });
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
for (int i = 0; i < _backgrounds.Length; i += 2)
|
||||
{
|
||||
var row = new HorizontalStackPanel { Spacing = 6 };
|
||||
for (int j = i; j < System.Math.Min(_backgrounds.Length, i + 2); j++)
|
||||
{
|
||||
var b = _backgrounds[j];
|
||||
string skills = string.Join(", ", b.SkillProficiencies.Select(CodexCopy.SkillName));
|
||||
string label = (_background == b ? "→ " : " ") + b.Name + "\n " +
|
||||
b.FeatureName + "\n Skills: " + skills;
|
||||
var btn = new TextButton { Text = label, Width = 350, Padding = new Thickness(6, 4, 6, 4) };
|
||||
var bg = b;
|
||||
btn.Click += (_, _) =>
|
||||
{
|
||||
_background = bg;
|
||||
ShowDetail(bg.FeatureName, bg.FeatureDescription);
|
||||
Rebuild();
|
||||
};
|
||||
row.Widgets.Add(btn);
|
||||
}
|
||||
page.Widgets.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildStepStats(VerticalStackPanel page)
|
||||
{
|
||||
page.Widgets.Add(new Label { Text = "Folio V — Of Aptitudes" });
|
||||
page.Widgets.Add(new Label { Text = "Set your Abilities. Click a value in the pool to select it,\nthen click an ability to assign. Click a filled slot to return its value to the pool." });
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Method tabs
|
||||
var tabs = new HorizontalStackPanel { Spacing = 8 };
|
||||
var arrayBtn = new TextButton { Text = (!_useRoll ? "→ " : " ") + "Standard Array", Width = 220 };
|
||||
arrayBtn.Click += (_, _) => { _useRoll = false; InitStandardArrayPool(); Rebuild(); };
|
||||
tabs.Widgets.Add(arrayBtn);
|
||||
var rollBtn = new TextButton { Text = (_useRoll ? "→ " : " ") + "Roll 4d6 — drop lowest", Width = 260 };
|
||||
rollBtn.Click += (_, _) => { _useRoll = true; RollAndPool(); Rebuild(); };
|
||||
tabs.Widgets.Add(rollBtn);
|
||||
page.Widgets.Add(tabs);
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Pool
|
||||
page.Widgets.Add(new Label { Text = "Pool (click to select):" });
|
||||
var poolRow = new HorizontalStackPanel { Spacing = 6 };
|
||||
if (_statPool.Count == 0)
|
||||
poolRow.Widgets.Add(new Label { Text = " (all values assigned — click a slot to return its value)" });
|
||||
for (int i = 0; i < _statPool.Count; i++)
|
||||
{
|
||||
int idx = i;
|
||||
int v = _statPool[i];
|
||||
bool selected = _pendingPoolIdx == idx;
|
||||
var dieBtn = new TextButton
|
||||
{
|
||||
Text = (selected ? "[" + v + "]" : " " + v + " "),
|
||||
Padding = new Thickness(8, 4, 8, 4),
|
||||
};
|
||||
dieBtn.Click += (_, _) => { _pendingPoolIdx = (selected ? null : (int?)idx); Rebuild(); };
|
||||
poolRow.Widgets.Add(dieBtn);
|
||||
}
|
||||
// Inline action buttons
|
||||
if (_useRoll)
|
||||
{
|
||||
var reroll = new TextButton { Text = "Reroll", Padding = new Thickness(6, 4, 6, 4) };
|
||||
reroll.Click += (_, _) => { RollAndPool(); Rebuild(); };
|
||||
poolRow.Widgets.Add(reroll);
|
||||
}
|
||||
var auto = new TextButton { Text = "Auto-assign", Padding = new Thickness(6, 4, 6, 4), Enabled = _statPool.Count > 0 };
|
||||
auto.Click += (_, _) => { AutoAssignByClassPriority(); Rebuild(); };
|
||||
poolRow.Widgets.Add(auto);
|
||||
var clear = new TextButton { Text = "Clear", Padding = new Thickness(6, 4, 6, 4), Enabled = _statAssign.Count > 0 };
|
||||
clear.Click += (_, _) => { ClearAssignments(); Rebuild(); };
|
||||
poolRow.Widgets.Add(clear);
|
||||
page.Widgets.Add(poolRow);
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Roll history (last 3 prior rolls)
|
||||
if (_useRoll && _statHistory.Count > 1)
|
||||
{
|
||||
var prev = _statHistory.Take(_statHistory.Count - 1).TakeLast(3);
|
||||
string hist = string.Join(" ", prev.Select(h => "[" + string.Join(", ", h) + "]"));
|
||||
page.Widgets.Add(new Label { Text = "Previous rolls: " + hist });
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
}
|
||||
|
||||
// Ability rows
|
||||
foreach (var ab in CodexCopy.AbilityOrder)
|
||||
{
|
||||
int? assigned = _statAssign.TryGetValue(ab, out var v) ? v : null;
|
||||
int cladeMod = ModFromDict(_clade?.AbilityMods, ab);
|
||||
int speciesMod = ModFromDict(_species?.AbilityMods, ab);
|
||||
int totalBonus = cladeMod + speciesMod;
|
||||
int finalScore = (assigned ?? 0) + totalBonus;
|
||||
int finalMod = AbilityScores.Mod(finalScore);
|
||||
bool isPrimary = _class?.PrimaryAbility.Contains(ab.ToString()) == true;
|
||||
string primaryTag = isPrimary ? " *" : "";
|
||||
string bonusTag = totalBonus == 0 ? "" : $" ({CodexCopy.Signed(totalBonus)} from clade+species)";
|
||||
|
||||
string slotText = assigned is null ? " [ — ] " : $" [ {assigned} ] ";
|
||||
string fullText = $"{ab}{primaryTag} {CodexCopy.AbilityLabels[ab]}\n" +
|
||||
$"{slotText}{bonusTag}\n" +
|
||||
(assigned is null ? "" : $" Final: {finalScore} ({CodexCopy.Signed(finalMod)})");
|
||||
var rowBtn = new TextButton { Text = fullText, Width = 660, Padding = new Thickness(6, 4, 6, 4) };
|
||||
var ability = ab;
|
||||
var assignedSnap = assigned;
|
||||
rowBtn.Click += (_, _) =>
|
||||
{
|
||||
if (assignedSnap is null && _pendingPoolIdx is int pidx)
|
||||
{
|
||||
int val = _statPool[pidx];
|
||||
_statPool.RemoveAt(pidx);
|
||||
_statAssign[ability] = val;
|
||||
_pendingPoolIdx = null;
|
||||
}
|
||||
else if (assignedSnap is int v2)
|
||||
{
|
||||
_statPool.Add(v2);
|
||||
_statAssign.Remove(ability);
|
||||
}
|
||||
Rebuild();
|
||||
};
|
||||
page.Widgets.Add(rowBtn);
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildStepSkills(VerticalStackPanel page)
|
||||
{
|
||||
page.Widgets.Add(new Label { Text = "Folio VI — Of Trained Hands" });
|
||||
page.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"Choose your Skills. Background grants {_background?.SkillProficiencies.Length ?? 0} sealed; class lets you pick {_class?.SkillsChoose ?? 0} more.",
|
||||
});
|
||||
page.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"Chosen: {_chosenSkills.Count} / {_class?.SkillsChoose ?? 0} · Sealed by background: {_background?.SkillProficiencies.Length ?? 0}",
|
||||
});
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
var bgLocked = new HashSet<string>(_background?.SkillProficiencies ?? System.Array.Empty<string>(), System.StringComparer.OrdinalIgnoreCase);
|
||||
var classOpts = new HashSet<string>(_class?.SkillOptions ?? System.Array.Empty<string>(), System.StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Group by ability
|
||||
var grouped = new Dictionary<AbilityId, List<string>>();
|
||||
foreach (var ab in CodexCopy.AbilityOrder) grouped[ab] = new List<string>();
|
||||
foreach (var skillId in AllSkillIds())
|
||||
{
|
||||
var ab = CodexCopy.SkillAbility(skillId);
|
||||
grouped[ab].Add(skillId);
|
||||
}
|
||||
|
||||
foreach (var ab in CodexCopy.AbilityOrder)
|
||||
{
|
||||
page.Widgets.Add(new Label { Text = $"── {CodexCopy.AbilityLabels[ab]} ({ab}) ──" });
|
||||
foreach (var skillId in grouped[ab])
|
||||
{
|
||||
bool fromBg = bgLocked.Contains(skillId);
|
||||
bool fromClass = classOpts.Contains(skillId);
|
||||
bool checkedNow;
|
||||
try { checkedNow = _chosenSkills.Contains(SkillIdExtensions.FromJson(skillId)); }
|
||||
catch { checkedNow = false; }
|
||||
string mark = fromBg ? "[BG]" : (checkedNow ? "[✓]" : (fromClass ? "[ ]" : "[—]"));
|
||||
string label = $" {mark} {CodexCopy.SkillName(skillId)}" +
|
||||
(fromBg ? " (sealed by background)" : (fromClass ? "" : " (not offered by class)"));
|
||||
var btn = new TextButton
|
||||
{
|
||||
Text = label,
|
||||
Width = 600,
|
||||
Padding = new Thickness(4, 2, 4, 2),
|
||||
Enabled = fromClass && !fromBg,
|
||||
};
|
||||
if (fromClass && !fromBg)
|
||||
{
|
||||
var sid = skillId;
|
||||
btn.Click += (_, _) =>
|
||||
{
|
||||
SkillId enumId;
|
||||
try { enumId = SkillIdExtensions.FromJson(sid); }
|
||||
catch { return; }
|
||||
if (_chosenSkills.Contains(enumId))
|
||||
{
|
||||
_chosenSkills.Remove(enumId);
|
||||
}
|
||||
else if (_chosenSkills.Count < (_class?.SkillsChoose ?? 0))
|
||||
{
|
||||
_chosenSkills.Add(enumId);
|
||||
}
|
||||
ShowDetail(CodexCopy.SkillName(sid), CodexCopy.SkillDescription(sid));
|
||||
Rebuild();
|
||||
};
|
||||
}
|
||||
page.Widgets.Add(btn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildStepReview(VerticalStackPanel page)
|
||||
{
|
||||
page.Widgets.Add(new Label { Text = "Folio VII — Of Names & Witness" });
|
||||
page.Widgets.Add(new Label { Text = "Sign the Codex. The name you sign here is the one the world will speak." });
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Name input
|
||||
var nameRow = new HorizontalStackPanel { Spacing = 8 };
|
||||
nameRow.Widgets.Add(new Label { Text = "Name:", VerticalAlignment = VerticalAlignment.Center });
|
||||
var nameInput = new TextBox { Text = _name, Width = 360 };
|
||||
nameInput.TextChanged += (_, _) => _name = nameInput.Text ?? "";
|
||||
nameRow.Widgets.Add(nameInput);
|
||||
page.Widgets.Add(nameRow);
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Lineage block
|
||||
page.Widgets.Add(new Label { Text = "── Lineage ──" });
|
||||
page.Widgets.Add(new Label
|
||||
{
|
||||
Text = $" {_clade?.Name ?? "—"} / {_species?.Name ?? "—"} ({CodexCopy.SizeLabel(_species?.Size ?? "")})",
|
||||
});
|
||||
page.Widgets.Add(MakeEditLink("Edit ›", 0));
|
||||
|
||||
// Calling+History
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
page.Widgets.Add(new Label { Text = "── Calling & History ──" });
|
||||
page.Widgets.Add(new Label
|
||||
{
|
||||
Text = $" {_class?.Name ?? "—"} (d{_class?.HitDie ?? 0} · {string.Join("/", _class?.PrimaryAbility ?? new string[0])})\n Background: {_background?.Name ?? "—"}",
|
||||
});
|
||||
page.Widgets.Add(MakeEditLink("Edit Calling ›", 2));
|
||||
|
||||
// Final abilities
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
page.Widgets.Add(new Label { Text = "── Final Abilities ──" });
|
||||
page.Widgets.Add(new Label { Text = " " + FormatAbilityStrip() });
|
||||
page.Widgets.Add(MakeEditLink("Edit Abilities ›", 4));
|
||||
|
||||
// Skills
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
page.Widgets.Add(new Label { Text = "── Skills ──" });
|
||||
page.Widgets.Add(new Label { Text = " " + FormatSkillSummary() });
|
||||
page.Widgets.Add(MakeEditLink("Edit Skills ›", 5));
|
||||
|
||||
// Starting kit
|
||||
page.Widgets.Add(new Label { Text = " " });
|
||||
page.Widgets.Add(new Label { Text = "── Starting Kit ──" });
|
||||
if (_class?.StartingKit is null || _class.StartingKit.Length == 0)
|
||||
page.Widgets.Add(new Label { Text = " (no kit configured)" });
|
||||
else
|
||||
{
|
||||
foreach (var entry in _class.StartingKit)
|
||||
{
|
||||
string equipTag = entry.AutoEquip ? $" [equipped: {entry.EquipSlot}]" : "";
|
||||
string qtyTag = entry.Qty > 1 ? $" ×{entry.Qty}" : "";
|
||||
page.Widgets.Add(new Label { Text = $" • {CodexCopy.ItemName(entry.ItemId)}{qtyTag}{equipTag}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TextButton MakeEditLink(string text, int targetStep)
|
||||
{
|
||||
var btn = new TextButton { Text = text, Padding = new Thickness(6, 2, 6, 2) };
|
||||
btn.Click += (_, _) => { _step = targetStep; Rebuild(); };
|
||||
return btn;
|
||||
}
|
||||
|
||||
// ── State helpers ────────────────────────────────────────────────────
|
||||
|
||||
private void InitStandardArrayPool()
|
||||
{
|
||||
_statPool.Clear();
|
||||
foreach (int v in AbilityScores.StandardArray) _statPool.Add(v);
|
||||
_statAssign.Clear();
|
||||
_pendingPoolIdx = null;
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
|
||||
private void AutoAssignByClassPriority()
|
||||
{
|
||||
var primary = _class?.PrimaryAbility ?? System.Array.Empty<string>();
|
||||
var order = new 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();
|
||||
// Honour any already-pinned abilities; fill the rest from the pool.
|
||||
var emptyAbilities = new 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];
|
||||
}
|
||||
// Rebuild the pool from leftovers.
|
||||
_statPool.Clear();
|
||||
for (int i = emptyAbilities.Count; i < available.Count; i++) _statPool.Add(available[i]);
|
||||
_pendingPoolIdx = null;
|
||||
}
|
||||
|
||||
private void ClearAssignments()
|
||||
{
|
||||
foreach (var v in _statAssign.Values) _statPool.Add(v);
|
||||
_statAssign.Clear();
|
||||
_pendingPoolIdx = null;
|
||||
}
|
||||
|
||||
private void AutoPickSkills()
|
||||
{
|
||||
_chosenSkills.Clear();
|
||||
if (_class is null) return;
|
||||
int n = _class.SkillsChoose;
|
||||
foreach (var raw in _class.SkillOptions)
|
||||
{
|
||||
if (_chosenSkills.Count >= n) break;
|
||||
try { _chosenSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { /* unknown */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowDetail(string title, string body)
|
||||
{
|
||||
_detailTitle = title ?? "";
|
||||
_detailBody = body ?? "";
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────────
|
||||
|
||||
private 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;
|
||||
}
|
||||
|
||||
private bool AllStepsValid()
|
||||
{
|
||||
for (int i = 0; i < StepNames.Length; i++) if (ValidateStep(i) is not null) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Confirm ──────────────────────────────────────────────────────────
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// ── Formatters ───────────────────────────────────────────────────────
|
||||
|
||||
private string FormatAbilityStrip()
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var ab in CodexCopy.AbilityOrder)
|
||||
{
|
||||
int? assigned = _statAssign.TryGetValue(ab, out var v) ? v : null;
|
||||
int cladeMod = ModFromDict(_clade?.AbilityMods, ab);
|
||||
int speciesMod = ModFromDict(_species?.AbilityMods, ab);
|
||||
if (assigned is null) { parts.Add($"{ab} —"); continue; }
|
||||
int finalScore = assigned.Value + cladeMod + speciesMod;
|
||||
parts.Add($"{ab} {finalScore}({CodexCopy.Signed(AbilityScores.Mod(finalScore))})");
|
||||
}
|
||||
return string.Join(" ", parts);
|
||||
}
|
||||
|
||||
private string FormatSkillSummary()
|
||||
{
|
||||
var skills = new List<string>();
|
||||
if (_background is not null)
|
||||
foreach (var s in _background.SkillProficiencies) skills.Add(CodexCopy.SkillName(s) + "*");
|
||||
foreach (var s in _chosenSkills.OrderBy(x => x.ToString())) skills.Add(s.ToString());
|
||||
if (skills.Count == 0) return "(none yet)";
|
||||
return string.Join(", ", skills) + " (* = sealed by background)";
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
}
|
||||
|
||||
private 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",
|
||||
};
|
||||
|
||||
/// <summary>Soft word-wrap for the detail-panel body. Splits on spaces; crude but adequate.</summary>
|
||||
private static string WordWrap(string text, int maxCols)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return "";
|
||||
var sb = new System.Text.StringBuilder();
|
||||
int col = 0;
|
||||
foreach (var word in text.Split(' '))
|
||||
{
|
||||
if (col + word.Length + 1 > maxCols) { sb.Append('\n'); sb.Append(" "); col = 2; }
|
||||
else if (col > 2) { sb.Append(' '); col++; }
|
||||
else if (col == 0) { sb.Append(" "); col = 2; }
|
||||
sb.Append(word);
|
||||
col += word.Length;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
if (Keyboard.GetState().IsKeyDown(Keys.Escape)) _game.Screens.Pop();
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch sb)
|
||||
{
|
||||
_game.GraphicsDevice.Clear(new Color(15, 15, 25));
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Entities.Ai;
|
||||
using Theriapolis.Core.Persistence;
|
||||
using Theriapolis.Core.Rules.Combat;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M5 turn-based combat overlay. Pushed by PlayScreen when an
|
||||
/// encounter triggers; owns the live <see cref="Encounter"/>, drives input
|
||||
/// on the player's turn, ticks NPC behaviors on theirs, and on victory
|
||||
/// writes results back to the live actors and pops itself.
|
||||
///
|
||||
/// Player input (during player's turn):
|
||||
/// WASD / arrows → move 1 tactical tile (5 ft. of movement budget)
|
||||
/// SPACE → attack closest hostile in reach
|
||||
/// ENTER → end turn
|
||||
///
|
||||
/// Save-anywhere works mid-combat: PlayScreen.CaptureBody calls
|
||||
/// <see cref="SnapshotForSave"/>; on load, PlayScreen re-pushes this screen
|
||||
/// with the rebuilt encounter via the rehydrate constructor.
|
||||
/// </summary>
|
||||
public sealed class CombatHUDScreen : IScreen
|
||||
{
|
||||
private readonly Encounter _encounter;
|
||||
private readonly ActorManager _actors;
|
||||
private readonly Theriapolis.Core.Data.ContentResolver? _content;
|
||||
private readonly System.Action<EncounterEndResult> _onEnd;
|
||||
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private Label? _initLabel;
|
||||
private Label? _logLabel;
|
||||
private Label? _actionLabel;
|
||||
|
||||
// NPC turn pacing — instant-resolve NPC turns frame-by-frame so the log scrolls.
|
||||
private float _npcTurnDelay;
|
||||
private const float NPC_TURN_SECONDS = 0.4f;
|
||||
|
||||
// Edge-detect input.
|
||||
private bool _spaceWas, _enterWas, _wWas, _aWas, _sWas, _dWas;
|
||||
private bool _upWas, _downWas, _leftWas, _rightWas;
|
||||
private bool _rWas, _tWas;
|
||||
// Phase 6.5 M1 — class feature hotkeys: H = heal (Field Repair / Lay on
|
||||
// Paws), V = vocalize (Vocalization Dice).
|
||||
private bool _hWas, _vWas;
|
||||
// Phase 6.5 M3 — P = pheromone (Scent-Broker), O = oath (Covenant-Keeper).
|
||||
private bool _pWas, _oWas;
|
||||
|
||||
public Encounter Encounter => _encounter;
|
||||
public bool IsOver => _encounter.IsOver;
|
||||
|
||||
/// <summary>Build a fresh encounter from the supplied participants and push the HUD.</summary>
|
||||
public CombatHUDScreen(
|
||||
Encounter encounter,
|
||||
ActorManager actors,
|
||||
System.Action<EncounterEndResult> onEnd,
|
||||
Theriapolis.Core.Data.ContentResolver? content = null)
|
||||
{
|
||||
_encounter = encounter ?? throw new System.ArgumentNullException(nameof(encounter));
|
||||
_actors = actors ?? throw new System.ArgumentNullException(nameof(actors));
|
||||
_onEnd = onEnd ?? throw new System.ArgumentNullException(nameof(onEnd));
|
||||
_content = content;
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
BuildUI();
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Bottom,
|
||||
Padding = new Thickness(12, 8, 12, 8),
|
||||
Background = new SolidBrush(new Color(0, 0, 0, 200)),
|
||||
};
|
||||
|
||||
_initLabel = new Label { Text = "" };
|
||||
root.Widgets.Add(_initLabel);
|
||||
|
||||
_logLabel = new Label { Text = "" };
|
||||
root.Widgets.Add(_logLabel);
|
||||
|
||||
_actionLabel = new Label { Text = "WASD: move · SPACE: attack · ENTER: end turn" };
|
||||
root.Widgets.Add(_actionLabel);
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
Refresh();
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
if (_encounter.IsOver) { Resolve(); return; }
|
||||
|
||||
var actor = _encounter.CurrentActor;
|
||||
if (actor.IsDown)
|
||||
{
|
||||
// Phase 5 M6: player death-save loop. Roll once at the start of
|
||||
// the player's turn while at 0 HP, then end turn. NPC combatants
|
||||
// skip this and go straight to EndTurn (they're removed from
|
||||
// initiative since IsAlive is false).
|
||||
if (actor.SourceCharacter is not null && actor.DeathSaves is not null)
|
||||
{
|
||||
var outcome = actor.DeathSaves.Roll(_encounter, actor);
|
||||
if (outcome == Theriapolis.Core.Rules.Combat.DeathSaveOutcome.Dead)
|
||||
{
|
||||
PushDefeated(actor.Name + " fell to a final-blow death save.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
_encounter.EndTurn();
|
||||
Refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find this combatant's live actor (by id) so we can dispatch behavior or read input.
|
||||
var liveActor = FindLiveActor(actor.Id);
|
||||
if (liveActor is NpcActor npc)
|
||||
{
|
||||
_npcTurnDelay += (float)gt.ElapsedGameTime.TotalSeconds;
|
||||
if (_npcTurnDelay < NPC_TURN_SECONDS) return;
|
||||
_npcTurnDelay = 0f;
|
||||
var ctx = new AiContext(_encounter);
|
||||
BehaviorRegistry.For(npc.BehaviorId).TakeTurn(actor, ctx);
|
||||
_encounter.EndTurn();
|
||||
Refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Player turn — wait for input.
|
||||
DrivePlayerTurn(gt);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void DrivePlayerTurn(GameTime gt)
|
||||
{
|
||||
var ks = Keyboard.GetState();
|
||||
bool space = JustPressed(ks, Keys.Space, ref _spaceWas);
|
||||
bool enter = JustPressed(ks, Keys.Enter, ref _enterWas);
|
||||
bool w = JustPressed(ks, Keys.W, ref _wWas);
|
||||
bool a = JustPressed(ks, Keys.A, ref _aWas);
|
||||
bool s = JustPressed(ks, Keys.S, ref _sWas);
|
||||
bool d = JustPressed(ks, Keys.D, ref _dWas);
|
||||
bool up = JustPressed(ks, Keys.Up, ref _upWas);
|
||||
bool down = JustPressed(ks, Keys.Down, ref _downWas);
|
||||
bool left = JustPressed(ks, Keys.Left, ref _leftWas);
|
||||
bool right = JustPressed(ks, Keys.Right, ref _rightWas);
|
||||
|
||||
int dx = 0, dy = 0;
|
||||
if (w || up) dy = -1;
|
||||
if (s || down) dy = +1;
|
||||
if (a || left) dx = -1;
|
||||
if (d || right) dx = +1;
|
||||
if (dx != 0 || dy != 0) TryMovePlayer(dx, dy);
|
||||
if (space) TryAttack();
|
||||
if (enter) { _encounter.EndTurn(); Refresh(); }
|
||||
|
||||
// Phase 5 M6: class-feature toggles.
|
||||
bool r = JustPressed(ks, Keys.R, ref _rWas);
|
||||
bool t = JustPressed(ks, Keys.T, ref _tWas);
|
||||
if (r) TryToggleRage();
|
||||
if (t) TryToggleSentinelStance();
|
||||
|
||||
// Phase 6.5 M1: heal + vocalize hotkeys. H prefers Lay on Paws when
|
||||
// available (Covenant-Keeper); falls through to Field Repair
|
||||
// (Claw-Wright). V grants a Vocalization Die to the closest ally.
|
||||
bool h = JustPressed(ks, Keys.H, ref _hWas);
|
||||
bool v = JustPressed(ks, Keys.V, ref _vWas);
|
||||
if (h) TryHealAction();
|
||||
if (v) TryVocalize();
|
||||
|
||||
// Phase 6.5 M3: pheromone + oath hotkeys. P emits a Fear pheromone
|
||||
// (the most universally useful default; future UI can let the
|
||||
// player pick the type). O declares an oath against the closest
|
||||
// hostile.
|
||||
bool p = JustPressed(ks, Keys.P, ref _pWas);
|
||||
bool o = JustPressed(ks, Keys.O, ref _oWas);
|
||||
if (p) TryEmitPheromone();
|
||||
if (o) TryDeclareOath();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M3 — Scent-Broker Pheromone Craft hotkey. Bonus action;
|
||||
/// emits a Fear pheromone in 10-ft radius. Hostiles in range CON-save
|
||||
/// or get Frightened. Future UI iteration can offer a type picker.
|
||||
/// </summary>
|
||||
private void TryEmitPheromone()
|
||||
{
|
||||
var actor = _encounter.CurrentActor;
|
||||
var c = actor.SourceCharacter;
|
||||
if (c is null || c.ClassDef.Id != "scent_broker") return;
|
||||
if (c.Level < 2)
|
||||
{
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{actor.Name}: Pheromone Craft unlocks at level 2.");
|
||||
return;
|
||||
}
|
||||
if (c.PheromoneUsesRemaining <= 0)
|
||||
{
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{actor.Name}: no Pheromone Craft uses remaining.");
|
||||
return;
|
||||
}
|
||||
if (Theriapolis.Core.Rules.Combat.FeatureProcessor.TryEmitPheromone(
|
||||
_encounter, actor, Theriapolis.Core.Rules.Combat.PheromoneType.Fear))
|
||||
_encounter.CurrentTurn.ConsumeBonusAction();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M3 — Covenant-Keeper Covenant's Authority hotkey. Bonus
|
||||
/// action; declares an oath against the closest hostile, inflicting
|
||||
/// -2 to attack rolls vs. the Covenant-Keeper for 10 rounds.
|
||||
/// </summary>
|
||||
private void TryDeclareOath()
|
||||
{
|
||||
var actor = _encounter.CurrentActor;
|
||||
var c = actor.SourceCharacter;
|
||||
if (c is null || c.ClassDef.Id != "covenant_keeper") return;
|
||||
if (c.Level < 2)
|
||||
{
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{actor.Name}: Covenant's Authority unlocks at level 2.");
|
||||
return;
|
||||
}
|
||||
var aiCtx = new Theriapolis.Core.Entities.Ai.AiContext(_encounter);
|
||||
var target = aiCtx.FindClosestHostile(actor);
|
||||
if (target is null)
|
||||
{
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{actor.Name}: no hostile target in sight.");
|
||||
return;
|
||||
}
|
||||
if (Theriapolis.Core.Rules.Combat.FeatureProcessor.TryDeclareOath(
|
||||
_encounter, actor, target))
|
||||
_encounter.CurrentTurn.ConsumeBonusAction();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M1 — heal hotkey. Auto-targets the most-damaged friendly
|
||||
/// (self or ally). Uses Lay on Paws (Covenant-Keeper) when there's pool
|
||||
/// remaining, else falls through to Field Repair (Claw-Wright).
|
||||
/// Consumes the action and a bonus action where appropriate. No-op for
|
||||
/// non-healer classes.
|
||||
/// </summary>
|
||||
private void TryHealAction()
|
||||
{
|
||||
if (!_encounter.CurrentTurn.ActionAvailable) return;
|
||||
var actor = _encounter.CurrentActor;
|
||||
var c = actor.SourceCharacter;
|
||||
if (c is null) return;
|
||||
var aiCtx = new Theriapolis.Core.Entities.Ai.AiContext(_encounter);
|
||||
var target = aiCtx.FindMostDamagedFriendly(actor) ?? actor;
|
||||
|
||||
bool acted = false;
|
||||
if (c.ClassDef.Id == "covenant_keeper" && c.LayOnPawsPoolRemaining > 0)
|
||||
{
|
||||
// Spend up to 5 (or whatever's needed to top up the target) per
|
||||
// press — keeps the no-target-picker UX quick to use repeatedly.
|
||||
int request = System.Math.Max(1, target.MaxHp - target.CurrentHp);
|
||||
if (request > 5) request = 5;
|
||||
acted = Theriapolis.Core.Rules.Combat.FeatureProcessor.TryLayOnPaws(_encounter, actor, target, request);
|
||||
}
|
||||
else if (c.ClassDef.Id == "claw_wright" && c.FieldRepairUsesRemaining > 0)
|
||||
{
|
||||
acted = Theriapolis.Core.Rules.Combat.FeatureProcessor.TryFieldRepair(_encounter, actor, target);
|
||||
}
|
||||
else
|
||||
{
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{actor.Name} has no heal action available.");
|
||||
}
|
||||
if (acted) _encounter.CurrentTurn.ConsumeAction();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M1 — Vocalization Dice. Bonus action for Muzzle-Speakers;
|
||||
/// auto-targets the closest ally (excludes self). No-op when no ally is
|
||||
/// in combat (the typical M1 case — the player is alone).
|
||||
/// </summary>
|
||||
private void TryVocalize()
|
||||
{
|
||||
var actor = _encounter.CurrentActor;
|
||||
var c = actor.SourceCharacter;
|
||||
if (c is null || c.ClassDef.Id != "muzzle_speaker") return;
|
||||
if (c.VocalizationDiceRemaining <= 0)
|
||||
{
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{actor.Name}: no Vocalization Dice remaining.");
|
||||
return;
|
||||
}
|
||||
var aiCtx = new Theriapolis.Core.Entities.Ai.AiContext(_encounter);
|
||||
var ally = aiCtx.FindClosestAlly(actor);
|
||||
if (ally is null)
|
||||
{
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{actor.Name}: no ally in range to inspire.");
|
||||
return;
|
||||
}
|
||||
if (Theriapolis.Core.Rules.Combat.FeatureProcessor.TryGrantVocalizationDie(_encounter, actor, ally))
|
||||
_encounter.CurrentTurn.ConsumeBonusAction();
|
||||
}
|
||||
|
||||
private void TryToggleRage()
|
||||
{
|
||||
var actor = _encounter.CurrentActor;
|
||||
if (actor.RageActive)
|
||||
{
|
||||
actor.RageActive = false;
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note, $"{actor.Name} ends the rage.");
|
||||
return;
|
||||
}
|
||||
if (!FeatureProcessor.TryActivateRage(_encounter, actor))
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note, $"{actor.Name} can't enter rage right now.");
|
||||
_encounter.CurrentTurn.ConsumeBonusAction();
|
||||
}
|
||||
|
||||
private void TryToggleSentinelStance()
|
||||
{
|
||||
var actor = _encounter.CurrentActor;
|
||||
FeatureProcessor.ToggleSentinelStance(_encounter, actor);
|
||||
_encounter.CurrentTurn.ConsumeBonusAction();
|
||||
}
|
||||
|
||||
private void TryMovePlayer(int dx, int dy)
|
||||
{
|
||||
if (_encounter.CurrentTurn.RemainingMovementFt < 5) return;
|
||||
var actor = _encounter.CurrentActor;
|
||||
var newPos = new Vec2((int)actor.Position.X + dx, (int)actor.Position.Y + dy);
|
||||
actor.Position = newPos;
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Move,
|
||||
$"{actor.Name} moves to ({(int)newPos.X},{(int)newPos.Y}).");
|
||||
_encounter.CurrentTurn.ConsumeMovement(5);
|
||||
}
|
||||
|
||||
private void TryAttack()
|
||||
{
|
||||
if (!_encounter.CurrentTurn.ActionAvailable) return;
|
||||
var actor = _encounter.CurrentActor;
|
||||
var ctx = new AiContext(_encounter);
|
||||
var target = ctx.FindClosestHostile(actor);
|
||||
if (target is null) return;
|
||||
var attack = actor.AttackOptions[0];
|
||||
if (!ReachAndCover.IsInReach(actor, target, attack))
|
||||
{
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{actor.Name}: target out of reach.");
|
||||
return;
|
||||
}
|
||||
Resolver.AttemptAttack(_encounter, actor, target, attack);
|
||||
_encounter.CurrentTurn.ConsumeAction();
|
||||
}
|
||||
|
||||
private static bool JustPressed(KeyboardState ks, Keys k, ref bool was)
|
||||
{
|
||||
bool now = ks.IsKeyDown(k);
|
||||
bool jp = now && !was;
|
||||
was = now;
|
||||
return jp;
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (_initLabel is not null)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append($"R{_encounter.RoundNumber} ");
|
||||
for (int i = 0; i < _encounter.InitiativeOrder.Count; i++)
|
||||
{
|
||||
var c = _encounter.Participants[_encounter.InitiativeOrder[i]];
|
||||
if (i == _encounter.CurrentTurnIndex) sb.Append("→");
|
||||
sb.Append($"[{c.Name} {c.CurrentHp}/{c.MaxHp}] ");
|
||||
}
|
||||
_initLabel.Text = sb.ToString();
|
||||
}
|
||||
if (_logLabel is not null)
|
||||
{
|
||||
int start = System.Math.Max(0, _encounter.Log.Count - 6);
|
||||
var sb = new System.Text.StringBuilder();
|
||||
for (int i = start; i < _encounter.Log.Count; i++)
|
||||
{
|
||||
var e = _encounter.Log[i];
|
||||
sb.AppendLine(e.Message);
|
||||
}
|
||||
_logLabel.Text = sb.ToString();
|
||||
}
|
||||
if (_actionLabel is not null)
|
||||
{
|
||||
var actor = _encounter.IsOver ? null : _encounter.CurrentActor;
|
||||
if (actor is null)
|
||||
_actionLabel.Text = "Encounter ended.";
|
||||
else if (FindLiveActor(actor.Id) is NpcActor)
|
||||
_actionLabel.Text = $"{actor.Name}'s turn (NPC) …";
|
||||
else
|
||||
{
|
||||
string featureHints = "";
|
||||
var c = actor.SourceCharacter;
|
||||
if (c is not null)
|
||||
{
|
||||
if (c.ClassDef.Id == "feral")
|
||||
featureHints += actor.RageActive
|
||||
? $" [Raging — R to end]"
|
||||
: $" [R: Rage ({c.RageUsesRemaining} left)]";
|
||||
if (c.ClassDef.Id == "bulwark")
|
||||
featureHints += actor.SentinelStanceActive
|
||||
? " [Stance — T to leave]"
|
||||
: " [T: Sentinel Stance]";
|
||||
// Phase 6.5 M1 hotkey hints.
|
||||
if (c.ClassDef.Id == "covenant_keeper" && c.LayOnPawsPoolRemaining > 0)
|
||||
featureHints += $" [H: Lay on Paws ({c.LayOnPawsPoolRemaining} HP)]";
|
||||
if (c.ClassDef.Id == "claw_wright" && c.FieldRepairUsesRemaining > 0)
|
||||
featureHints += $" [H: Field Repair ({c.FieldRepairUsesRemaining})]";
|
||||
if (c.ClassDef.Id == "muzzle_speaker" && c.VocalizationDiceRemaining > 0)
|
||||
featureHints += $" [V: Vocalize ({c.VocalizationDiceRemaining})]";
|
||||
// Phase 6.5 M3 hotkey hints.
|
||||
if (c.ClassDef.Id == "scent_broker" && c.Level >= 2 && c.PheromoneUsesRemaining > 0)
|
||||
featureHints += $" [P: Pheromone ({c.PheromoneUsesRemaining})]";
|
||||
if (c.ClassDef.Id == "covenant_keeper" && c.Level >= 2 && c.CovenantAuthorityUsesRemaining > 0)
|
||||
featureHints += $" [O: Oath ({c.CovenantAuthorityUsesRemaining})]";
|
||||
}
|
||||
_actionLabel.Text = $"{actor.Name}'s turn — WASD: move ({_encounter.CurrentTurn.RemainingMovementFt}ft left) · SPACE: attack · ENTER: end turn{featureHints}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Actor? FindLiveActor(int id)
|
||||
{
|
||||
foreach (var a in _actors.All)
|
||||
if (a.Id == id) return a;
|
||||
return null;
|
||||
}
|
||||
|
||||
private void Resolve()
|
||||
{
|
||||
// Write combatant state back to live actors and remove dead NPCs.
|
||||
// Phase 5 M6: roll loot per killed NPC and auto-pickup into player
|
||||
// inventory. Loot RNG is a sub-stream of the encounter seed so save+load
|
||||
// round-trips produce identical drops.
|
||||
var killedByChunk = new Dictionary<Theriapolis.Core.Tactical.ChunkCoord, List<int>>();
|
||||
var pickedUp = new List<(string Name, int Qty)>();
|
||||
var lootRng = _content is null
|
||||
? null
|
||||
: new Theriapolis.Core.Util.SeededRng(_encounter.EncounterSeed ^ Theriapolis.Core.C.RNG_LOOT);
|
||||
Theriapolis.Core.Items.Inventory? playerInv = null;
|
||||
int xpEarned = 0; // Phase 6.5 M0 — sum of killed-NPC XpAward values; awarded to player below.
|
||||
|
||||
foreach (var c in _encounter.Participants)
|
||||
{
|
||||
var live = FindLiveActor(c.Id);
|
||||
if (live is NpcActor npc)
|
||||
{
|
||||
npc.CurrentHp = c.CurrentHp;
|
||||
npc.Position = c.Position;
|
||||
if (c.IsDown)
|
||||
{
|
||||
if (npc.SourceChunk is { } chunk && npc.SourceSpawnIndex is int idx)
|
||||
{
|
||||
if (!killedByChunk.TryGetValue(chunk, out var list))
|
||||
killedByChunk[chunk] = list = new List<int>();
|
||||
list.Add(idx);
|
||||
}
|
||||
// Auto-pickup loot into player inventory. Residents
|
||||
// (Phase 6 M1) don't have a loot table — only Phase 5
|
||||
// hostiles do.
|
||||
if (lootRng is not null && _content is not null && npc.Template is not null)
|
||||
{
|
||||
var drops = Theriapolis.Core.Loot.LootRoller.Roll(
|
||||
npc.Template.LootTable, _content.LootTables, _content.Items, lootRng);
|
||||
foreach (var d in drops)
|
||||
{
|
||||
playerInv ??= _actors.Player?.Character?.Inventory;
|
||||
if (playerInv is null) break;
|
||||
playerInv.Add(d.Def, d.Qty);
|
||||
pickedUp.Add((d.Def.Name, d.Qty));
|
||||
}
|
||||
}
|
||||
// Phase 6.5 M0 — award XP for the kill. Templates' XpAward
|
||||
// was loaded since Phase 5 but never consumed; this is
|
||||
// the wiring.
|
||||
if (npc.Template is not null && npc.Template.XpAward > 0)
|
||||
xpEarned += npc.Template.XpAward;
|
||||
_actors.RemoveActor(npc.Id);
|
||||
}
|
||||
}
|
||||
else if (live is PlayerActor pa && pa.Character is not null)
|
||||
{
|
||||
pa.Character.CurrentHp = c.CurrentHp;
|
||||
pa.Position = c.Position;
|
||||
pa.Character.Conditions.Clear();
|
||||
foreach (var cond in c.Conditions) pa.Character.Conditions.Add(cond);
|
||||
}
|
||||
}
|
||||
|
||||
if (pickedUp.Count > 0)
|
||||
{
|
||||
string lootLine = string.Join(", ", pickedUp.Select(p => p.Qty > 1 ? $"{p.Name} ×{p.Qty}" : p.Name));
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note, "Picked up: " + lootLine);
|
||||
}
|
||||
|
||||
// Phase 6.5 M0 — award accumulated combat XP to the player.
|
||||
if (xpEarned > 0)
|
||||
{
|
||||
var pcChar = _actors.Player?.Character;
|
||||
if (pcChar is not null)
|
||||
{
|
||||
pcChar.Xp += xpEarned;
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note, $"+{xpEarned} XP.");
|
||||
if (Theriapolis.Core.Rules.Character.LevelUpFlow.CanLevelUp(pcChar))
|
||||
_encounter.AppendLog(CombatLogEntry.Kind.Note, "Level up available — open the pause menu.");
|
||||
}
|
||||
}
|
||||
|
||||
var result = new EncounterEndResult
|
||||
{
|
||||
Killed = killedByChunk,
|
||||
PlayerSurvived = _encounter.Participants.Any(c =>
|
||||
c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player && !c.IsDown),
|
||||
};
|
||||
_onEnd(result);
|
||||
_game.Screens.Pop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the encounter for save-anywhere. PlayScreen.CaptureBody
|
||||
/// calls this and stores the result in <c>SaveBody.ActiveEncounter</c>.
|
||||
/// </summary>
|
||||
private void PushDefeated(string cause)
|
||||
{
|
||||
// Pop ourselves so the play-screen sits underneath; then push the
|
||||
// DefeatedScreen which the player can dismiss to return to title.
|
||||
_onEnd(new EncounterEndResult { PlayerSurvived = false });
|
||||
_game.Screens.Pop();
|
||||
_game.Screens.Push(new DefeatedScreen(cause));
|
||||
}
|
||||
|
||||
public EncounterState SnapshotForSave()
|
||||
{
|
||||
var snaps = new CombatantSnapshot[_encounter.Participants.Count];
|
||||
for (int i = 0; i < _encounter.Participants.Count; i++)
|
||||
{
|
||||
var c = _encounter.Participants[i];
|
||||
var snap = new CombatantSnapshot
|
||||
{
|
||||
Id = c.Id,
|
||||
Name = c.Name,
|
||||
IsPlayer = c.SourceCharacter is not null,
|
||||
CurrentHp = c.CurrentHp,
|
||||
PositionX = c.Position.X,
|
||||
PositionY = c.Position.Y,
|
||||
Conditions = c.Conditions.Select(x => (byte)x).ToArray(),
|
||||
};
|
||||
if (c.SourceTemplate is not null)
|
||||
{
|
||||
snap.NpcTemplateId = c.SourceTemplate.Id;
|
||||
var live = FindLiveActor(c.Id);
|
||||
if (live is NpcActor npc)
|
||||
{
|
||||
snap.NpcChunkX = npc.SourceChunk?.X;
|
||||
snap.NpcChunkY = npc.SourceChunk?.Y;
|
||||
snap.NpcSpawnIndex = npc.SourceSpawnIndex;
|
||||
}
|
||||
}
|
||||
snaps[i] = snap;
|
||||
}
|
||||
var initOrder = new int[_encounter.InitiativeOrder.Count];
|
||||
for (int i = 0; i < initOrder.Length; i++) initOrder[i] = _encounter.InitiativeOrder[i];
|
||||
return new EncounterState
|
||||
{
|
||||
EncounterId = _encounter.EncounterId,
|
||||
RollCount = _encounter.RollCount,
|
||||
CurrentTurnIndex = _encounter.CurrentTurnIndex,
|
||||
RoundNumber = _encounter.RoundNumber,
|
||||
InitiativeOrder = initOrder,
|
||||
Combatants = snaps,
|
||||
};
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch sb)
|
||||
{
|
||||
// Don't clear — let the play-screen's last frame stay visible underneath.
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reported back to PlayScreen when an encounter wraps so it can update
|
||||
/// the chunk roster delta + decide whether to push the death screen.
|
||||
/// </summary>
|
||||
public sealed class EncounterEndResult
|
||||
{
|
||||
public Dictionary<Theriapolis.Core.Tactical.ChunkCoord, List<int>> Killed { get; init; }
|
||||
= new();
|
||||
public bool PlayerSurvived { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Game.Platform;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M6: shown when the player's death-save loop fails (3 cumulative
|
||||
/// failures). Permadeath per the §9 resolved decision — the only option is
|
||||
/// "Return to Title". The autosave_combat slot persists, so the player can
|
||||
/// load it from the title screen and retry.
|
||||
/// </summary>
|
||||
public sealed class DefeatedScreen : IScreen
|
||||
{
|
||||
private readonly string _causeOfDeath;
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private bool _enterWas = true;
|
||||
private bool _escWas = true;
|
||||
|
||||
public DefeatedScreen(string causeOfDeath = "")
|
||||
{
|
||||
_causeOfDeath = causeOfDeath ?? "";
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 14,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Padding = new Thickness(50, 40, 50, 40),
|
||||
Background = new SolidBrush(new Color(20, 0, 0, 230)),
|
||||
};
|
||||
root.Widgets.Add(new Label { Text = "YOU HAVE DIED", HorizontalAlignment = HorizontalAlignment.Center });
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
if (!string.IsNullOrEmpty(_causeOfDeath))
|
||||
{
|
||||
root.Widgets.Add(new Label { Text = _causeOfDeath, HorizontalAlignment = HorizontalAlignment.Center });
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
}
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"Your last autosave is at slot \"{C.SAVE_SLOT_AUTOSAVE_COMBAT}\".",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = "Load from the title to retry the encounter.",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
var ret = new TextButton { Text = "Return to Title (ENTER)", Width = 280, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
ret.Click += (_, _) => ReturnToTitle();
|
||||
root.Widgets.Add(ret);
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
private void ReturnToTitle()
|
||||
{
|
||||
// Pop everything above the TitleScreen. Stack at this point is:
|
||||
// Title → CharacterCreation (popped) → WorldGenProgress (popped) →
|
||||
// PlayScreen → CombatHUD (popped earlier) → DefeatedScreen.
|
||||
// So we need to pop DefeatedScreen + PlayScreen = 2 pops.
|
||||
// ScreenManager.Pop is queue-based now, so multiple calls all apply.
|
||||
_game.Screens.Pop();
|
||||
_game.Screens.Pop();
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
var ks = Keyboard.GetState();
|
||||
bool enter = ks.IsKeyDown(Keys.Enter);
|
||||
bool esc = ks.IsKeyDown(Keys.Escape);
|
||||
bool enterPressed = enter && !_enterWas;
|
||||
bool escPressed = esc && !_escWas;
|
||||
_enterWas = enter; _escWas = esc;
|
||||
if (enterPressed || escPressed) ReturnToTitle();
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch sb)
|
||||
{
|
||||
// Don't clear — leave the play-screen's last frame underneath.
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// A single game screen (title, world map, tactical, etc.).
|
||||
/// Managed by ScreenManager as a push/pop stack.
|
||||
/// </summary>
|
||||
public interface IScreen
|
||||
{
|
||||
/// <summary>Called once when the screen is first pushed onto the stack.</summary>
|
||||
void Initialize(Game1 game);
|
||||
|
||||
/// <summary>Called every frame while this screen is active (top of stack).</summary>
|
||||
void Update(GameTime gameTime);
|
||||
|
||||
/// <summary>Called every frame to draw the screen.</summary>
|
||||
void Draw(GameTime gameTime, SpriteBatch spriteBatch);
|
||||
|
||||
/// <summary>Called when a screen is popped or another is pushed on top.</summary>
|
||||
void Deactivate();
|
||||
|
||||
/// <summary>Called when this screen comes back to the top of the stack.</summary>
|
||||
void Reactivate();
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Rules.Dialogue;
|
||||
using Theriapolis.Core.Rules.Reputation;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M3 — full dialogue UI driven by <see cref="DialogueRunner"/>.
|
||||
/// Pushed by <see cref="PlayScreen"/> when the player presses F next to a
|
||||
/// friendly/neutral NPC.
|
||||
///
|
||||
/// Layout:
|
||||
/// - Speaker header: NPC name + role + bias profile + effective disposition
|
||||
/// - Scrollback: history of NPC lines, PC choices, narration (skill-check rolls)
|
||||
/// - Options: numbered, conditions evaluated each refresh
|
||||
/// - Footer: "(1-9 to choose · Esc to leave)"
|
||||
///
|
||||
/// On <c>open_shop</c> effect: pushes <see cref="ShopScreen"/>; resumes
|
||||
/// dialogue when shop closes.
|
||||
/// </summary>
|
||||
public sealed class InteractionScreen : IScreen
|
||||
{
|
||||
private readonly NpcActor _npc;
|
||||
private readonly ContentResolver? _content;
|
||||
private readonly PlayScreen? _playScreen;
|
||||
private DialogueRunner? _runner;
|
||||
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private VerticalStackPanel _root = null!;
|
||||
private VerticalStackPanel _historyPanel = null!;
|
||||
private VerticalStackPanel _optionsPanel = null!;
|
||||
private bool _escWasDown = true;
|
||||
private bool _fWasDown = true;
|
||||
private bool _enterWasDown = true;
|
||||
private readonly bool[] _numWasDown = new bool[10];
|
||||
|
||||
public InteractionScreen(NpcActor npc, ContentResolver? content = null, PlayScreen? playScreen = null)
|
||||
{
|
||||
_npc = npc ?? throw new System.ArgumentNullException(nameof(npc));
|
||||
_content = content;
|
||||
_playScreen = playScreen;
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
_runner = TryBuildRunner();
|
||||
BuildLayout();
|
||||
}
|
||||
|
||||
private DialogueRunner? TryBuildRunner()
|
||||
{
|
||||
if (_content is null || _playScreen is null) return null;
|
||||
var pc = _playScreen.PlayerCharacter();
|
||||
if (pc is null) return null;
|
||||
if (string.IsNullOrEmpty(_npc.DialogueId)) return null;
|
||||
if (!_content.Dialogues.TryGetValue(_npc.DialogueId, out var tree)) return null;
|
||||
|
||||
var ctx = new DialogueContext(_npc, pc, _playScreen.Reputation, _playScreen.Flags, _content)
|
||||
{
|
||||
PlayerWorldTileX = (int)(_playScreen.PlayerActorPosition().X / Theriapolis.Core.C.WORLD_TILE_PIXELS),
|
||||
PlayerWorldTileY = (int)(_playScreen.PlayerActorPosition().Y / Theriapolis.Core.C.WORLD_TILE_PIXELS),
|
||||
WorldClockSeconds = _playScreen.ClockSeconds(),
|
||||
};
|
||||
return new DialogueRunner(tree, ctx, _playScreen.WorldSeed());
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
_root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Padding = new Thickness(40, 24, 40, 24),
|
||||
Background = new SolidBrush(new Color(15, 12, 8, 235)),
|
||||
Width = 760,
|
||||
};
|
||||
|
||||
_root.Widgets.Add(BuildHeader());
|
||||
_historyPanel = new VerticalStackPanel { Spacing = 2, Width = 680 };
|
||||
_root.Widgets.Add(_historyPanel);
|
||||
_optionsPanel = new VerticalStackPanel { Spacing = 4, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
_root.Widgets.Add(_optionsPanel);
|
||||
_root.Widgets.Add(new Label
|
||||
{
|
||||
Text = "(1-9 to choose · Esc to leave · F also closes)",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(120, 110, 100),
|
||||
});
|
||||
|
||||
Refresh();
|
||||
_desktop = new Desktop { Root = _root };
|
||||
}
|
||||
|
||||
private Widget BuildHeader()
|
||||
{
|
||||
var header = new VerticalStackPanel { Spacing = 2, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
header.Widgets.Add(new Label
|
||||
{
|
||||
Text = _npc.DisplayName,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(255, 230, 170),
|
||||
});
|
||||
string roleLine = FormatRoleLine(_npc.RoleTag);
|
||||
if (!string.IsNullOrEmpty(roleLine))
|
||||
header.Widgets.Add(new Label { Text = roleLine, HorizontalAlignment = HorizontalAlignment.Center, TextColor = new Color(180, 160, 130) });
|
||||
|
||||
// Disposition footnote: profile + score + label.
|
||||
if (_content is not null && _playScreen?.PlayerCharacter() is { } pc)
|
||||
{
|
||||
var br = EffectiveDisposition.Breakdown(_npc, pc, _playScreen.Reputation, _content,
|
||||
_playScreen.World(), _playScreen.WorldSeed());
|
||||
string profile = _content.BiasProfiles.TryGetValue(_npc.BiasProfileId, out var bp) ? bp.Name : _npc.BiasProfileId;
|
||||
header.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"[{profile}] · {DispositionLabels.DisplayName(br.Label)} {br.Total:+#;-#;0}",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(120, 140, 180),
|
||||
});
|
||||
|
||||
// Phase 6.5 M1 — Scent Literacy overlay. Appears when the PC has
|
||||
// the level-1 Scent-Broker feature; surfaces NPC clade, species,
|
||||
// and HP%. ScentTags (Phase 6.5 M6) appear here when authored.
|
||||
string? scentLine = ScentReadingFor(_npc, pc);
|
||||
if (scentLine is not null)
|
||||
{
|
||||
header.Widgets.Add(new Label
|
||||
{
|
||||
Text = scentLine,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(180, 160, 200),
|
||||
});
|
||||
}
|
||||
}
|
||||
header.Widgets.Add(new Label { Text = " " });
|
||||
return header;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M1 — produce the Scent Literacy line for the dialogue
|
||||
/// header, or null if the PC doesn't have the feature. Scent Literacy
|
||||
/// is granted by the level-1 Scent-Broker entry in classes.json
|
||||
/// (<c>scent_literacy</c>) and tracked in
|
||||
/// <see cref="Theriapolis.Core.Rules.Character.Character.LearnedFeatureIds"/>
|
||||
/// after Phase 6.5 M0; for Phase-5-built characters that predate the
|
||||
/// LearnedFeatureIds wiring, fall back to a class-id check.
|
||||
///
|
||||
/// Phase 6.5 M6 — surfaces the top <see cref="Theriapolis.Core.Entities.ScentTag"/>
|
||||
/// from <see cref="NpcActor.ComputeScentTags"/>. Scent Mastery
|
||||
/// (<c>master_nose</c>, level 11) reads up to 3 tags; baseline Scent
|
||||
/// Literacy reads the top 1.
|
||||
/// </summary>
|
||||
private static string? ScentReadingFor(NpcActor npc, Theriapolis.Core.Rules.Character.Character pc)
|
||||
{
|
||||
bool hasFeature = pc.LearnedFeatureIds.Contains("scent_literacy")
|
||||
|| pc.ClassDef.Id == "scent_broker"; // L1-default fallback for legacy saves
|
||||
if (!hasFeature) return null;
|
||||
|
||||
string clade = npc.Resident?.Clade ?? npc.Template?.Behavior ?? "unknown";
|
||||
string species = npc.Resident?.Species ?? "—";
|
||||
// HP% from the live actor.
|
||||
int hpPct = npc.MaxHp > 0
|
||||
? (int)System.Math.Round(100.0 * npc.CurrentHp / npc.MaxHp)
|
||||
: 100;
|
||||
// Hide noise: NPCs we haven't damaged yet show "—" instead of 100% to
|
||||
// avoid "the innkeeper is at 100% HP" redundancy in flavour reads.
|
||||
string hp = hpPct == 100 ? "—" : $"{hpPct}%";
|
||||
|
||||
// Phase 6.5 M6 — Scent Mastery (master_nose) reads up to 3 tags;
|
||||
// baseline Scent Literacy reads the top 1.
|
||||
int tagCount = pc.LearnedFeatureIds.Contains("master_nose") ? 3 : 1;
|
||||
var tags = npc.ComputeScentTags(tagCount);
|
||||
string tagSuffix = "";
|
||||
if (tags.Count > 0)
|
||||
{
|
||||
var rendered = tags.Select(t => "⚠ " + t.DisplayName());
|
||||
tagSuffix = " · " + string.Join(" · ", rendered);
|
||||
}
|
||||
|
||||
return $"⊙ Scent: {Capitalize(clade)} ({Capitalize(species)}) · HP {hp}{tagSuffix}";
|
||||
}
|
||||
|
||||
private static string Capitalize(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return s;
|
||||
return char.ToUpperInvariant(s[0]) + s[1..].Replace('_', ' ');
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
_historyPanel.Widgets.Clear();
|
||||
_optionsPanel.Widgets.Clear();
|
||||
|
||||
if (_runner is null)
|
||||
{
|
||||
_historyPanel.Widgets.Add(new Label
|
||||
{
|
||||
Text = "(They have nothing to say yet.)",
|
||||
TextColor = new Color(180, 180, 180),
|
||||
});
|
||||
_historyPanel.Widgets.Add(new Label
|
||||
{
|
||||
Text = "— No dialogue tree authored for this NPC yet. (Phase 6 M3 ships generic_merchant/villager/guard.)",
|
||||
TextColor = new Color(120, 110, 100),
|
||||
Wrap = true,
|
||||
});
|
||||
var close = new TextButton
|
||||
{
|
||||
Text = "1. Goodbye",
|
||||
Width = 240,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
close.Click += (_, _) => _game.Screens.Pop();
|
||||
_optionsPanel.Widgets.Add(close);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render history (last DIALOGUE_HISTORY_LINES entries).
|
||||
int start = System.Math.Max(0, _runner.History.Count - Theriapolis.Core.C.DIALOGUE_HISTORY_LINES);
|
||||
for (int i = start; i < _runner.History.Count; i++)
|
||||
{
|
||||
var entry = _runner.History[i];
|
||||
string prefix = entry.Speaker switch
|
||||
{
|
||||
DialogueSpeaker.Pc => " > ",
|
||||
DialogueSpeaker.Narration => " ",
|
||||
_ => "",
|
||||
};
|
||||
Color color = entry.Speaker switch
|
||||
{
|
||||
DialogueSpeaker.Npc => new Color(220, 220, 200),
|
||||
DialogueSpeaker.Pc => new Color(170, 200, 220),
|
||||
DialogueSpeaker.Narration => new Color(160, 180, 140),
|
||||
_ => Color.White,
|
||||
};
|
||||
_historyPanel.Widgets.Add(new Label { Text = prefix + entry.Text, Wrap = true, Width = 680, TextColor = color });
|
||||
}
|
||||
|
||||
if (_runner.IsOver)
|
||||
{
|
||||
var close = new TextButton
|
||||
{
|
||||
Text = "1. (close)",
|
||||
Width = 240,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
close.Click += (_, _) => _game.Screens.Pop();
|
||||
_optionsPanel.Widgets.Add(close);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render options: number them by DISPLAY index (visible only).
|
||||
int displayN = 0;
|
||||
foreach (var (origIdx, opt) in _runner.VisibleOptions())
|
||||
{
|
||||
displayN++;
|
||||
int captured = origIdx;
|
||||
string label = $"{displayN}. {opt.Text}";
|
||||
if (opt.SkillCheck is { } sc)
|
||||
label = $"{displayN}. [{sc.Skill.ToUpperInvariant()} DC {sc.Dc}] {opt.Text}";
|
||||
var btn = new TextButton
|
||||
{
|
||||
Text = label,
|
||||
Width = 680,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
btn.Click += (_, _) => OnOptionPicked(captured);
|
||||
_optionsPanel.Widgets.Add(btn);
|
||||
if (displayN >= Theriapolis.Core.C.DIALOGUE_MAX_OPTIONS_PER_NODE) break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOptionPicked(int origIndex)
|
||||
{
|
||||
if (_runner is null) return;
|
||||
_runner.ChooseOption(origIndex);
|
||||
|
||||
// Phase 6 M4 — dialogue's start_quest effects buffer quest ids on
|
||||
// the runner context. Drain them into the live engine before
|
||||
// refreshing, so journal entries print in the right order.
|
||||
if (_playScreen is not null && _runner.Context.StartQuestRequests.Count > 0)
|
||||
{
|
||||
var qctx = _playScreen.BuildQuestContextForDialogue();
|
||||
if (qctx is not null)
|
||||
{
|
||||
foreach (var qid in _runner.Context.StartQuestRequests)
|
||||
_playScreen.QuestEngine.Start(qid, qctx);
|
||||
}
|
||||
_runner.Context.StartQuestRequests.Clear();
|
||||
}
|
||||
|
||||
Refresh();
|
||||
|
||||
// Effects may have flipped DialogueContext.ShopRequested. Push the
|
||||
// shop modal and clear the flag so re-entry doesn't loop.
|
||||
if (_runner.Context.ShopRequested
|
||||
&& _content is not null
|
||||
&& _playScreen?.PlayerCharacter() is { } pcChar)
|
||||
{
|
||||
_runner.Context.ShopRequested = false;
|
||||
_game.Screens.Push(new ShopScreen(_npc, pcChar, _content, _playScreen));
|
||||
}
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
var ks = Keyboard.GetState();
|
||||
bool esc = ks.IsKeyDown(Keys.Escape);
|
||||
bool f = ks.IsKeyDown(Keys.F);
|
||||
bool ent = ks.IsKeyDown(Keys.Enter);
|
||||
bool escPressed = esc && !_escWasDown;
|
||||
bool fPressed = f && !_fWasDown;
|
||||
bool entPressed = ent && !_enterWasDown;
|
||||
_escWasDown = esc; _fWasDown = f; _enterWasDown = ent;
|
||||
|
||||
if (escPressed || fPressed) { _game.Screens.Pop(); return; }
|
||||
|
||||
// Number-key option picks (edge-detected so a held key fires once).
|
||||
if (_runner is not null && !_runner.IsOver)
|
||||
{
|
||||
for (int n = 1; n <= 9; n++)
|
||||
{
|
||||
Keys k1 = (Keys)((int)Keys.D0 + n);
|
||||
Keys k2 = (Keys)((int)Keys.NumPad0 + n);
|
||||
bool down = ks.IsKeyDown(k1) || ks.IsKeyDown(k2);
|
||||
bool pressed = down && !_numWasDown[n];
|
||||
_numWasDown[n] = down;
|
||||
if (pressed)
|
||||
{
|
||||
HandleNumberPick(n);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (_runner is { IsOver: true } && entPressed)
|
||||
{
|
||||
_game.Screens.Pop();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleNumberPick(int displayN)
|
||||
{
|
||||
if (_runner is null) return;
|
||||
int seen = 0;
|
||||
foreach (var (origIdx, _) in _runner.VisibleOptions())
|
||||
{
|
||||
seen++;
|
||||
if (seen == displayN)
|
||||
{
|
||||
OnOptionPicked(origIdx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch sb) => _desktop.Render();
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { Refresh(); }
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static string FormatRoleLine(string roleTag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(roleTag)) return "";
|
||||
int dot = roleTag.LastIndexOf('.');
|
||||
if (dot < 0) return TitleCase(roleTag);
|
||||
string anchor = roleTag[..dot];
|
||||
string role = roleTag[(dot + 1)..];
|
||||
return $"{TitleCase(role)} of {TitleCase(anchor)}";
|
||||
}
|
||||
|
||||
private static string TitleCase(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return "";
|
||||
Span<char> buf = stackalloc char[raw.Length];
|
||||
bool capNext = true;
|
||||
for (int i = 0; i < raw.Length; i++)
|
||||
{
|
||||
char c = raw[i];
|
||||
if (c == '_' || c == '.') { buf[i] = ' '; capNext = true; continue; }
|
||||
buf[i] = capNext ? char.ToUpperInvariant(c) : c;
|
||||
capNext = false;
|
||||
}
|
||||
return new string(buf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M3 inventory screen. Pushed by <see cref="PlayScreen"/> when the
|
||||
/// player presses TAB. Two-column layout: equipped slots on the left, bagged
|
||||
/// items on the right. Click an equipped slot to unequip; click a bagged
|
||||
/// item to equip into its natural slot (weapon → main hand, armor → body,
|
||||
/// shield → off hand, enhancer → matching natural-weapon slot).
|
||||
///
|
||||
/// All mutations are direct on the character's <see cref="Inventory"/>;
|
||||
/// <see cref="Stats.DerivedStats"/> recomputes AC/Speed automatically on
|
||||
/// next read, so no signals or events are needed.
|
||||
/// </summary>
|
||||
public sealed class InventoryScreen : IScreen
|
||||
{
|
||||
private readonly Character _character;
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private Label? _statusLabel;
|
||||
private bool _tabWasDown = true;
|
||||
private bool _escWasDown = true;
|
||||
|
||||
public InventoryScreen(Character character)
|
||||
{
|
||||
_character = character ?? throw new System.ArgumentNullException(nameof(character));
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
BuildUI();
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(20),
|
||||
Padding = new Thickness(20, 12, 20, 12),
|
||||
Background = new SolidBrush(new Color(0, 0, 0, 220)),
|
||||
};
|
||||
|
||||
// Header
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"INVENTORY — {_character.Species.Name} {_character.ClassDef.Name} (Lv{_character.Level})",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = FormatStatLine(),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Two columns
|
||||
var columns = new HorizontalStackPanel { Spacing = 24 };
|
||||
|
||||
// Equipped column
|
||||
var equippedCol = new VerticalStackPanel { Spacing = 4, Width = 320 };
|
||||
equippedCol.Widgets.Add(new Label { Text = "EQUIPPED:" });
|
||||
foreach (EquipSlot slot in EquipSlotsToShow())
|
||||
equippedCol.Widgets.Add(BuildEquippedSlotRow(slot));
|
||||
columns.Widgets.Add(equippedCol);
|
||||
|
||||
// Inventory column
|
||||
var bagCol = new VerticalStackPanel { Spacing = 4, Width = 380 };
|
||||
bagCol.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"INVENTORY ({_character.Inventory.Items.Count} stacks, {_character.Inventory.TotalWeightLb:F1} lb):",
|
||||
});
|
||||
bool any = false;
|
||||
foreach (var item in _character.Inventory.Items)
|
||||
{
|
||||
// Skip equipped items in the bag list — they're shown in the equipped column.
|
||||
if (item.EquippedAt is not null) continue;
|
||||
any = true;
|
||||
bagCol.Widgets.Add(BuildBagItemRow(item));
|
||||
}
|
||||
if (!any)
|
||||
bagCol.Widgets.Add(new Label { Text = " (everything is equipped)" });
|
||||
columns.Widgets.Add(bagCol);
|
||||
|
||||
root.Widgets.Add(columns);
|
||||
|
||||
// Status line
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
_statusLabel = new Label { Text = "TAB or ESC to close.", HorizontalAlignment = HorizontalAlignment.Center };
|
||||
root.Widgets.Add(_statusLabel);
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
private Widget BuildEquippedSlotRow(EquipSlot slot)
|
||||
{
|
||||
var inst = _character.Inventory.GetEquipped(slot);
|
||||
string text = inst is null
|
||||
? $" {SlotLabel(slot),-18}—"
|
||||
: $" {SlotLabel(slot),-18}{inst.Def.Name}";
|
||||
var btn = new TextButton
|
||||
{
|
||||
Text = text,
|
||||
Width = 320,
|
||||
Padding = new Thickness(4, 2, 4, 2),
|
||||
};
|
||||
if (inst is not null)
|
||||
btn.Click += (_, _) =>
|
||||
{
|
||||
_character.Inventory.TryUnequip(slot, out var err);
|
||||
if (!string.IsNullOrEmpty(err) && _statusLabel is not null) _statusLabel.Text = err;
|
||||
else SetStatus($"Unequipped {inst.Def.Name}.");
|
||||
BuildUI();
|
||||
};
|
||||
else btn.Enabled = false;
|
||||
return btn;
|
||||
}
|
||||
|
||||
private Widget BuildBagItemRow(ItemInstance inst)
|
||||
{
|
||||
string suffix = inst.Qty > 1 ? $" ×{inst.Qty}" : "";
|
||||
string weight = $" ({inst.TotalWeightLb:F1} lb)";
|
||||
var btn = new TextButton
|
||||
{
|
||||
Text = $" {inst.Def.Name}{suffix}{weight}",
|
||||
Width = 380,
|
||||
Padding = new Thickness(4, 2, 4, 2),
|
||||
};
|
||||
var auto = NaturalSlotFor(inst);
|
||||
if (auto is null)
|
||||
{
|
||||
btn.Enabled = false; // gear / consumables — no equip target in M3
|
||||
}
|
||||
else
|
||||
{
|
||||
btn.Click += (_, _) =>
|
||||
{
|
||||
if (_character.Inventory.TryEquip(inst, auto.Value, out var err))
|
||||
SetStatus($"Equipped {inst.Def.Name} into {auto.Value}.");
|
||||
else
|
||||
SetStatus(err);
|
||||
BuildUI();
|
||||
};
|
||||
}
|
||||
return btn;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default equip slot for an item based on kind. Returns null for items
|
||||
/// that have no obvious slot (consumables, adventuring gear).
|
||||
/// </summary>
|
||||
private static EquipSlot? NaturalSlotFor(ItemInstance inst)
|
||||
{
|
||||
switch (inst.Def.Kind)
|
||||
{
|
||||
case "weapon":
|
||||
return EquipSlot.MainHand;
|
||||
case "armor":
|
||||
return EquipSlot.Body;
|
||||
case "shield":
|
||||
return EquipSlot.OffHand;
|
||||
case "natural_weapon_enhancer":
|
||||
return EquipSlotExtensions.FromEnhancerSlot(inst.Def.EnhancerSlot);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<EquipSlot> EquipSlotsToShow() => new[]
|
||||
{
|
||||
EquipSlot.MainHand,
|
||||
EquipSlot.OffHand,
|
||||
EquipSlot.Body,
|
||||
EquipSlot.Helm,
|
||||
EquipSlot.Cloak,
|
||||
EquipSlot.Boots,
|
||||
EquipSlot.AdaptivePack,
|
||||
EquipSlot.NaturalWeaponFang,
|
||||
EquipSlot.NaturalWeaponClaw,
|
||||
EquipSlot.NaturalWeaponHoof,
|
||||
EquipSlot.NaturalWeaponAntler,
|
||||
EquipSlot.NaturalWeaponHorn,
|
||||
};
|
||||
|
||||
private static string SlotLabel(EquipSlot s) => s switch
|
||||
{
|
||||
EquipSlot.MainHand => "Main hand:",
|
||||
EquipSlot.OffHand => "Off hand:",
|
||||
EquipSlot.Body => "Body:",
|
||||
EquipSlot.Helm => "Helm:",
|
||||
EquipSlot.Cloak => "Cloak:",
|
||||
EquipSlot.Boots => "Boots:",
|
||||
EquipSlot.AdaptivePack => "Pack:",
|
||||
EquipSlot.NaturalWeaponFang => "Fang caps:",
|
||||
EquipSlot.NaturalWeaponClaw => "Claw sheaths:",
|
||||
EquipSlot.NaturalWeaponHoof => "Hoof plates:",
|
||||
EquipSlot.NaturalWeaponAntler => "Antler tips:",
|
||||
EquipSlot.NaturalWeaponHorn => "Horn rings:",
|
||||
_ => s.ToString(),
|
||||
};
|
||||
|
||||
private string FormatStatLine()
|
||||
{
|
||||
int ac = DerivedStats.ArmorClass(_character);
|
||||
int spd = DerivedStats.SpeedFt(_character);
|
||||
float cap = DerivedStats.CarryCapacityLb(_character);
|
||||
var enc = DerivedStats.Encumbrance(_character);
|
||||
return $"HP {_character.CurrentHp}/{_character.MaxHp} AC {ac} Speed {spd} ft. " +
|
||||
$"Carry {_character.Inventory.TotalWeightLb:F1}/{cap:F1} lb ({enc})";
|
||||
}
|
||||
|
||||
private void SetStatus(string text)
|
||||
{
|
||||
if (_statusLabel is not null) _statusLabel.Text = text;
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
var ks = Keyboard.GetState();
|
||||
bool tab = ks.IsKeyDown(Keys.Tab);
|
||||
bool esc = ks.IsKeyDown(Keys.Escape);
|
||||
bool tabPressed = tab && !_tabWasDown;
|
||||
bool escPressed = esc && !_escWasDown;
|
||||
_tabWasDown = tab;
|
||||
_escWasDown = esc;
|
||||
if (tabPressed || escPressed) _game.Screens.Pop();
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch sb)
|
||||
{
|
||||
// Don't clear — let the play screen's last frame show through (semi-transparent overlay).
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M0 — the level-up modal. Pushed by <see cref="PauseMenuScreen"/>
|
||||
/// when the player clicks "Level Up" while
|
||||
/// <see cref="LevelUpFlow.CanLevelUp"/> returns true.
|
||||
///
|
||||
/// Shows the rolled (or averaged) HP gain, the feature unlocks for this
|
||||
/// level, and — when applicable — the ASI picker and subclass picker. On
|
||||
/// confirm, applies the deltas to the player's <see cref="Character"/>
|
||||
/// via <see cref="Character.ApplyLevelUp"/> and pops; if the player still
|
||||
/// has enough XP for another level, the screen offers to re-open.
|
||||
/// </summary>
|
||||
public sealed class LevelUpScreen : IScreen
|
||||
{
|
||||
private readonly Character _character;
|
||||
private readonly ulong _baseSeed;
|
||||
private readonly IReadOnlyDictionary<string, Theriapolis.Core.Data.SubclassDef>? _subclasses;
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private LevelUpResult _preview = null!;
|
||||
private LevelUpChoices _choices = null!;
|
||||
private Label? _statusLabel;
|
||||
private bool _escWasDown = true;
|
||||
|
||||
public LevelUpScreen(
|
||||
Character character,
|
||||
ulong baseSeed,
|
||||
IReadOnlyDictionary<string, Theriapolis.Core.Data.SubclassDef>? subclasses = null)
|
||||
{
|
||||
_character = character ?? throw new ArgumentNullException(nameof(character));
|
||||
_baseSeed = baseSeed;
|
||||
_subclasses = subclasses;
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
RecomputePreview(takeAverage: true);
|
||||
Build();
|
||||
}
|
||||
|
||||
private void RecomputePreview(bool takeAverage)
|
||||
{
|
||||
int targetLevel = _character.Level + 1;
|
||||
ulong seed = _baseSeed
|
||||
^ C.RNG_LEVELUP
|
||||
^ (ulong)targetLevel
|
||||
// Mix in level-up history length so each successive level-up
|
||||
// (when the player chains multiple at once) gets a distinct
|
||||
// sub-seed even when targetLevel is reused after re-entry.
|
||||
^ ((ulong)_character.LevelUpHistory.Count << 16);
|
||||
_preview = LevelUpFlow.Compute(_character, targetLevel, seed,
|
||||
takeAverage: takeAverage,
|
||||
subclasses: _subclasses);
|
||||
_choices = new LevelUpChoices { TakeAverageHp = takeAverage };
|
||||
}
|
||||
|
||||
private void Build()
|
||||
{
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Background = new SolidBrush(new Color(0, 0, 0, 220)),
|
||||
Padding = new Thickness(40, 24, 40, 24),
|
||||
};
|
||||
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"LEVEL UP — Level {_character.Level} → {_preview.NewLevel}",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// HP section.
|
||||
string hpLine = _preview.HpWasAveraged
|
||||
? $"HP: +{_preview.HpGained} (took average; rolled would be 1d{_character.ClassDef.HitDie})"
|
||||
: $"HP: +{_preview.HpGained} (rolled {_preview.HpHitDieResult} on 1d{_character.ClassDef.HitDie})";
|
||||
root.Widgets.Add(new Label { Text = hpLine, HorizontalAlignment = HorizontalAlignment.Center });
|
||||
|
||||
var hpToggle = new TextButton
|
||||
{
|
||||
Text = _preview.HpWasAveraged ? "Switch to: Roll HP" : "Switch to: Take average HP",
|
||||
Width = 280,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
hpToggle.Click += (_, _) =>
|
||||
{
|
||||
RecomputePreview(takeAverage: !_preview.HpWasAveraged);
|
||||
Build();
|
||||
};
|
||||
root.Widgets.Add(hpToggle);
|
||||
|
||||
// Class features.
|
||||
if (_preview.ClassFeaturesUnlocked.Length > 0)
|
||||
{
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
root.Widgets.Add(new Label { Text = "Features unlocked:", HorizontalAlignment = HorizontalAlignment.Center });
|
||||
foreach (var fid in _preview.ClassFeaturesUnlocked)
|
||||
{
|
||||
string display = fid;
|
||||
if (_character.ClassDef.FeatureDefinitions.TryGetValue(fid, out var def))
|
||||
display = string.IsNullOrEmpty(def.Name) ? fid : $"{def.Name} — {def.Kind}";
|
||||
root.Widgets.Add(new Label { Text = " • " + display, HorizontalAlignment = HorizontalAlignment.Center });
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6.5 M2 — subclass features (post-L3, when SubclassId is set).
|
||||
if (_preview.SubclassFeaturesUnlocked.Length > 0 && _subclasses is not null)
|
||||
{
|
||||
var subclass = Theriapolis.Core.Rules.Character.SubclassResolver.TryFindSubclass(
|
||||
_subclasses, _character.SubclassId);
|
||||
string subclassName = subclass?.Name ?? _character.SubclassId;
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"{subclassName} features:",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(220, 200, 140),
|
||||
});
|
||||
foreach (var fid in _preview.SubclassFeaturesUnlocked)
|
||||
{
|
||||
string display = fid;
|
||||
var fdef = Theriapolis.Core.Rules.Character.SubclassResolver.ResolveFeatureDef(
|
||||
_character.ClassDef, subclass, fid);
|
||||
if (fdef is not null)
|
||||
display = string.IsNullOrEmpty(fdef.Name) ? fid : $"{fdef.Name} — {fdef.Kind}";
|
||||
root.Widgets.Add(new Label { Text = " • " + display, HorizontalAlignment = HorizontalAlignment.Center });
|
||||
}
|
||||
}
|
||||
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"Proficiency bonus: +{_preview.NewProficiencyBonus}",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
|
||||
// Subclass picker.
|
||||
if (_preview.GrantsSubclassChoice)
|
||||
{
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
root.Widgets.Add(new Label { Text = "Choose a subclass:", HorizontalAlignment = HorizontalAlignment.Center });
|
||||
foreach (var sid in _character.ClassDef.SubclassIds)
|
||||
{
|
||||
string sCapture = sid;
|
||||
string label = sid;
|
||||
string? flavor = null;
|
||||
if (_subclasses is not null
|
||||
&& _subclasses.TryGetValue(sid, out var subDef))
|
||||
{
|
||||
label = subDef.Name;
|
||||
flavor = subDef.Flavor;
|
||||
}
|
||||
var btn = new TextButton
|
||||
{
|
||||
Text = $" {label}{(_choices.SubclassId == sCapture ? " ✓" : "")}",
|
||||
Width = 360,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
btn.Click += (_, _) =>
|
||||
{
|
||||
_choices.SubclassId = sCapture;
|
||||
Build();
|
||||
};
|
||||
root.Widgets.Add(btn);
|
||||
if (_choices.SubclassId == sCapture && !string.IsNullOrEmpty(flavor))
|
||||
{
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = " " + flavor,
|
||||
Wrap = true,
|
||||
Width = 600,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(170, 170, 170),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ASI picker.
|
||||
if (_preview.GrantsAsiChoice)
|
||||
{
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
root.Widgets.Add(new Label { Text = "Ability Score Improvement (+2 to one or +1 to two):", HorizontalAlignment = HorizontalAlignment.Center });
|
||||
int allocated = _choices.AsiAdjustments.Values.Sum();
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"Allocated: +{allocated} / +2",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
foreach (AbilityId aid in Enum.GetValues<AbilityId>())
|
||||
{
|
||||
var aidCapture = aid;
|
||||
int current = _character.Abilities.Get(aid);
|
||||
int delta = _choices.AsiAdjustments.TryGetValue(aid, out var d) ? d : 0;
|
||||
var row = new HorizontalStackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
row.Widgets.Add(new Label { Text = $" {aid}: {current}{(delta > 0 ? $" → {current + delta}" : "")} ", VerticalAlignment = VerticalAlignment.Center });
|
||||
var minus = new TextButton { Text = "−", Width = 30 };
|
||||
minus.Click += (_, _) =>
|
||||
{
|
||||
if (_choices.AsiAdjustments.TryGetValue(aidCapture, out var v) && v > 0)
|
||||
{
|
||||
if (v == 1) _choices.AsiAdjustments.Remove(aidCapture);
|
||||
else _choices.AsiAdjustments[aidCapture] = v - 1;
|
||||
Build();
|
||||
}
|
||||
};
|
||||
var plus = new TextButton { Text = "+", Width = 30 };
|
||||
plus.Click += (_, _) =>
|
||||
{
|
||||
int totalAllocated = _choices.AsiAdjustments.Values.Sum();
|
||||
if (totalAllocated >= 2) return; // cap at +2
|
||||
int currentDelta = _choices.AsiAdjustments.TryGetValue(aidCapture, out var v) ? v : 0;
|
||||
if (currentDelta >= 2) return;
|
||||
int currentScore = _character.Abilities.Get(aidCapture);
|
||||
if (currentScore + currentDelta + 1 > C.ABILITY_SCORE_CAP_PRE_L20) return;
|
||||
_choices.AsiAdjustments[aidCapture] = currentDelta + 1;
|
||||
Build();
|
||||
};
|
||||
row.Widgets.Add(minus);
|
||||
row.Widgets.Add(plus);
|
||||
root.Widgets.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Confirm button.
|
||||
bool valid = ChoicesValid(out string reason);
|
||||
var confirm = new TextButton
|
||||
{
|
||||
Text = valid ? "Confirm" : $"Confirm — {reason}",
|
||||
Width = 280,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Enabled = valid,
|
||||
};
|
||||
confirm.Click += (_, _) =>
|
||||
{
|
||||
if (!ChoicesValid(out _)) return;
|
||||
_character.ApplyLevelUp(_preview, _choices);
|
||||
// Chain into the next level-up immediately if eligible.
|
||||
if (LevelUpFlow.CanLevelUp(_character))
|
||||
{
|
||||
RecomputePreview(takeAverage: true);
|
||||
Build();
|
||||
ShowStatus($"Now level {_character.Level}. Another level available!");
|
||||
}
|
||||
else
|
||||
{
|
||||
_game.Screens.Pop();
|
||||
}
|
||||
};
|
||||
root.Widgets.Add(confirm);
|
||||
|
||||
var cancel = new TextButton { Text = "Cancel", Width = 280, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
cancel.Click += (_, _) => _game.Screens.Pop();
|
||||
root.Widgets.Add(cancel);
|
||||
|
||||
_statusLabel = new Label { Text = " ", HorizontalAlignment = HorizontalAlignment.Center };
|
||||
root.Widgets.Add(_statusLabel);
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
private bool ChoicesValid(out string reason)
|
||||
{
|
||||
if (_preview.GrantsSubclassChoice && string.IsNullOrEmpty(_choices.SubclassId))
|
||||
{
|
||||
reason = "pick a subclass";
|
||||
return false;
|
||||
}
|
||||
if (_preview.GrantsAsiChoice)
|
||||
{
|
||||
int total = _choices.AsiAdjustments.Values.Sum();
|
||||
if (total != 2)
|
||||
{
|
||||
reason = $"allocate +{2 - total} more ASI";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
reason = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ShowStatus(string text)
|
||||
{
|
||||
if (_statusLabel is not null) _statusLabel.Text = text;
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
bool down = Keyboard.GetState().IsKeyDown(Keys.Escape);
|
||||
bool justPressed = down && !_escWasDown;
|
||||
_escWasDown = down;
|
||||
if (justPressed) _game.Screens.Pop();
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch sb)
|
||||
{
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { Build(); }
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Game.Platform;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M2 pause menu. Pushed by <see cref="PlayScreen"/> when the player
|
||||
/// presses ESC during play. Provides Save Game (any slot, any time —
|
||||
/// "save-anywhere" per the Phase 5 plan), Resume, and Quit to Title.
|
||||
///
|
||||
/// Cut-scene exclusion is forward-compat: Phase 5 has no cut scenes, so
|
||||
/// save-anywhere is unconditional. When cut scenes arrive (Phase 6+), the
|
||||
/// caller can suppress this push or grey out the Save Game button.
|
||||
/// </summary>
|
||||
public sealed class PauseMenuScreen : IScreen
|
||||
{
|
||||
private readonly PlayScreen _playScreen;
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private Label? _statusLabel;
|
||||
private bool _showingSlots;
|
||||
// ESC was already down when PauseMenu was pushed (the same press that
|
||||
// opened it). Wait for release before treating ESC as "close the menu".
|
||||
private bool _escWasDown = true;
|
||||
|
||||
public PauseMenuScreen(PlayScreen playScreen)
|
||||
{
|
||||
_playScreen = playScreen ?? throw new ArgumentNullException(nameof(playScreen));
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
BuildMain();
|
||||
}
|
||||
|
||||
private void BuildMain()
|
||||
{
|
||||
_showingSlots = false;
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Background = new SolidBrush(new Color(0, 0, 0, 200)),
|
||||
Padding = new Thickness(40, 30, 40, 30),
|
||||
};
|
||||
|
||||
root.Widgets.Add(new Label { Text = "PAUSED", HorizontalAlignment = HorizontalAlignment.Center });
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
var resume = new TextButton { Text = "Resume", Width = 220, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
resume.Click += (_, _) => _game.Screens.Pop();
|
||||
root.Widgets.Add(resume);
|
||||
|
||||
// Phase 6.5 M0 — surface the level-up affordance when eligible.
|
||||
var pcChar = _playScreen.PlayerCharacter();
|
||||
if (pcChar is not null && Theriapolis.Core.Rules.Character.LevelUpFlow.CanLevelUp(pcChar))
|
||||
{
|
||||
var lvlBtn = new TextButton
|
||||
{
|
||||
Text = $"★ Level Up ({pcChar.Level} → {pcChar.Level + 1})",
|
||||
Width = 220,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
lvlBtn.Click += (_, _) =>
|
||||
{
|
||||
_game.Screens.Pop();
|
||||
_game.Screens.Push(new LevelUpScreen(
|
||||
pcChar,
|
||||
_playScreen.WorldSeed(),
|
||||
subclasses: _playScreen.ContentResolver?.Subclasses));
|
||||
};
|
||||
root.Widgets.Add(lvlBtn);
|
||||
}
|
||||
|
||||
var saveBtn = new TextButton { Text = "Save Game", Width = 220, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
saveBtn.Click += (_, _) => BuildSlotPicker();
|
||||
root.Widgets.Add(saveBtn);
|
||||
|
||||
var quickSave = new TextButton { Text = "Quicksave (autosave slot)", Width = 220, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
quickSave.Click += (_, _) =>
|
||||
{
|
||||
bool ok = _playScreen.SaveTo(SavePaths.AutosavePath());
|
||||
ShowStatus(ok ? "Quicksaved." : "Quicksave failed.");
|
||||
};
|
||||
root.Widgets.Add(quickSave);
|
||||
|
||||
var quit = new TextButton { Text = "Quit to Title", Width = 220, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
quit.Click += (_, _) =>
|
||||
{
|
||||
// Autosave on quit-to-title (matches existing Phase 4 behaviour).
|
||||
_playScreen.SaveTo(SavePaths.AutosavePath());
|
||||
// Pop the pause menu, then pop the play screen.
|
||||
_game.Screens.Pop();
|
||||
_game.Screens.Pop();
|
||||
};
|
||||
root.Widgets.Add(quit);
|
||||
|
||||
_statusLabel = new Label { Text = " ", HorizontalAlignment = HorizontalAlignment.Center };
|
||||
root.Widgets.Add(_statusLabel);
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
private void BuildSlotPicker()
|
||||
{
|
||||
_showingSlots = true;
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Background = new SolidBrush(new Color(0, 0, 0, 200)),
|
||||
Padding = new Thickness(40, 20, 40, 20),
|
||||
};
|
||||
root.Widgets.Add(new Label { Text = "Save to slot:", HorizontalAlignment = HorizontalAlignment.Center });
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
for (int i = 1; i <= C.SAVE_SLOT_COUNT; i++)
|
||||
{
|
||||
int slotNum = i; // capture
|
||||
string path = SavePaths.SlotPath(slotNum);
|
||||
string label = $"Slot {slotNum:D2}";
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
var header = Theriapolis.Core.Persistence.SaveCodec.DeserializeHeaderOnly(bytes);
|
||||
label += " — " + header.SlotLabel();
|
||||
}
|
||||
catch { label += " — <unreadable>"; }
|
||||
}
|
||||
else { label += " — <empty>"; }
|
||||
|
||||
var btn = new TextButton { Text = label, Width = 480, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
btn.Click += (_, _) =>
|
||||
{
|
||||
bool ok = _playScreen.SaveTo(path);
|
||||
if (ok)
|
||||
{
|
||||
ShowStatus($"Saved to slot {slotNum:D2}.");
|
||||
BuildMain();
|
||||
}
|
||||
else ShowStatus("Save failed.");
|
||||
};
|
||||
root.Widgets.Add(btn);
|
||||
}
|
||||
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
var back = new TextButton { Text = "Back", Width = 220, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
back.Click += (_, _) => BuildMain();
|
||||
root.Widgets.Add(back);
|
||||
|
||||
_statusLabel = new Label { Text = " ", HorizontalAlignment = HorizontalAlignment.Center };
|
||||
root.Widgets.Add(_statusLabel);
|
||||
|
||||
_desktop.Root = root;
|
||||
}
|
||||
|
||||
private void ShowStatus(string text)
|
||||
{
|
||||
if (_statusLabel is not null) _statusLabel.Text = text;
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
// ESC dismisses the pause menu (resume), or backs out of the slot picker.
|
||||
// Edge-detect so the press that opened the menu doesn't immediately close it.
|
||||
bool down = Keyboard.GetState().IsKeyDown(Keys.Escape);
|
||||
bool justPressed = down && !_escWasDown;
|
||||
_escWasDown = down;
|
||||
if (justPressed)
|
||||
{
|
||||
if (_showingSlots) BuildMain();
|
||||
else _game.Screens.Pop();
|
||||
}
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch sb)
|
||||
{
|
||||
// Don't clear — let the play screen's last frame remain visible underneath.
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Persistence;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.Time;
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
using Theriapolis.Game.Input;
|
||||
using Theriapolis.Game.Platform;
|
||||
using Theriapolis.Game.Rendering;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// The in-game screen. Owns the camera, both renderers, the player actor,
|
||||
/// the world clock, and the input pipeline.
|
||||
///
|
||||
/// Phase 4 — M1: world-map only (click to walk). M3 will plug in the tactical
|
||||
/// renderer and view swap; M4 the autosave hooks.
|
||||
/// </summary>
|
||||
public sealed class PlayScreen : IScreen
|
||||
{
|
||||
private readonly WorldGenContext _ctx;
|
||||
private readonly SaveBody? _restoredBody;
|
||||
private readonly Theriapolis.Core.Rules.Character.Character? _pendingCharacter;
|
||||
private readonly string? _pendingName;
|
||||
private ContentResolver? _content;
|
||||
private float _saveFlashTimer;
|
||||
private string _saveFlashText = "";
|
||||
|
||||
// Phase 5 M5: per-session NPC roster delta. ChunkCoord → killed spawn indices.
|
||||
private readonly Dictionary<Theriapolis.Core.Tactical.ChunkCoord, HashSet<int>> _killedByChunk = new();
|
||||
// Tracks the active CombatHUD so SaveTo can snapshot it for save-anywhere mid-combat.
|
||||
private CombatHUDScreen? _activeCombatHud;
|
||||
// Cached interact prompt — updated each tick.
|
||||
private Theriapolis.Core.Entities.NpcActor? _interactCandidate;
|
||||
// Edge-detect F key for the interact prompt.
|
||||
private bool _fWasDown;
|
||||
// Phase 5 M5: pending encounter restore (deferred to first Update so chunks load NPCs first).
|
||||
private EncounterState? _pendingEncounterRestore;
|
||||
// Phase 6 M1: anchor:* and role:* lookup table for quest/dialogue resolution.
|
||||
private readonly Theriapolis.Core.World.Settlements.AnchorRegistry _anchorRegistry = new();
|
||||
// Phase 6 M2: faction standings + per-NPC personal disposition + ledger.
|
||||
private readonly Theriapolis.Core.Rules.Reputation.PlayerReputation _reputation = new();
|
||||
// Phase 6 M3: world flag dictionary written by dialogue set_flag effects
|
||||
// (and Phase 6 M4 by quest steps). Round-trips through SaveBody.Flags.
|
||||
private readonly Dictionary<string, int> _flags = new();
|
||||
// Phase 6 M4: quest engine — ticked from PlayScreen.Update.
|
||||
private readonly Theriapolis.Core.Rules.Quests.QuestEngine _questEngine = new();
|
||||
private Theriapolis.Core.Rules.Quests.QuestContext? _questCtx;
|
||||
|
||||
// Phase 6 M3 accessors used by InteractionScreen / ShopScreen to drive
|
||||
// dialogue + shop state without copying the live aggregates.
|
||||
internal Theriapolis.Core.Rules.Reputation.PlayerReputation Reputation => _reputation;
|
||||
internal Dictionary<string, int> Flags => _flags;
|
||||
internal Theriapolis.Core.World.Settlements.AnchorRegistry Anchors => _anchorRegistry;
|
||||
|
||||
internal Theriapolis.Core.Rules.Character.Character? PlayerCharacter()
|
||||
=> _actors.Player?.Character;
|
||||
internal Theriapolis.Core.Rules.Quests.QuestEngine QuestEngine => _questEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M4 — fresh quest context for dialogue / shop screens that
|
||||
/// need to fire <c>start_quest</c> effects outside the regular tick.
|
||||
/// </summary>
|
||||
internal Theriapolis.Core.Rules.Quests.QuestContext? BuildQuestContextForDialogue()
|
||||
{
|
||||
if (_content is null) return null;
|
||||
if (_questCtx is null) return null;
|
||||
_questCtx.PlayerCharacter = _actors.Player?.Character;
|
||||
return _questCtx;
|
||||
}
|
||||
internal Vec2 PlayerActorPosition()
|
||||
=> _actors.Player?.Position ?? new Vec2(0, 0);
|
||||
internal long ClockSeconds()
|
||||
=> _clock.InGameSeconds;
|
||||
internal ulong WorldSeed()
|
||||
=> _ctx.World.WorldSeed;
|
||||
internal Theriapolis.Core.World.WorldState World()
|
||||
=> _ctx.World;
|
||||
internal ContentResolver? ContentResolver => _content;
|
||||
|
||||
private Game1 _game = null!;
|
||||
|
||||
private Camera2D _camera = null!;
|
||||
private TileAtlas _atlas = null!;
|
||||
private TacticalAtlas _tacticalAtlas = null!;
|
||||
private WorldMapRenderer _worldRenderer = null!;
|
||||
private TacticalRenderer _tacticalRenderer = null!;
|
||||
private LineFeatureRenderer _lineOverlay = null!;
|
||||
private PlayerSprite _playerSprite = null!;
|
||||
private NpcSprite _npcSprite = null!;
|
||||
private InputManager _input = null!;
|
||||
private SpriteBatch _sb = null!;
|
||||
|
||||
private ActorManager _actors = null!;
|
||||
private WorldClock _clock = null!;
|
||||
private PlayerController _controller = null!;
|
||||
private ChunkStreamer _streamer = null!;
|
||||
private InMemoryChunkDeltaStore _deltas = null!;
|
||||
|
||||
private Desktop _overlayDesktop = null!;
|
||||
private Label _hudLabel = null!;
|
||||
private int _cursorTileX, _cursorTileY; // world-tile coords (0..255)
|
||||
private int _cursorTacticalX, _cursorTacticalY; // tactical-tile coords (= world pixels)
|
||||
|
||||
// Click-vs-drag detection (same idiom as WorldMapScreen).
|
||||
private Vector2 _mouseDownPos;
|
||||
private int _mouseDownTileX, _mouseDownTileY;
|
||||
private bool _mouseDownTracked;
|
||||
private const float ClickSlopPixels = 4f;
|
||||
|
||||
public PlayScreen(WorldGenContext ctx)
|
||||
{
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
/// <summary>Restore-from-save constructor: applies the snapshot once Initialize runs.</summary>
|
||||
public PlayScreen(WorldGenContext ctx, SaveBody restoredBody)
|
||||
: this(ctx)
|
||||
{
|
||||
_restoredBody = restoredBody;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M2: new-game-with-character constructor. The character was built
|
||||
/// by <see cref="CharacterCreationScreen"/> and is attached to the spawned
|
||||
/// player on Initialize.
|
||||
/// </summary>
|
||||
public PlayScreen(WorldGenContext ctx, Theriapolis.Core.Rules.Character.Character character, string playerName)
|
||||
: this(ctx)
|
||||
{
|
||||
_pendingCharacter = character;
|
||||
_pendingName = playerName;
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
_input = new InputManager();
|
||||
_sb = new SpriteBatch(game.GraphicsDevice);
|
||||
|
||||
var gdw = new GraphicsDeviceWrapper(game.GraphicsDevice);
|
||||
_camera = new Camera2D(gdw);
|
||||
|
||||
_atlas = new TileAtlas(game.GraphicsDevice);
|
||||
_atlas.GeneratePlaceholders(_ctx.World.BiomeDefs!);
|
||||
|
||||
_worldRenderer = new WorldMapRenderer(_ctx, _atlas);
|
||||
_playerSprite = new PlayerSprite(game.GraphicsDevice);
|
||||
_npcSprite = new NpcSprite(game.GraphicsDevice);
|
||||
_lineOverlay = new LineFeatureRenderer(game.GraphicsDevice, _ctx);
|
||||
|
||||
_clock = new WorldClock();
|
||||
_actors = new ActorManager();
|
||||
_deltas = new InMemoryChunkDeltaStore();
|
||||
|
||||
// Phase 5: ContentResolver is needed for save/restore character round-trips
|
||||
// and to look up NPC templates from chunk spawns. Phase 6 M0: also feeds
|
||||
// building/layout content to the streamer so settlements stamp templates
|
||||
// instead of the placeholder plaza.
|
||||
_content = new ContentResolver(new ContentLoader(_game.ContentDataDirectory));
|
||||
_streamer = new ChunkStreamer(_ctx.World.WorldSeed, _ctx.World, _deltas, _content.Settlements);
|
||||
|
||||
// Phase 6 M1: pre-register every settlement's anchor id. Role tags
|
||||
// register lazily as residents stream in.
|
||||
_anchorRegistry.RegisterAllAnchors(_ctx.World);
|
||||
|
||||
// Phase 6 M4: build the quest context once content + clock + actors
|
||||
// are wired up. PlayerCharacter is filled in once SpawnPlayer runs.
|
||||
_questCtx = new Theriapolis.Core.Rules.Quests.QuestContext(
|
||||
_content, _actors, _reputation, _flags, _anchorRegistry, _clock, _ctx.World);
|
||||
|
||||
// Tactical art root: Gfx/tactical/{surface,deco}/<name>.png. The atlas
|
||||
// falls back to placeholders for any tile that has no PNG yet.
|
||||
string tacticalGfx = System.IO.Path.Combine(_game.ContentGfxDirectory, "tactical");
|
||||
_tacticalAtlas = new TacticalAtlas(game.GraphicsDevice, tacticalGfx);
|
||||
_tacticalRenderer = new TacticalRenderer(game.GraphicsDevice, _streamer, _tacticalAtlas);
|
||||
|
||||
// Phase 5 M5: subscribe to chunk events so NPCs spawn/despawn with the
|
||||
// active tactical window.
|
||||
_streamer.OnChunkLoaded += HandleChunkLoaded;
|
||||
_streamer.OnChunkEvicting += HandleChunkEvicting;
|
||||
|
||||
if (_restoredBody is not null)
|
||||
{
|
||||
ApplyRestoredBody(_restoredBody);
|
||||
}
|
||||
else
|
||||
{
|
||||
// New game: spawn at the Tier-1 anchor (Millhaven), or world centre as
|
||||
// a safe fallback if no Tier-1 exists yet.
|
||||
var spawn = ChooseSpawn(_ctx.World);
|
||||
if (_pendingCharacter is not null)
|
||||
{
|
||||
var p = _actors.SpawnPlayer(spawn, _pendingCharacter);
|
||||
if (!string.IsNullOrWhiteSpace(_pendingName)) p.Name = _pendingName;
|
||||
}
|
||||
else
|
||||
{
|
||||
_actors.SpawnPlayer(spawn);
|
||||
}
|
||||
}
|
||||
_controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
|
||||
// Tactical sampler — looks up walkability through the streamer.
|
||||
_controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;
|
||||
|
||||
// Camera initially centred on the player and zoomed to a comfortable
|
||||
// mid-zoom (between fit-the-world and tactical threshold) so the player
|
||||
// can see their surroundings without instantly entering tactical.
|
||||
_camera.Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y);
|
||||
SetInitialZoom();
|
||||
|
||||
BuildOverlay();
|
||||
|
||||
// Phase 5 M5: if we restored a mid-combat encounter, force-load chunks
|
||||
// so the NPC actors spawn, then rehydrate the encounter and push the
|
||||
// CombatHUD on top.
|
||||
if (_pendingEncounterRestore is not null)
|
||||
{
|
||||
_streamer.EnsureLoadedAround(_actors.Player!.Position, C.TACTICAL_WINDOW_WORLD_TILES);
|
||||
RestoreEncounter(_pendingEncounterRestore);
|
||||
_pendingEncounterRestore = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreEncounter(EncounterState saved)
|
||||
{
|
||||
if (_actors.Player?.Character is null) return;
|
||||
var participants = new List<Theriapolis.Core.Rules.Combat.Combatant>();
|
||||
foreach (var snap in saved.Combatants)
|
||||
{
|
||||
Theriapolis.Core.Rules.Combat.Combatant combatant;
|
||||
if (snap.IsPlayer)
|
||||
{
|
||||
combatant = Theriapolis.Core.Rules.Combat.Combatant.FromCharacter(
|
||||
_actors.Player.Character!, _actors.Player.Id, _actors.Player.Name,
|
||||
new Vec2((int)snap.PositionX, (int)snap.PositionY),
|
||||
Theriapolis.Core.Rules.Character.Allegiance.Player);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try to find the live NPC actor (same chunk + spawn index).
|
||||
Theriapolis.Core.Entities.NpcActor? npc = null;
|
||||
if (snap.NpcChunkX is int cx && snap.NpcChunkY is int cy && snap.NpcSpawnIndex is int si)
|
||||
npc = _actors.FindNpcBySource(new Theriapolis.Core.Tactical.ChunkCoord(cx, cy), si);
|
||||
if (npc is null)
|
||||
{
|
||||
// Fall back to template-only combatant. Won't write back to a live actor on resolve,
|
||||
// but the encounter still completes correctly.
|
||||
var template = _content!.Npcs.Templates.FirstOrDefault(t => t.Id == snap.NpcTemplateId);
|
||||
if (template is null) continue;
|
||||
combatant = Theriapolis.Core.Rules.Combat.Combatant.FromNpcTemplate(
|
||||
template, snap.Id, new Vec2((int)snap.PositionX, (int)snap.PositionY));
|
||||
}
|
||||
else if (npc.Template is not null)
|
||||
{
|
||||
combatant = Theriapolis.Core.Rules.Combat.Combatant.FromNpcTemplate(
|
||||
npc.Template, npc.Id, new Vec2((int)snap.PositionX, (int)snap.PositionY));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Phase 6 M1 resident — re-resolve via template id from the snapshot.
|
||||
var template = _content!.Npcs.Templates.FirstOrDefault(t => t.Id == snap.NpcTemplateId);
|
||||
if (template is null) continue;
|
||||
combatant = Theriapolis.Core.Rules.Combat.Combatant.FromNpcTemplate(
|
||||
template, npc.Id, new Vec2((int)snap.PositionX, (int)snap.PositionY));
|
||||
}
|
||||
}
|
||||
combatant.CurrentHp = snap.CurrentHp;
|
||||
combatant.Position = new Vec2((int)snap.PositionX, (int)snap.PositionY);
|
||||
foreach (byte cb in snap.Conditions)
|
||||
combatant.Conditions.Add((Theriapolis.Core.Rules.Stats.Condition)cb);
|
||||
participants.Add(combatant);
|
||||
}
|
||||
|
||||
var encounter = new Theriapolis.Core.Rules.Combat.Encounter(
|
||||
_ctx.World.WorldSeed, saved.EncounterId, participants);
|
||||
encounter.ResumeRolls(saved.RollCount);
|
||||
// Note: we do NOT restore CurrentTurnIndex / RoundNumber directly — the
|
||||
// encounter constructor recomputes initiative from the participants. Save
|
||||
// captures the round/turn for HUD display purposes; functional resume
|
||||
// works because the dice stream is at the same point.
|
||||
|
||||
_activeCombatHud = new CombatHUDScreen(encounter, _actors, OnEncounterEnd, _content);
|
||||
_game.Screens.Push(_activeCombatHud);
|
||||
}
|
||||
|
||||
private void ApplyRestoredBody(SaveBody body)
|
||||
{
|
||||
var player = _actors.RestorePlayer(body.Player);
|
||||
_clock.RestoreState(body.Clock);
|
||||
// Reload chunk delta store from the save.
|
||||
foreach (var kv in body.ModifiedChunks)
|
||||
_deltas.Put(kv.Key, kv.Value);
|
||||
// Apply world-tile deltas in place — these are sparse "the player burned
|
||||
// a settlement" style overrides, not full tile rewrites.
|
||||
foreach (var d in body.ModifiedWorldTiles)
|
||||
{
|
||||
ref var t = ref _ctx.World.TileAt(d.X, d.Y);
|
||||
t.Biome = (BiomeId)d.NewBiome;
|
||||
t.Features = (FeatureFlags)d.NewFeatures;
|
||||
}
|
||||
// Phase 5: rehydrate the character if one was saved. Phase-4 saves
|
||||
// (without character) would have been refused by SaveLoadScreen, so
|
||||
// here PlayerCharacter should always be non-null. Defensive null-check
|
||||
// anyway in case a hand-edited save sneaks through.
|
||||
if (body.PlayerCharacter is not null && _content is not null)
|
||||
{
|
||||
player.Character = CharacterCodec.Restore(body.PlayerCharacter, _content);
|
||||
}
|
||||
// Phase 5 M5: restore per-chunk killed-spawn-indices.
|
||||
_killedByChunk.Clear();
|
||||
foreach (var d in body.NpcRoster.ChunkDeltas)
|
||||
{
|
||||
var coord = new Theriapolis.Core.Tactical.ChunkCoord(d.ChunkX, d.ChunkY);
|
||||
_killedByChunk[coord] = new HashSet<int>(d.KilledSpawnIndices);
|
||||
}
|
||||
// Phase 5 M5: defer encounter rehydration until chunks load and NPC actors
|
||||
// exist; the first Update tick triggers EnsureLoadedAround which spawns them.
|
||||
_pendingEncounterRestore = body.ActiveEncounter;
|
||||
|
||||
// Phase 6 M2 — restore reputation aggregate. Replace the empty default
|
||||
// by mutating the existing instance in place so consumers holding a
|
||||
// reference (the ReputationScreen, dialogue runner) keep working.
|
||||
var restoredRep = Theriapolis.Core.Persistence.ReputationCodec.Restore(body.ReputationState);
|
||||
_reputation.Factions.Clear();
|
||||
foreach (var (k, v) in restoredRep.Factions.Standings) _reputation.Factions.Set(k, v);
|
||||
_reputation.Personal.Clear();
|
||||
foreach (var (k, v) in restoredRep.Personal) _reputation.Personal[k] = v;
|
||||
_reputation.Ledger.Clear();
|
||||
foreach (var ev in restoredRep.Ledger.Entries) _reputation.Ledger.Append(ev);
|
||||
|
||||
// Phase 6 M3 — restore world flag dictionary.
|
||||
_flags.Clear();
|
||||
foreach (var (k, v) in body.Flags) _flags[k] = v;
|
||||
|
||||
// Phase 6 M4 — restore quest engine state.
|
||||
Theriapolis.Core.Persistence.QuestCodec.Restore(_questEngine, body.QuestEngineState);
|
||||
}
|
||||
|
||||
/// <summary>Build a save body snapshot from the current screen state.</summary>
|
||||
private SaveBody CaptureBody()
|
||||
{
|
||||
// Capture the encounter snapshot BEFORE flushing chunks (snapshot needs
|
||||
// live combatant state, and FlushAll evicts NPCs which would erase it).
|
||||
EncounterState? activeEnc = _activeCombatHud is { IsOver: false }
|
||||
? _activeCombatHud.SnapshotForSave()
|
||||
: null;
|
||||
|
||||
// Push every loaded chunk through eviction so any in-memory deltas
|
||||
// hit the store before we read it. NOTE: this also despawns all live
|
||||
// NPCs via OnChunkEvicting — fine for save (state is captured above
|
||||
// for the encounter; NpcRoster captures the kill-list).
|
||||
_streamer.FlushAll();
|
||||
|
||||
var body = new SaveBody
|
||||
{
|
||||
Player = _actors.Player!.CaptureState(),
|
||||
Clock = _clock.CaptureState(),
|
||||
};
|
||||
// Phase 5: capture the character if one is attached.
|
||||
if (_actors.Player.Character is not null)
|
||||
body.PlayerCharacter = CharacterCodec.Capture(_actors.Player.Character);
|
||||
foreach (var kv in _deltas.All) body.ModifiedChunks[kv.Key] = kv.Value;
|
||||
|
||||
// Phase 5 M5: per-chunk killed-spawn-indices.
|
||||
foreach (var kv in _killedByChunk)
|
||||
body.NpcRoster.ChunkDeltas.Add(new NpcChunkDelta
|
||||
{
|
||||
ChunkX = kv.Key.X,
|
||||
ChunkY = kv.Key.Y,
|
||||
KilledSpawnIndices = kv.Value.ToArray(),
|
||||
});
|
||||
|
||||
body.ActiveEncounter = activeEnc;
|
||||
// Phase 6 M2 — capture reputation state.
|
||||
body.ReputationState = Theriapolis.Core.Persistence.ReputationCodec.Capture(_reputation);
|
||||
// Phase 6 M3 — capture world flag dictionary (dialogue set_flag effects).
|
||||
body.Flags = new Dictionary<string, int>(_flags);
|
||||
// Phase 6 M4 — capture quest engine state.
|
||||
body.QuestEngineState = Theriapolis.Core.Persistence.QuestCodec.Capture(_questEngine);
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── Phase 5 M5: chunk → NPC lifecycle ───────────────────────────────
|
||||
|
||||
private void HandleChunkLoaded(Theriapolis.Core.Tactical.TacticalChunk chunk)
|
||||
{
|
||||
if (_content is null) return;
|
||||
// For each spawn in the chunk that hasn't been recorded as killed,
|
||||
// resolve it against the per-zone template table and spawn an NPC at
|
||||
// the spawn's tactical-tile coord (= world-pixel coord).
|
||||
_killedByChunk.TryGetValue(chunk.Coord, out var killed);
|
||||
for (int i = 0; i < chunk.Spawns.Count; i++)
|
||||
{
|
||||
if (killed is not null && killed.Contains(i)) continue;
|
||||
var spawn = chunk.Spawns[i];
|
||||
// Skip if an actor from this slot already exists (chunk reload).
|
||||
if (_actors.FindNpcBySource(chunk.Coord, i) is not null) continue;
|
||||
|
||||
// Phase 6 M1: residents take a different lookup path —
|
||||
// by building-role tag, not by danger zone.
|
||||
if (spawn.Kind == Theriapolis.Core.Tactical.SpawnKind.Resident)
|
||||
{
|
||||
Theriapolis.Core.Rules.Combat.ResidentInstantiator.Spawn(
|
||||
_ctx.World.WorldSeed, chunk, i, spawn,
|
||||
_ctx.World, _content, _actors, _anchorRegistry);
|
||||
continue;
|
||||
}
|
||||
|
||||
var template = Theriapolis.Core.Rules.Combat.NpcInstantiator.PickTemplate(
|
||||
spawn.Kind, chunk.DangerZone, _content.Npcs);
|
||||
if (template is null) continue;
|
||||
|
||||
int tx = chunk.OriginX + spawn.LocalX;
|
||||
int ty = chunk.OriginY + spawn.LocalY;
|
||||
_actors.SpawnNpc(template, new Vec2(tx, ty), chunk.Coord, i);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleChunkEvicting(Theriapolis.Core.Tactical.TacticalChunk chunk)
|
||||
{
|
||||
// Despawn any live NPCs sourced from this chunk so the active actor
|
||||
// list stays bounded as the player moves.
|
||||
var toRemove = new List<int>();
|
||||
foreach (var npc in _actors.Npcs)
|
||||
{
|
||||
if (npc.SourceChunk is { } src && src.Equals(chunk.Coord))
|
||||
{
|
||||
toRemove.Add(npc.Id);
|
||||
// Phase 6 M1 — drop role-tag mapping so the registry stays in
|
||||
// sync with active actors. Anchor entries (settlements) stay.
|
||||
if (!string.IsNullOrEmpty(npc.RoleTag))
|
||||
_anchorRegistry.UnregisterRole(npc.RoleTag);
|
||||
}
|
||||
}
|
||||
foreach (int id in toRemove) _actors.RemoveActor(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M5 per-tick check: hostile in LOS within
|
||||
/// <see cref="C.ENCOUNTER_TRIGGER_TILES"/> → start an encounter.
|
||||
/// Friendly/neutral within <see cref="C.INTERACT_PROMPT_TILES"/> →
|
||||
/// record interact candidate so the HUD can show "[F] Talk to ...".
|
||||
/// </summary>
|
||||
private void TickEncounterAndInteract()
|
||||
{
|
||||
if (_actors.Player is null) return;
|
||||
if (_activeCombatHud is { IsOver: false }) return; // already in combat
|
||||
|
||||
// Phase 6 M4 — quest engine tick. Updates active quests, checks
|
||||
// auto-start triggers, runs effects. Cheap (a few µs even with
|
||||
// dozens of active quests).
|
||||
if (_questCtx is not null)
|
||||
{
|
||||
_questCtx.PlayerCharacter = _actors.Player.Character;
|
||||
_questEngine.Tick(_questCtx);
|
||||
}
|
||||
|
||||
// Phase 6 M5 — faction-driven aggression. Flips friendly/neutral
|
||||
// faction-affiliated NPCs to Hostile when local disposition drops
|
||||
// through the HOSTILE threshold. Runs BEFORE FindHostileTrigger so
|
||||
// a freshly-flipped patrol attacks on the same tick.
|
||||
if (_content is not null && _actors.Player.Character is { } pcChar)
|
||||
{
|
||||
Theriapolis.Core.Rules.Reputation.FactionAggression.UpdateAllegiances(
|
||||
_actors, pcChar, _reputation, _content, _ctx.World, _ctx.World.WorldSeed);
|
||||
}
|
||||
|
||||
// Hostile auto-trigger.
|
||||
var hostile = Theriapolis.Core.Rules.Combat.EncounterTrigger.FindHostileTrigger(_actors);
|
||||
if (hostile is not null)
|
||||
{
|
||||
StartEncounterWith(hostile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Friendly/neutral interact prompt.
|
||||
_interactCandidate = Theriapolis.Core.Rules.Combat.EncounterTrigger.FindInteractCandidate(_actors);
|
||||
}
|
||||
|
||||
private void StartEncounterWith(Theriapolis.Core.Entities.NpcActor seed)
|
||||
{
|
||||
if (_actors.Player?.Character is null) return;
|
||||
|
||||
// Player + the triggering NPC + any other living hostiles within
|
||||
// ENCOUNTER_TRIGGER_TILES (multi-mob encounters).
|
||||
var player = _actors.Player;
|
||||
var participants = new List<Theriapolis.Core.Rules.Combat.Combatant>();
|
||||
participants.Add(Theriapolis.Core.Rules.Combat.Combatant.FromCharacter(
|
||||
player.Character!, player.Id, player.Name,
|
||||
new Vec2((int)player.Position.X, (int)player.Position.Y),
|
||||
Theriapolis.Core.Rules.Character.Allegiance.Player));
|
||||
foreach (var npc in _actors.Npcs)
|
||||
{
|
||||
if (!npc.IsAlive) continue;
|
||||
if (npc.Allegiance != Theriapolis.Core.Rules.Character.Allegiance.Hostile) continue;
|
||||
if (npc.Template is null) continue; // residents (Phase 6 M1) skip combat
|
||||
int dx = (int)System.Math.Abs(player.Position.X - npc.Position.X);
|
||||
int dy = (int)System.Math.Abs(player.Position.Y - npc.Position.Y);
|
||||
if (System.Math.Max(dx, dy) > C.ENCOUNTER_TRIGGER_TILES) continue;
|
||||
var combatant = Theriapolis.Core.Rules.Combat.Combatant.FromNpcTemplate(
|
||||
npc.Template, npc.Id,
|
||||
new Vec2((int)npc.Position.X, (int)npc.Position.Y));
|
||||
// Sync HP from the live actor in case it took damage from a previous fight.
|
||||
combatant.CurrentHp = npc.CurrentHp;
|
||||
participants.Add(combatant);
|
||||
}
|
||||
|
||||
ulong encId = (ulong)seed.Id;
|
||||
var encounter = new Theriapolis.Core.Rules.Combat.Encounter(
|
||||
_ctx.World.WorldSeed, encId, participants);
|
||||
|
||||
// Phase 6.5 M1 — top up per-encounter resource pools (Lay on Paws,
|
||||
// Field Repair, Vocalization Dice). Phase 8's rest model will replace
|
||||
// this encounter-rest equivalence.
|
||||
// Phase 6.5 M3 adds Pheromone Craft + Covenant Authority pools.
|
||||
if (_actors.Player?.Character is { } pc)
|
||||
{
|
||||
Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsureLayOnPawsPoolReady(pc);
|
||||
Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsureFieldRepairReady(pc);
|
||||
Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsureVocalizationDiceReady(pc);
|
||||
Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsurePheromoneUsesReady(pc);
|
||||
Theriapolis.Core.Rules.Combat.FeatureProcessor.EnsureCovenantAuthorityReady(pc);
|
||||
}
|
||||
|
||||
// Combat-start autosave to a dedicated slot so the player can always
|
||||
// retry the most recent fight even if their manual save is older.
|
||||
SaveTo(SavePaths.AutosavePath());
|
||||
|
||||
_activeCombatHud = new CombatHUDScreen(encounter, _actors, OnEncounterEnd, _content);
|
||||
_game.Screens.Push(_activeCombatHud);
|
||||
}
|
||||
|
||||
private void OnEncounterEnd(EncounterEndResult result)
|
||||
{
|
||||
// Merge per-chunk kill records.
|
||||
foreach (var kv in result.Killed)
|
||||
{
|
||||
if (!_killedByChunk.TryGetValue(kv.Key, out var set))
|
||||
_killedByChunk[kv.Key] = set = new HashSet<int>();
|
||||
foreach (int idx in kv.Value) set.Add(idx);
|
||||
}
|
||||
_activeCombatHud = null;
|
||||
}
|
||||
|
||||
/// <summary>Build the save header from current state + worldgen StageHashes.</summary>
|
||||
private SaveHeader BuildHeader()
|
||||
{
|
||||
var h = new SaveHeader
|
||||
{
|
||||
WorldSeedHex = $"0x{_ctx.World.WorldSeed:X}",
|
||||
PlayerName = _actors.Player!.Name,
|
||||
PlayerTier = _actors.Player.HighestTierReached,
|
||||
InGameSeconds = _clock.InGameSeconds,
|
||||
SavedAtUtc = DateTime.UtcNow.ToString("u"),
|
||||
};
|
||||
foreach (var kv in _ctx.World.StageHashes)
|
||||
h.StageHashes[kv.Key] = $"0x{kv.Value:X}";
|
||||
return h;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write the current state to the given slot path (atomic). Used by F5
|
||||
/// quicksave, by the slot picker, and by autosave on screen transitions.
|
||||
/// </summary>
|
||||
public bool SaveTo(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var header = BuildHeader();
|
||||
var body = CaptureBody();
|
||||
var bytes = SaveCodec.Serialize(header, body);
|
||||
SavePaths.WriteAtomic(path, bytes);
|
||||
FlashSavedToast($"Saved to {Path.GetFileName(path)}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FlashSavedToast($"Save failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void FlashSavedToast(string text)
|
||||
{
|
||||
_saveFlashText = text;
|
||||
_saveFlashTimer = 2.5f;
|
||||
}
|
||||
|
||||
private static Vec2 ChooseSpawn(WorldState w)
|
||||
{
|
||||
var tier1 = w.Settlements.FirstOrDefault(s => s.Tier == 1 && !s.IsPoi);
|
||||
if (tier1 is not null) return new Vec2(tier1.WorldPixelX, tier1.WorldPixelY);
|
||||
var anyInhabited = w.Settlements.FirstOrDefault(s => !s.IsPoi);
|
||||
if (anyInhabited is not null) return new Vec2(anyInhabited.WorldPixelX, anyInhabited.WorldPixelY);
|
||||
// Last-ditch: centre of the world.
|
||||
return new Vec2(C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS * 0.5f,
|
||||
C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS * 0.5f);
|
||||
}
|
||||
|
||||
private void SetInitialZoom()
|
||||
{
|
||||
// Frame ~24 tiles across the screen — comfortable overland-travel zoom.
|
||||
float targetZoom = (float)_game.GraphicsDevice.Viewport.Width
|
||||
/ (24f * C.WORLD_TILE_PIXELS);
|
||||
targetZoom = Math.Clamp(targetZoom, Camera2D.MinZoom, Camera2D.TacticalThreshold * 0.95f);
|
||||
_camera.AdjustZoom(
|
||||
targetZoom / _camera.Zoom - 1f,
|
||||
new Vector2(_game.GraphicsDevice.Viewport.Width * 0.5f,
|
||||
_game.GraphicsDevice.Viewport.Height * 0.5f));
|
||||
}
|
||||
|
||||
private void BuildOverlay()
|
||||
{
|
||||
_hudLabel = new Label
|
||||
{
|
||||
Text = "",
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(8),
|
||||
Padding = new Thickness(8, 4, 8, 4),
|
||||
Background = new SolidBrush(new Color(0, 0, 0, 180)),
|
||||
};
|
||||
_overlayDesktop = new Desktop { Root = _hudLabel };
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
_input.Update();
|
||||
if (!_game.IsActive) return;
|
||||
|
||||
// ESC → push the pause menu (Phase 5 M2). The menu offers Resume,
|
||||
// Save Game (any slot), Quicksave, and Quit-to-Title (autosaves first).
|
||||
if (_input.JustPressed(Keys.Escape))
|
||||
{
|
||||
_game.Screens.Push(new PauseMenuScreen(this));
|
||||
return;
|
||||
}
|
||||
|
||||
// TAB → open inventory (Phase 5 M3). Requires a Character on the player.
|
||||
if (_input.JustPressed(Keys.Tab) && _actors.Player?.Character is not null)
|
||||
{
|
||||
_game.Screens.Push(new InventoryScreen(_actors.Player.Character));
|
||||
return;
|
||||
}
|
||||
|
||||
// R → reputation screen (Phase 6 M2).
|
||||
if (_input.JustPressed(Keys.R) && _content is not null)
|
||||
{
|
||||
_game.Screens.Push(new ReputationScreen(_reputation, _content));
|
||||
return;
|
||||
}
|
||||
|
||||
// J → quest journal (Phase 6 M4).
|
||||
if (_input.JustPressed(Keys.J) && _content is not null)
|
||||
{
|
||||
_game.Screens.Push(new QuestLogScreen(_questEngine, _content));
|
||||
return;
|
||||
}
|
||||
// F5 → quicksave to autosave slot (no slot-picker flow).
|
||||
if (_input.JustPressed(Keys.F5))
|
||||
SaveTo(SavePaths.AutosavePath());
|
||||
|
||||
float dt = (float)gt.ElapsedGameTime.TotalSeconds;
|
||||
float panSpeed = 400f / _camera.Zoom;
|
||||
|
||||
// Camera pan stays on arrow keys / middle-drag so WASD remains free for
|
||||
// tactical stepping (M3). The world-map view doesn't read WASD.
|
||||
Vector2 panDir = Vector2.Zero;
|
||||
if (_input.IsDown(Keys.Up)) panDir.Y -= 1;
|
||||
if (_input.IsDown(Keys.Down)) panDir.Y += 1;
|
||||
if (_input.IsDown(Keys.Left)) panDir.X -= 1;
|
||||
if (_input.IsDown(Keys.Right)) panDir.X += 1;
|
||||
if (panDir != Vector2.Zero && _camera.Mode == ViewMode.WorldMap)
|
||||
_camera.Pan(panDir * panSpeed * dt);
|
||||
|
||||
// Track mouse-down for click-vs-drag.
|
||||
if (_input.LeftJustDown)
|
||||
{
|
||||
_mouseDownPos = _input.MousePosition;
|
||||
var downWorld = _camera.ScreenToWorld(_input.MousePosition);
|
||||
_mouseDownTileX = (int)MathF.Floor(downWorld.X / C.WORLD_TILE_PIXELS);
|
||||
_mouseDownTileY = (int)MathF.Floor(downWorld.Y / C.WORLD_TILE_PIXELS);
|
||||
_mouseDownTracked = true;
|
||||
}
|
||||
|
||||
// Mouse drag → pan (right-mouse on tactical so left-click stays usable for actions later).
|
||||
var dragDelta = _input.ConsumeDragDelta(_camera);
|
||||
if (dragDelta != Vector2.Zero) _camera.Pan(dragDelta);
|
||||
|
||||
// Mouse wheel zoom.
|
||||
int scroll = _input.ScrollDelta;
|
||||
if (scroll != 0)
|
||||
_camera.AdjustZoom(scroll > 0 ? 0.12f : -0.12f, _input.MousePosition);
|
||||
|
||||
// Resolve cursor → both world-tile and tactical-tile coords for the HUD.
|
||||
var worldPos = _camera.ScreenToWorld(_input.MousePosition);
|
||||
_cursorTileX = (int)MathF.Floor(worldPos.X / C.WORLD_TILE_PIXELS);
|
||||
_cursorTileY = (int)MathF.Floor(worldPos.Y / C.WORLD_TILE_PIXELS);
|
||||
_cursorTacticalX = (int)MathF.Floor(worldPos.X);
|
||||
_cursorTacticalY = (int)MathF.Floor(worldPos.Y);
|
||||
|
||||
// Click handler: world-map → travel; tactical → no-op for now.
|
||||
if (_input.LeftJustUp && _mouseDownTracked)
|
||||
{
|
||||
_mouseDownTracked = false;
|
||||
bool wasClick = Vector2.Distance(_input.MousePosition, _mouseDownPos) <= ClickSlopPixels;
|
||||
if (wasClick && _camera.Mode == ViewMode.WorldMap)
|
||||
{
|
||||
if (InBounds(_mouseDownTileX, _mouseDownTileY))
|
||||
_controller.RequestTravelTo(_mouseDownTileX, _mouseDownTileY);
|
||||
}
|
||||
}
|
||||
|
||||
_controller.Update(gt, _input, _camera, _game.IsActive);
|
||||
|
||||
// Camera follow when traveling so the player stays centred.
|
||||
if (_controller.IsTraveling || _camera.Mode == ViewMode.Tactical)
|
||||
{
|
||||
_camera.Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y);
|
||||
}
|
||||
|
||||
// Stream tactical chunks around the player whenever we're in (or
|
||||
// about to enter) tactical mode. We do this even on world-map mode
|
||||
// so the swap is instantaneous when the player zooms in.
|
||||
if (_camera.Mode == ViewMode.Tactical)
|
||||
_streamer.EnsureLoadedAround(_actors.Player!.Position, C.TACTICAL_WINDOW_WORLD_TILES);
|
||||
|
||||
// Phase 5 M5: encounter trigger + interact prompt only fire in
|
||||
// tactical mode (world-map travel doesn't surface NPCs at this scale).
|
||||
if (_camera.Mode == ViewMode.Tactical) TickEncounterAndInteract();
|
||||
else _interactCandidate = null;
|
||||
|
||||
// Friendly NPC F-press → push InteractionScreen.
|
||||
bool fNow = _input.IsDown(Keys.F);
|
||||
bool fJustDown = fNow && !_fWasDown;
|
||||
_fWasDown = fNow;
|
||||
if (fJustDown && _interactCandidate is not null)
|
||||
{
|
||||
_game.Screens.Push(new InteractionScreen(_interactCandidate, _content, this));
|
||||
_interactCandidate = null;
|
||||
}
|
||||
|
||||
if (_saveFlashTimer > 0f)
|
||||
_saveFlashTimer = MathF.Max(0f, _saveFlashTimer - dt);
|
||||
|
||||
UpdateOverlayText();
|
||||
}
|
||||
|
||||
private static bool InBounds(int x, int y)
|
||||
=> (uint)x < C.WORLD_WIDTH_TILES && (uint)y < C.WORLD_HEIGHT_TILES;
|
||||
|
||||
private void UpdateOverlayText()
|
||||
{
|
||||
var p = _actors.Player!;
|
||||
int ptx = (int)MathF.Floor(p.Position.X / C.WORLD_TILE_PIXELS);
|
||||
int pty = (int)MathF.Floor(p.Position.Y / C.WORLD_TILE_PIXELS);
|
||||
ref var t = ref _ctx.World.TileAt(
|
||||
Math.Clamp(ptx, 0, C.WORLD_WIDTH_TILES - 1),
|
||||
Math.Clamp(pty, 0, C.WORLD_HEIGHT_TILES - 1));
|
||||
string status = _controller.IsTraveling
|
||||
? "Traveling..."
|
||||
: _camera.Mode == ViewMode.Tactical
|
||||
? "WASD to step. Mouse-wheel out to leave tactical."
|
||||
: "Click a tile to travel. Mouse-wheel in for tactical.";
|
||||
|
||||
string toast = _saveFlashTimer > 0f ? $"\n[ {_saveFlashText} ]" : "";
|
||||
|
||||
string cursorBlock = _camera.Mode == ViewMode.Tactical
|
||||
? FormatTacticalCursor()
|
||||
: $"Cursor: ({_cursorTileX},{_cursorTileY})";
|
||||
|
||||
// Phase 5 M3: character header with HP/AC/encumbrance when attached.
|
||||
string charBlock = "";
|
||||
if (p.Character is { } pc)
|
||||
{
|
||||
int ac = Theriapolis.Core.Rules.Stats.DerivedStats.ArmorClass(pc);
|
||||
var enc = Theriapolis.Core.Rules.Stats.DerivedStats.Encumbrance(pc);
|
||||
string encTag = enc switch
|
||||
{
|
||||
Theriapolis.Core.Rules.Stats.DerivedStats.EncumbranceBand.Heavy => " [encumbered]",
|
||||
Theriapolis.Core.Rules.Stats.DerivedStats.EncumbranceBand.Over => " [over-encumbered]",
|
||||
_ => "",
|
||||
};
|
||||
charBlock = $"{p.Name} HP {pc.CurrentHp}/{pc.MaxHp} AC {ac}{encTag}\n";
|
||||
}
|
||||
|
||||
// Phase 5 M5: show "[F] Talk to ..." when a friendly/neutral is near.
|
||||
// Phase 6 M2: append the effective-disposition breakdown so the
|
||||
// player can see why an NPC is friendly/cool/hostile before talking.
|
||||
string interact = "";
|
||||
if (_interactCandidate is { } npc)
|
||||
{
|
||||
interact = $"\n[F] Talk to {npc.DisplayName}";
|
||||
if (p.Character is { } pcChar && _content is not null)
|
||||
{
|
||||
var br = Theriapolis.Core.Rules.Reputation.EffectiveDisposition.Breakdown(
|
||||
npc, pcChar, _reputation, _content, _ctx.World, _ctx.World.WorldSeed);
|
||||
interact += $" ({Theriapolis.Core.Rules.Reputation.DispositionLabels.DisplayName(br.Label)} {br.Total:+#;-#;0})";
|
||||
interact += $"\n clade {br.CladeBias:+#;-#;0} size {br.SizeDifferential:+#;-#;0} faction {br.FactionModifier:+#;-#;0} personal {br.Personal:+#;-#;0}";
|
||||
|
||||
// Phase 6 M5 — "why" breadcrumb. If the NPC has a settlement
|
||||
// home and a faction, show the most recent event reaching
|
||||
// them with its decay band so the player understands the
|
||||
// tooltip score.
|
||||
if (!string.IsNullOrEmpty(npc.FactionId) && npc.HomeSettlementId is { } hid
|
||||
&& _ctx.World.Settlements.FirstOrDefault(s => s.Id == hid) is { } home)
|
||||
{
|
||||
var explained = Theriapolis.Core.Rules.Reputation.RepPropagation
|
||||
.ExplainLocalStanding(npc.FactionId, home, _ctx.World.WorldSeed,
|
||||
_reputation.Ledger, _content.Factions, max: 1)
|
||||
.FirstOrDefault();
|
||||
if (explained.Event is not null)
|
||||
{
|
||||
interact += $"\n ↳ recent: {explained.Event.Note} "
|
||||
+ $"({explained.Band}, {explained.LocalDelta:+#;-#;0})";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_hudLabel.Text =
|
||||
charBlock +
|
||||
$"Seed: 0x{_ctx.World.WorldSeed:X}\n" +
|
||||
$"Player: ({ptx},{pty}) {t.Biome}\n" +
|
||||
$"{cursorBlock}\n" +
|
||||
$"View: {_camera.Mode} zoom={_camera.Zoom:F2}\n" +
|
||||
$"Time: {_clock.Format()}\n" +
|
||||
$"{status}\n" +
|
||||
"F5 = Quicksave · TAB = Inventory · ESC = Pause Menu" + interact + toast;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tactical cursor read-out: tactical coord, surface, deco, walkability,
|
||||
/// and the active flag set. SampleTile lazy-generates the chunk under the
|
||||
/// cursor if needed; the soft cache cap evicts it on the next stream sweep.
|
||||
/// </summary>
|
||||
private string FormatTacticalCursor()
|
||||
{
|
||||
int worldPxW = C.WORLD_WIDTH_TILES * C.TACTICAL_PER_WORLD_TILE;
|
||||
int worldPxH = C.WORLD_HEIGHT_TILES * C.TACTICAL_PER_WORLD_TILE;
|
||||
if ((uint)_cursorTacticalX >= worldPxW || (uint)_cursorTacticalY >= worldPxH)
|
||||
return $"Cursor: ({_cursorTacticalX},{_cursorTacticalY}) <off-world>";
|
||||
|
||||
var tt = _streamer.SampleTile(_cursorTacticalX, _cursorTacticalY);
|
||||
string deco = tt.Deco == TacticalDeco.None ? "—" : tt.Deco.ToString();
|
||||
string pass = tt.IsWalkable ? "walkable" : "blocked";
|
||||
if (tt.SlowsMovement && tt.IsWalkable) pass = "slow";
|
||||
|
||||
// Render flag set as a compact tag list (River, Road, Bridge, Settlement, Slow).
|
||||
var flags = (TacticalFlags)tt.Flags;
|
||||
string flagText = flags == TacticalFlags.None ? "" : $" [{flags}]";
|
||||
|
||||
return
|
||||
$"Cursor: ({_cursorTacticalX},{_cursorTacticalY})\n" +
|
||||
$" Surface: {tt.Surface} (v{tt.Variant})\n" +
|
||||
$" Deco: {deco}\n" +
|
||||
$" Move: {pass}{flagText}";
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch _)
|
||||
{
|
||||
_game.GraphicsDevice.Clear(new Color(5, 10, 20));
|
||||
|
||||
// Renderer swap — world-map view below the tactical-zoom threshold,
|
||||
// tactical above. WorldMapRenderer already includes its polyline pass.
|
||||
// In tactical mode the chunk gen has already burned roads/rivers into
|
||||
// surface tiles via TacticalChunkGen.Pass2_Polylines, so re-stroking
|
||||
// the polylines on top would double-draw the road and create visible
|
||||
// overlap artefacts.
|
||||
if (_camera.Mode == ViewMode.WorldMap)
|
||||
{
|
||||
_worldRenderer.Draw(_sb, _camera, gt);
|
||||
}
|
||||
else
|
||||
{
|
||||
_tacticalRenderer.Draw(_sb, _camera, gt);
|
||||
}
|
||||
// NPCs draw before the player so the player marker sits on top.
|
||||
_npcSprite.Draw(_sb, _camera, _actors.Npcs);
|
||||
_playerSprite.Draw(_sb, _camera, _actors.Player!);
|
||||
_overlayDesktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
|
||||
~PlayScreen()
|
||||
{
|
||||
_worldRenderer?.Dispose();
|
||||
_tacticalRenderer?.Dispose();
|
||||
_tacticalAtlas?.Dispose();
|
||||
_lineOverlay?.Dispose();
|
||||
_playerSprite?.Dispose();
|
||||
_npcSprite?.Dispose();
|
||||
_atlas?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Quests;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M4 — quest journal modal (J key). Two columns: active quests
|
||||
/// on the left (current step + waypoint hint), completed/failed quests
|
||||
/// on the right. A tail block shows the engine's recent journal entries.
|
||||
///
|
||||
/// Hidden quests stay hidden until they activate; once started, they
|
||||
/// appear in the active list normally.
|
||||
/// </summary>
|
||||
public sealed class QuestLogScreen : IScreen
|
||||
{
|
||||
private readonly QuestEngine _engine;
|
||||
private readonly ContentResolver _content;
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private bool _jWasDown = true;
|
||||
private bool _escWasDown = true;
|
||||
|
||||
public QuestLogScreen(QuestEngine engine, ContentResolver content)
|
||||
{
|
||||
_engine = engine ?? throw new System.ArgumentNullException(nameof(engine));
|
||||
_content = content ?? throw new System.ArgumentNullException(nameof(content));
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
BuildUI();
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(20),
|
||||
Padding = new Thickness(20, 12, 20, 12),
|
||||
Background = new SolidBrush(new Color(15, 12, 8, 235)),
|
||||
Width = 880,
|
||||
};
|
||||
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = "QUEST JOURNAL",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(255, 230, 170),
|
||||
});
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
var twoCol = new HorizontalStackPanel { Spacing = 24 };
|
||||
|
||||
// Active.
|
||||
var leftCol = new VerticalStackPanel { Spacing = 4, Width = 420 };
|
||||
leftCol.Widgets.Add(new Label { Text = "ACTIVE", TextColor = new Color(200, 180, 130) });
|
||||
var active = _engine.Active.Values.OrderBy(s => s.StartedAt).ToList();
|
||||
if (active.Count == 0)
|
||||
leftCol.Widgets.Add(new Label { Text = "(none yet)", TextColor = new Color(120, 110, 100) });
|
||||
foreach (var st in active)
|
||||
{
|
||||
var def = _content.Quests.TryGetValue(st.QuestId, out var d) ? d : null;
|
||||
string title = def?.Title ?? st.QuestId;
|
||||
leftCol.Widgets.Add(new Label
|
||||
{
|
||||
Text = $" {title}",
|
||||
TextColor = new Color(220, 220, 200),
|
||||
});
|
||||
// Step description if available.
|
||||
var step = def?.Steps.FirstOrDefault(x =>
|
||||
string.Equals(x.Id, st.CurrentStep, System.StringComparison.OrdinalIgnoreCase));
|
||||
if (step is not null && !string.IsNullOrEmpty(step.Description))
|
||||
leftCol.Widgets.Add(new Label
|
||||
{
|
||||
Text = $" • {step.Description}",
|
||||
TextColor = new Color(170, 200, 220),
|
||||
Wrap = true,
|
||||
Width = 410,
|
||||
});
|
||||
if (step is not null && !string.IsNullOrEmpty(step.Waypoint))
|
||||
leftCol.Widgets.Add(new Label
|
||||
{
|
||||
Text = $" → {step.Waypoint}",
|
||||
TextColor = new Color(140, 180, 110),
|
||||
});
|
||||
}
|
||||
twoCol.Widgets.Add(leftCol);
|
||||
|
||||
// Completed / failed.
|
||||
var rightCol = new VerticalStackPanel { Spacing = 4, Width = 420 };
|
||||
rightCol.Widgets.Add(new Label { Text = "ARCHIVE", TextColor = new Color(200, 180, 130) });
|
||||
var done = _engine.Completed.Values.OrderByDescending(s => s.StartedAt).ToList();
|
||||
if (done.Count == 0)
|
||||
rightCol.Widgets.Add(new Label { Text = "(none yet)", TextColor = new Color(120, 110, 100) });
|
||||
foreach (var st in done)
|
||||
{
|
||||
var def = _content.Quests.TryGetValue(st.QuestId, out var d) ? d : null;
|
||||
string title = def?.Title ?? st.QuestId;
|
||||
string mark = st.Status == QuestStatus.Completed ? "✓" : "✗";
|
||||
Color color = st.Status == QuestStatus.Completed
|
||||
? new Color(140, 200, 130)
|
||||
: new Color(200, 130, 130);
|
||||
rightCol.Widgets.Add(new Label
|
||||
{
|
||||
Text = $" {mark} {title}",
|
||||
TextColor = color,
|
||||
});
|
||||
}
|
||||
twoCol.Widgets.Add(rightCol);
|
||||
|
||||
root.Widgets.Add(twoCol);
|
||||
|
||||
// Recent journal tail.
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
root.Widgets.Add(new Label { Text = "RECENT", TextColor = new Color(200, 180, 130) });
|
||||
var tail = _engine.Journal.Skip(System.Math.Max(0, _engine.Journal.Count - 8)).ToList();
|
||||
if (tail.Count == 0)
|
||||
root.Widgets.Add(new Label { Text = " (no entries)", TextColor = new Color(120, 110, 100) });
|
||||
foreach (var line in tail)
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $" {line}",
|
||||
TextColor = new Color(180, 180, 170),
|
||||
Wrap = true,
|
||||
Width = 840,
|
||||
});
|
||||
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = "(J / Esc to close)",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(120, 110, 100),
|
||||
});
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
var ks = Keyboard.GetState();
|
||||
bool j = ks.IsKeyDown(Keys.J);
|
||||
bool esc = ks.IsKeyDown(Keys.Escape);
|
||||
bool jPressed = j && !_jWasDown;
|
||||
bool escPressed = esc && !_escWasDown;
|
||||
_jWasDown = j; _escWasDown = esc;
|
||||
if (jPressed || escPressed) _game.Screens.Pop();
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch sb) => _desktop.Render();
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { BuildUI(); }
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Reputation;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M2 — reputation screen (R key). Two columns:
|
||||
/// 1. **Factions:** strip of bars per known faction with NEMESIS..CHAMPION
|
||||
/// colour banding + numeric standing.
|
||||
/// 2. **Recent contacts:** last N NPCs the player has personally
|
||||
/// interacted with — name, role, current personal disposition,
|
||||
/// trust ladder.
|
||||
/// Plus a tail block showing the most recent ledger entries with their
|
||||
/// reasons ("why does so-and-so hate me?" breadcrumbs).
|
||||
///
|
||||
/// Hidden factions (e.g. The Maw before Act I climax) are skipped from
|
||||
/// the faction column; they still accumulate state internally.
|
||||
///
|
||||
/// In debug builds, F12 fires a synthetic "+5 Inheritor" event so we can
|
||||
/// eyeball the cascade live without scripting a quest.
|
||||
/// </summary>
|
||||
public sealed class ReputationScreen : IScreen
|
||||
{
|
||||
private readonly PlayerReputation _rep;
|
||||
private readonly ContentResolver _content;
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private bool _rWasDown = true;
|
||||
private bool _escWasDown = true;
|
||||
private bool _f12WasDown = true;
|
||||
|
||||
public ReputationScreen(PlayerReputation rep, ContentResolver content)
|
||||
{
|
||||
_rep = rep ?? throw new System.ArgumentNullException(nameof(rep));
|
||||
_content = content ?? throw new System.ArgumentNullException(nameof(content));
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
BuildUI();
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(20),
|
||||
Padding = new Thickness(20, 12, 20, 12),
|
||||
Background = new SolidBrush(new Color(15, 12, 8, 235)),
|
||||
Width = 880,
|
||||
};
|
||||
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = "REPUTATION",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(255, 230, 170),
|
||||
});
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
var twoCol = new HorizontalStackPanel { Spacing = 24 };
|
||||
|
||||
// ── Factions column ─────────────────────────────────────────────
|
||||
var leftCol = new VerticalStackPanel { Spacing = 4, Width = 420 };
|
||||
leftCol.Widgets.Add(new Label { Text = "FACTIONS", TextColor = new Color(200, 180, 130) });
|
||||
foreach (var f in _content.Factions.Values.Where(f => !f.Hidden).OrderBy(f => f.Name))
|
||||
{
|
||||
int score = _rep.Factions.Get(f.Id);
|
||||
var label = DispositionLabels.For(score);
|
||||
leftCol.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"{f.Name,-24} {score,+4:+#;-#;0} {DispositionLabels.DisplayName(label)}",
|
||||
TextColor = ColorForLabel(label),
|
||||
});
|
||||
}
|
||||
twoCol.Widgets.Add(leftCol);
|
||||
|
||||
// ── Personal column ────────────────────────────────────────────
|
||||
var rightCol = new VerticalStackPanel { Spacing = 4, Width = 420 };
|
||||
rightCol.Widgets.Add(new Label { Text = "RECENT CONTACTS", TextColor = new Color(200, 180, 130) });
|
||||
var personals = _rep.Personal.Values.OrderByDescending(p => p.LastInteractionSeconds).Take(12).ToList();
|
||||
if (personals.Count == 0)
|
||||
rightCol.Widgets.Add(new Label
|
||||
{
|
||||
Text = "(no one has met you yet)",
|
||||
TextColor = new Color(120, 110, 100),
|
||||
});
|
||||
foreach (var p in personals)
|
||||
{
|
||||
var label = DispositionLabels.For(p.Score);
|
||||
rightCol.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"{p.RoleTag,-30} {p.Score,+4:+#;-#;0} {p.Trust}",
|
||||
TextColor = ColorForLabel(label),
|
||||
});
|
||||
}
|
||||
twoCol.Widgets.Add(rightCol);
|
||||
root.Widgets.Add(twoCol);
|
||||
|
||||
// ── Recent ledger ──────────────────────────────────────────────
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
root.Widgets.Add(new Label { Text = "RECENT EVENTS", TextColor = new Color(200, 180, 130) });
|
||||
var recent = _rep.Ledger.Entries.Reverse().Take(10).ToList();
|
||||
if (recent.Count == 0)
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = "(no events yet)",
|
||||
TextColor = new Color(120, 110, 100),
|
||||
});
|
||||
foreach (var ev in recent)
|
||||
{
|
||||
string what = string.IsNullOrEmpty(ev.FactionId)
|
||||
? (string.IsNullOrEmpty(ev.RoleTag) ? "world" : ev.RoleTag)
|
||||
: ev.FactionId;
|
||||
string note = string.IsNullOrEmpty(ev.Note) ? "" : $" — {ev.Note}";
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $" [{ev.Kind,-9}] {what,-26} {ev.Magnitude,+4:+#;-#;0}{note}",
|
||||
TextColor = ev.Magnitude >= 0 ? new Color(160, 200, 140) : new Color(220, 140, 140),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Footer ─────────────────────────────────────────────────────
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = "(R / Esc to close · F12 in debug build = +5 Inheritor synthetic event)",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(120, 110, 100),
|
||||
});
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
var ks = Keyboard.GetState();
|
||||
bool r = ks.IsKeyDown(Keys.R);
|
||||
bool esc = ks.IsKeyDown(Keys.Escape);
|
||||
bool f12 = ks.IsKeyDown(Keys.F12);
|
||||
|
||||
bool rPressed = r && !_rWasDown;
|
||||
bool escPressed = esc && !_escWasDown;
|
||||
bool f12Pressed = f12 && !_f12WasDown;
|
||||
|
||||
_rWasDown = r; _escWasDown = esc; _f12WasDown = f12;
|
||||
|
||||
if (rPressed || escPressed) { _game.Screens.Pop(); return; }
|
||||
|
||||
#if DEBUG
|
||||
if (f12Pressed)
|
||||
{
|
||||
// Dev affordance — fire a +5 Inheritor event so the cascade is
|
||||
// visible to the eye without authoring a quest.
|
||||
_rep.Submit(new RepEvent
|
||||
{
|
||||
Kind = RepEventKind.Misc,
|
||||
FactionId = "inheritors",
|
||||
Magnitude = 5,
|
||||
Note = "dev affordance (F12)",
|
||||
}, _content.Factions);
|
||||
BuildUI(); // refresh
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch sb) => _desktop.Render();
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
|
||||
private static Color ColorForLabel(DispositionLabel label) => label switch
|
||||
{
|
||||
DispositionLabel.Nemesis => new Color(220, 60, 60),
|
||||
DispositionLabel.Hostile => new Color(220, 110, 90),
|
||||
DispositionLabel.Antagonistic => new Color(220, 160, 110),
|
||||
DispositionLabel.Unfriendly => new Color(200, 180, 130),
|
||||
DispositionLabel.Neutral => new Color(180, 180, 180),
|
||||
DispositionLabel.Favorable => new Color(170, 200, 150),
|
||||
DispositionLabel.Friendly => new Color(140, 210, 130),
|
||||
DispositionLabel.Allied => new Color(110, 220, 130),
|
||||
DispositionLabel.Champion => new Color(150, 230, 200),
|
||||
_ => Color.White,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.Persistence;
|
||||
using Theriapolis.Game.Platform;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Slot picker. Lists C.SAVE_SLOT_COUNT slots plus the autosave slot. Reading
|
||||
/// each slot only deserializes the JSON header (cheap), so opening the picker
|
||||
/// is fast even if there are many large saves.
|
||||
///
|
||||
/// Phase 4 mode: load only (called from TitleScreen). Save-from-game uses the
|
||||
/// F5 quicksave; a save-as-slot UI can be added later by extending this screen
|
||||
/// with an Action.
|
||||
/// </summary>
|
||||
public sealed class SaveLoadScreen : IScreen
|
||||
{
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
BuildUI();
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = "LOAD GAME",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Autosave row first.
|
||||
AddSlotRow(root, "Autosave", SavePaths.AutosavePath());
|
||||
for (int i = 1; i <= C.SAVE_SLOT_COUNT; i++)
|
||||
AddSlotRow(root, $"Slot {i:D2}", SavePaths.SlotPath(i));
|
||||
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
var back = new TextButton { Text = "Back", Width = 200, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
back.Click += (_, _) => _game.Screens.Pop();
|
||||
root.Widgets.Add(back);
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
private void AddSlotRow(VerticalStackPanel parent, string label, string path)
|
||||
{
|
||||
string text = label;
|
||||
bool exists = File.Exists(path);
|
||||
bool compatible = false;
|
||||
if (exists)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
var header = SaveCodec.DeserializeHeaderOnly(bytes);
|
||||
if (SaveCodec.IsCompatible(header))
|
||||
{
|
||||
text = $"{label}: {header.SlotLabel()}";
|
||||
compatible = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
text = $"{label}: <v{header.Version} — incompatible (Phase 5+ only)>";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
text = $"{label}: <unreadable>";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
text = $"{label}: <empty>";
|
||||
}
|
||||
|
||||
var btn = new TextButton
|
||||
{
|
||||
Text = text,
|
||||
Width = 480,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
if (exists && compatible) btn.Click += (_, _) => LoadSlot(path);
|
||||
else btn.Enabled = false;
|
||||
parent.Widgets.Add(btn);
|
||||
}
|
||||
|
||||
private void LoadSlot(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
var headerOnly = SaveCodec.DeserializeHeaderOnly(bytes);
|
||||
if (!SaveCodec.IsCompatible(headerOnly))
|
||||
{
|
||||
var err = new Label
|
||||
{
|
||||
Text = SaveCodec.IncompatibilityReason(headerOnly),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
_desktop.Root = err;
|
||||
return;
|
||||
}
|
||||
var (header, body) = SaveCodec.Deserialize(bytes);
|
||||
_game.Screens.Pop(); // back to title
|
||||
_game.Screens.Push(new WorldGenProgressScreen(header.ParseSeed(), restoreFromSave: body, savedHeader: header));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Crude error display: replace the screen content with the error.
|
||||
var err = new Label { Text = $"Load failed:\n{ex.Message}", HorizontalAlignment = HorizontalAlignment.Center };
|
||||
_desktop.Root = err;
|
||||
}
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
if (Keyboard.GetState().IsKeyDown(Keys.Escape)) _game.Screens.Pop();
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch sb)
|
||||
{
|
||||
_game.GraphicsDevice.Clear(new Color(20, 20, 30));
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Manages a stack of IScreen instances.
|
||||
/// Only the top screen receives Update and Draw calls.
|
||||
/// Push/Pop are deferred to the start of the next frame to avoid mid-loop mutation.
|
||||
/// </summary>
|
||||
public sealed class ScreenManager
|
||||
{
|
||||
private readonly Game1 _game;
|
||||
private readonly Stack<IScreen> _stack = new();
|
||||
|
||||
private IScreen? _pendingPush;
|
||||
private int _pendingPops; // count rather than bool — multiple Pops queued in one frame all apply
|
||||
|
||||
public ScreenManager(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
}
|
||||
|
||||
public IScreen? Current => _stack.Count > 0 ? _stack.Peek() : null;
|
||||
|
||||
public void Push(IScreen screen)
|
||||
{
|
||||
_pendingPush = screen;
|
||||
}
|
||||
|
||||
public void Pop()
|
||||
{
|
||||
_pendingPops++;
|
||||
}
|
||||
|
||||
/// <summary>Process any pending push/pop, then update the current screen.</summary>
|
||||
public void Update(GameTime gameTime)
|
||||
{
|
||||
// Apply deferred transitions — drain all pending pops before any push.
|
||||
bool popped = false;
|
||||
while (_pendingPops > 0 && _stack.Count > 0)
|
||||
{
|
||||
var top = _stack.Pop();
|
||||
top.Deactivate();
|
||||
_pendingPops--;
|
||||
popped = true;
|
||||
}
|
||||
_pendingPops = 0;
|
||||
// Only call Reactivate once after a pop chain — not every frame.
|
||||
if (popped && _stack.TryPeek(out var back)) back.Reactivate();
|
||||
|
||||
if (_pendingPush is not null)
|
||||
{
|
||||
_stack.TryPeek(out var cur);
|
||||
cur?.Deactivate();
|
||||
_pendingPush.Initialize(_game);
|
||||
_stack.Push(_pendingPush);
|
||||
_pendingPush = null;
|
||||
}
|
||||
|
||||
Current?.Update(gameTime);
|
||||
}
|
||||
|
||||
public void Draw(GameTime gameTime, SpriteBatch spriteBatch)
|
||||
{
|
||||
Current?.Draw(gameTime, spriteBatch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Dialogue;
|
||||
using Theriapolis.Core.Rules.Reputation;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M3 — buy/sell modal pushed from <see cref="InteractionScreen"/>
|
||||
/// when a dialogue option fires the <c>open_shop</c> effect.
|
||||
///
|
||||
/// Pricing applies the disposition modifier from
|
||||
/// <see cref="ShopPricing"/>: a friendly merchant sells for cheaper, an
|
||||
/// antagonistic one marks up. Hostile / Nemesis merchants refuse service
|
||||
/// outright (the screen shows a refusal line and only the close button).
|
||||
///
|
||||
/// Phase 6 M3 ships a hand-curated stock list per merchant role
|
||||
/// (innkeeper / shopkeeper / smith / alchemist) — Phase 6 M5 swaps this
|
||||
/// for trade-route-driven inventories.
|
||||
/// </summary>
|
||||
public sealed class ShopScreen : IScreen
|
||||
{
|
||||
private static readonly string[] InnkeeperStock = { "rations_predator", "rations_prey", "poultice_universal" };
|
||||
private static readonly string[] ShopkeeperStock = { "rope_claw_braid", "torch_scent_neutral", "scent_mask_basic", "rations_predator", "poultice_universal", "healers_kit" };
|
||||
private static readonly string[] SmithStock = { "fang_knife", "rend_sword", "thorn_blade", "paw_axe", "hide_vest", "leather_harness", "studded_leather", "chain_shirt", "buckler", "standard_shield" };
|
||||
private static readonly string[] AlchemistStock = { "poultice_universal", "poultice_canid", "healers_kit", "scent_mask_basic", "pheromone_vial_calm", "pheromone_vial_fear" };
|
||||
|
||||
private readonly NpcActor _npc;
|
||||
private readonly Character _pc;
|
||||
private readonly ContentResolver _content;
|
||||
private readonly PlayScreen _playScreen;
|
||||
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private VerticalStackPanel _root = null!;
|
||||
private Label _statusLabel = null!;
|
||||
private VerticalStackPanel _stockList = null!;
|
||||
private VerticalStackPanel _bagList = null!;
|
||||
private bool _escWasDown = true;
|
||||
private bool _enterWasDown = true;
|
||||
|
||||
public ShopScreen(NpcActor npc, Character pc, ContentResolver content, PlayScreen playScreen)
|
||||
{
|
||||
_npc = npc;
|
||||
_pc = pc;
|
||||
_content = content;
|
||||
_playScreen = playScreen;
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
BuildLayout();
|
||||
}
|
||||
|
||||
private int Disposition()
|
||||
=> EffectiveDisposition.For(_npc, _pc, _playScreen.Reputation, _content,
|
||||
_playScreen.World(), _playScreen.WorldSeed());
|
||||
|
||||
private string[] StockForRole(string roleTag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(roleTag)) return ShopkeeperStock;
|
||||
string suffix = roleTag;
|
||||
int dot = roleTag.LastIndexOf('.');
|
||||
if (dot >= 0) suffix = roleTag[(dot + 1)..];
|
||||
return suffix.ToLowerInvariant() switch
|
||||
{
|
||||
"innkeeper" => InnkeeperStock,
|
||||
"smith" => SmithStock,
|
||||
"alchemist" => AlchemistStock,
|
||||
_ => ShopkeeperStock,
|
||||
};
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
_root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Padding = new Thickness(40, 24, 40, 24),
|
||||
Background = new SolidBrush(new Color(15, 12, 8, 240)),
|
||||
Width = 760,
|
||||
};
|
||||
|
||||
_root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"{_npc.DisplayName} — wares",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(255, 230, 170),
|
||||
});
|
||||
|
||||
int disp = Disposition();
|
||||
var label = DispositionLabels.For(disp);
|
||||
if (!ShopPricing.ServiceAvailable(disp))
|
||||
{
|
||||
_root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"\"I'll not deal with you. Get out before I call the constabulary.\"",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(220, 120, 120),
|
||||
Wrap = true,
|
||||
Width = 660,
|
||||
});
|
||||
_root.Widgets.Add(new Label { Text = " " });
|
||||
var closeRefused = new TextButton { Text = "Leave", Width = 240, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
closeRefused.Click += (_, _) => _game.Screens.Pop();
|
||||
_root.Widgets.Add(closeRefused);
|
||||
_desktop = new Desktop { Root = _root };
|
||||
return;
|
||||
}
|
||||
|
||||
_root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"[{DispositionLabels.DisplayName(label)}] · buy ×{ShopPricing.BuyMultiplier(disp):0.00} · sell ×{ShopPricing.SellMultiplier(disp):0.00}",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(120, 140, 180),
|
||||
});
|
||||
_statusLabel = new Label
|
||||
{
|
||||
Text = $"Your fangs: {_pc.CurrencyFang}",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(220, 200, 140),
|
||||
};
|
||||
_root.Widgets.Add(_statusLabel);
|
||||
_root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
var twoCol = new HorizontalStackPanel { Spacing = 24 };
|
||||
|
||||
_stockList = new VerticalStackPanel { Spacing = 2, Width = 360 };
|
||||
_stockList.Widgets.Add(new Label { Text = "BUY", TextColor = new Color(200, 180, 130) });
|
||||
twoCol.Widgets.Add(_stockList);
|
||||
|
||||
_bagList = new VerticalStackPanel { Spacing = 2, Width = 360 };
|
||||
_bagList.Widgets.Add(new Label { Text = "SELL (your bag)", TextColor = new Color(200, 180, 130) });
|
||||
twoCol.Widgets.Add(_bagList);
|
||||
|
||||
_root.Widgets.Add(twoCol);
|
||||
_root.Widgets.Add(new Label { Text = " " });
|
||||
_root.Widgets.Add(new Label
|
||||
{
|
||||
Text = "(Click an item to buy/sell · Esc / Enter to close)",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextColor = new Color(120, 110, 100),
|
||||
});
|
||||
|
||||
RefreshLists();
|
||||
_desktop = new Desktop { Root = _root };
|
||||
}
|
||||
|
||||
private void RefreshLists()
|
||||
{
|
||||
// Rebuild buy list.
|
||||
for (int i = _stockList.Widgets.Count - 1; i >= 1; i--) _stockList.Widgets.RemoveAt(i);
|
||||
int disp = Disposition();
|
||||
var stock = StockForRole(_npc.RoleTag);
|
||||
foreach (var id in stock)
|
||||
{
|
||||
if (!_content.Items.TryGetValue(id, out var def)) continue;
|
||||
int price = ShopPricing.BuyPriceFor((int)System.Math.Round(def.CostFang), disp);
|
||||
var btn = new TextButton
|
||||
{
|
||||
Text = $" {def.Name,-26} {price,4}f",
|
||||
Width = 350,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
};
|
||||
string capturedId = def.Id;
|
||||
btn.Click += (_, _) => TryBuy(capturedId);
|
||||
_stockList.Widgets.Add(btn);
|
||||
}
|
||||
|
||||
// Rebuild sell list — group by item name + count, sell one at a time.
|
||||
for (int i = _bagList.Widgets.Count - 1; i >= 1; i--) _bagList.Widgets.RemoveAt(i);
|
||||
var grouped = _pc.Inventory.Items
|
||||
.Where(it => it.EquippedAt is null) // can't sell equipped gear without unequipping first
|
||||
.GroupBy(it => it.Def.Id)
|
||||
.OrderBy(g => g.Key, System.StringComparer.Ordinal);
|
||||
foreach (var grp in grouped)
|
||||
{
|
||||
var def = grp.First().Def;
|
||||
int totalQty = grp.Sum(it => it.Qty);
|
||||
int price = ShopPricing.SellPriceFor((int)System.Math.Round(def.CostFang), disp);
|
||||
var btn = new TextButton
|
||||
{
|
||||
Text = $" {def.Name,-22} ×{totalQty,2} @ {price,4}f",
|
||||
Width = 350,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
};
|
||||
string capturedId = def.Id;
|
||||
btn.Click += (_, _) => TrySell(capturedId);
|
||||
_bagList.Widgets.Add(btn);
|
||||
}
|
||||
_statusLabel.Text = $"Your fangs: {_pc.CurrencyFang}";
|
||||
}
|
||||
|
||||
private void TryBuy(string itemId)
|
||||
{
|
||||
if (!_content.Items.TryGetValue(itemId, out var def)) return;
|
||||
int disp = Disposition();
|
||||
int price = ShopPricing.BuyPriceFor((int)System.Math.Round(def.CostFang), disp);
|
||||
if (_pc.CurrencyFang < price)
|
||||
{
|
||||
_statusLabel.Text = $"Not enough fangs ({_pc.CurrencyFang} / {price}).";
|
||||
return;
|
||||
}
|
||||
_pc.CurrencyFang -= price;
|
||||
_pc.Inventory.Add(def, 1);
|
||||
_playScreen.Reputation.Submit(new RepEvent
|
||||
{
|
||||
Kind = RepEventKind.Trade,
|
||||
FactionId = _npc.FactionId,
|
||||
RoleTag = _npc.RoleTag,
|
||||
Magnitude = 1, // small positive bump per successful purchase
|
||||
Note = $"bought {def.Id}",
|
||||
TimestampSeconds = _playScreen.ClockSeconds(),
|
||||
}, _content.Factions);
|
||||
RefreshLists();
|
||||
}
|
||||
|
||||
private void TrySell(string itemId)
|
||||
{
|
||||
if (!_content.Items.TryGetValue(itemId, out var def)) return;
|
||||
int disp = Disposition();
|
||||
int price = ShopPricing.SellPriceFor((int)System.Math.Round(def.CostFang), disp);
|
||||
// Remove ONE unit (smallest stack first to keep stacks tidy).
|
||||
var stack = _pc.Inventory.Items.Where(it => it.Def.Id == itemId && it.EquippedAt is null)
|
||||
.OrderBy(it => it.Qty)
|
||||
.FirstOrDefault();
|
||||
if (stack is null) return;
|
||||
stack.Qty--;
|
||||
if (stack.Qty <= 0) _pc.Inventory.Remove(stack);
|
||||
_pc.CurrencyFang += price;
|
||||
RefreshLists();
|
||||
}
|
||||
|
||||
public void Update(GameTime gt)
|
||||
{
|
||||
var ks = Keyboard.GetState();
|
||||
bool esc = ks.IsKeyDown(Keys.Escape);
|
||||
bool ent = ks.IsKeyDown(Keys.Enter);
|
||||
bool escPressed = esc && !_escWasDown;
|
||||
bool entPressed = ent && !_enterWasDown;
|
||||
_escWasDown = esc; _enterWasDown = ent;
|
||||
if (escPressed || entPressed) _game.Screens.Pop();
|
||||
}
|
||||
|
||||
public void Draw(GameTime gt, SpriteBatch sb) => _desktop.Render();
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { RefreshLists(); }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Myra;
|
||||
using Myra.Graphics2D.UI;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Title screen: game logo text, "New World" button, optional seed input field.
|
||||
/// Uses Myra for all UI widgets.
|
||||
/// </summary>
|
||||
public sealed class TitleScreen : IScreen
|
||||
{
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private TextBox? _seedInput;
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
BuildUI();
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
|
||||
// Title label
|
||||
var title = new Label
|
||||
{
|
||||
Text = "THERIAPOLIS",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
root.Widgets.Add(title);
|
||||
|
||||
// Sub-title
|
||||
var sub = new Label
|
||||
{
|
||||
Text = "Veldara awaits.",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
root.Widgets.Add(sub);
|
||||
|
||||
// Spacer
|
||||
root.Widgets.Add(new Label { Text = " " });
|
||||
|
||||
// Seed row
|
||||
var seedRow = new HorizontalStackPanel { Spacing = 8 };
|
||||
seedRow.Widgets.Add(new Label { Text = "Seed:", VerticalAlignment = VerticalAlignment.Center });
|
||||
_seedInput = new TextBox
|
||||
{
|
||||
Text = "",
|
||||
Width = 160,
|
||||
};
|
||||
seedRow.Widgets.Add(_seedInput);
|
||||
root.Widgets.Add(seedRow);
|
||||
|
||||
// New World button
|
||||
var newWorldBtn = new TextButton
|
||||
{
|
||||
Text = "New World",
|
||||
Width = 180,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
newWorldBtn.Click += OnNewWorldClicked;
|
||||
root.Widgets.Add(newWorldBtn);
|
||||
|
||||
var loadBtn = new TextButton
|
||||
{
|
||||
Text = "Load Game",
|
||||
Width = 180,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
loadBtn.Click += (_, _) => _game.Screens.Push(new SaveLoadScreen());
|
||||
root.Widgets.Add(loadBtn);
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
private void OnNewWorldClicked(object? sender, EventArgs e)
|
||||
{
|
||||
ulong seed;
|
||||
string raw = _seedInput?.Text?.Trim() ?? "";
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
// Random seed from system time
|
||||
seed = (ulong)DateTime.UtcNow.Ticks;
|
||||
}
|
||||
else if (!ulong.TryParse(raw, out seed))
|
||||
{
|
||||
// Hash the string to a seed
|
||||
seed = 0;
|
||||
foreach (char c in raw) seed = seed * 31 + c;
|
||||
}
|
||||
|
||||
_game.Screens.Push(new CodexUI.Screens.CodexCharacterCreationScreen(seed));
|
||||
}
|
||||
|
||||
public void Update(GameTime gameTime) { }
|
||||
|
||||
public void Draw(GameTime gameTime, SpriteBatch spriteBatch)
|
||||
{
|
||||
_game.GraphicsDevice.Clear(new Color(20, 20, 30));
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Myra;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core.Persistence;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the world-generation pipeline on a background thread and shows per-stage progress.
|
||||
/// Transitions to WorldMapScreen when generation is complete.
|
||||
/// </summary>
|
||||
public sealed class WorldGenProgressScreen : IScreen
|
||||
{
|
||||
private readonly ulong _seed;
|
||||
private readonly SaveBody? _restoreFromSave;
|
||||
private readonly SaveHeader? _savedHeader;
|
||||
private readonly Character? _pendingCharacter;
|
||||
private readonly string? _pendingName;
|
||||
private Game1 _game = null!;
|
||||
private Desktop _desktop = null!;
|
||||
private Label? _stageLabel;
|
||||
private Label? _progressLabel;
|
||||
|
||||
private WorldGenContext? _ctx;
|
||||
private Task? _genTask;
|
||||
private volatile float _progress;
|
||||
private volatile string _stageName = "Initialising...";
|
||||
private volatile bool _complete;
|
||||
private volatile string? _error;
|
||||
|
||||
public WorldGenProgressScreen(
|
||||
ulong seed,
|
||||
SaveBody? restoreFromSave = null,
|
||||
SaveHeader? savedHeader = null,
|
||||
Character? pendingCharacter = null,
|
||||
string? pendingName = null)
|
||||
{
|
||||
_seed = seed;
|
||||
_restoreFromSave = restoreFromSave;
|
||||
_savedHeader = savedHeader;
|
||||
_pendingCharacter = pendingCharacter;
|
||||
_pendingName = pendingName;
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
BuildUI();
|
||||
StartGeneration();
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var root = new VerticalStackPanel
|
||||
{
|
||||
Spacing = 16,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
|
||||
root.Widgets.Add(new Label
|
||||
{
|
||||
Text = $"Generating world... (seed: 0x{_seed:X})",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
});
|
||||
|
||||
_progressLabel = new Label
|
||||
{
|
||||
Text = "[ ] 0%",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
root.Widgets.Add(_progressLabel);
|
||||
|
||||
_stageLabel = new Label
|
||||
{
|
||||
Text = "Starting...",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
root.Widgets.Add(_stageLabel);
|
||||
|
||||
_desktop = new Desktop { Root = root };
|
||||
}
|
||||
|
||||
private void StartGeneration()
|
||||
{
|
||||
string dataDir = _game.ContentDataDirectory;
|
||||
_genTask = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_ctx = new WorldGenContext(_seed, dataDir)
|
||||
{
|
||||
ProgressCallback = (name, frac) =>
|
||||
{
|
||||
_stageName = name;
|
||||
_progress = frac;
|
||||
},
|
||||
Log = msg => System.Diagnostics.Debug.WriteLine(msg),
|
||||
};
|
||||
WorldGenerator.RunAll(_ctx);
|
||||
_complete = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Unwrap AggregateException to get the real inner message
|
||||
var inner = ex is AggregateException ae ? ae.Flatten().InnerException ?? ex : ex;
|
||||
_error = inner.ToString(); // full type + message + stack trace
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Update(GameTime gameTime)
|
||||
{
|
||||
if (_error is not null)
|
||||
{
|
||||
// Show error on screen so it is visible; do NOT pop automatically.
|
||||
System.Diagnostics.Debug.WriteLine($"[WorldGen ERROR] {_error}");
|
||||
if (_stageLabel is not null) _stageLabel.Text = "ERROR — press Escape to go back";
|
||||
if (_progressLabel is not null) _progressLabel.Text = _error.Length > 80
|
||||
? _error[..80] + "..."
|
||||
: _error;
|
||||
// Write full error to a log file next to the exe for post-mortem diagnosis
|
||||
try
|
||||
{
|
||||
string logPath = Path.Combine(
|
||||
AppContext.BaseDirectory, "worldgen_error.log");
|
||||
File.WriteAllText(logPath,
|
||||
$"[{DateTime.Now:u}] WorldGen ERROR\n{_error}\n");
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
|
||||
// Only pop when the user presses Escape
|
||||
if (Microsoft.Xna.Framework.Input.Keyboard.GetState()
|
||||
.IsKeyDown(Microsoft.Xna.Framework.Input.Keys.Escape))
|
||||
_game.Screens.Pop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_complete && _ctx is not null)
|
||||
{
|
||||
// Stage-hash check: a soft warning is fine for Phase 4. We log
|
||||
// mismatches but proceed — saves anchored only by player position
|
||||
// and chunk deltas tolerate small worldgen drift.
|
||||
if (_savedHeader is not null) CompareStageHashes();
|
||||
|
||||
if (_restoreFromSave is not null)
|
||||
_game.Screens.Push(new PlayScreen(_ctx, _restoreFromSave));
|
||||
else if (_pendingCharacter is not null)
|
||||
_game.Screens.Push(new PlayScreen(_ctx, _pendingCharacter, _pendingName ?? "Wanderer"));
|
||||
else
|
||||
_game.Screens.Push(new PlayScreen(_ctx));
|
||||
|
||||
_complete = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update UI progress on game thread
|
||||
int pct = (int)(_progress * 100f);
|
||||
int filled = pct / 10;
|
||||
string bar = new string('#', filled) + new string(' ', 10 - filled);
|
||||
if (_progressLabel is not null) _progressLabel.Text = $"[{bar}] {pct,3}%";
|
||||
if (_stageLabel is not null) _stageLabel.Text = _stageName;
|
||||
}
|
||||
|
||||
public void Draw(GameTime gameTime, SpriteBatch spriteBatch)
|
||||
{
|
||||
_game.GraphicsDevice.Clear(new Color(10, 10, 20));
|
||||
_desktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
|
||||
private void CompareStageHashes()
|
||||
{
|
||||
if (_savedHeader is null || _ctx is null) return;
|
||||
int mismatches = 0;
|
||||
foreach (var kv in _ctx.World.StageHashes)
|
||||
{
|
||||
if (!_savedHeader.StageHashes.TryGetValue(kv.Key, out var sv)) continue;
|
||||
string current = $"0x{kv.Value:X}";
|
||||
if (!string.Equals(sv, current, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mismatches++;
|
||||
System.Diagnostics.Debug.WriteLine(
|
||||
$"[Save migration] Stage '{kv.Key}' hash drift: saved={sv}, current={current}");
|
||||
}
|
||||
}
|
||||
if (mismatches > 0)
|
||||
System.Diagnostics.Debug.WriteLine(
|
||||
$"[Save migration] {mismatches} stage(s) drifted; loading anyway (soft).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Microsoft.Xna.Framework.Input;
|
||||
using Myra.Graphics2D;
|
||||
using Myra.Graphics2D.Brushes;
|
||||
using Myra.Graphics2D.UI;
|
||||
using Theriapolis.Core;
|
||||
using Theriapolis.Core.World.Generation;
|
||||
using Theriapolis.Game.Input;
|
||||
using Theriapolis.Game.Platform;
|
||||
using Theriapolis.Game.Rendering;
|
||||
|
||||
namespace Theriapolis.Game.Screens;
|
||||
|
||||
/// <summary>
|
||||
/// World-map screen: pan with left-drag or WASD, zoom with mouse wheel.
|
||||
/// Shows the biome tile map produced by Phase 1 worldgen.
|
||||
/// A top-left debug overlay shows the world seed and the tile under the cursor;
|
||||
/// clicking the map copies "seed=N tile=(X,Y)" to the system clipboard.
|
||||
/// </summary>
|
||||
public sealed class WorldMapScreen : IScreen
|
||||
{
|
||||
private readonly WorldGenContext _ctx;
|
||||
private Game1 _game = null!;
|
||||
|
||||
private Camera2D _camera = null!;
|
||||
private TileAtlas _atlas = null!;
|
||||
private WorldMapRenderer _renderer = null!;
|
||||
private InputManager _input = null!;
|
||||
private SpriteBatch _sb = null!;
|
||||
|
||||
// Debug overlay
|
||||
private Desktop _overlayDesktop = null!;
|
||||
private Label _debugLabel = null!;
|
||||
private int _cursorTileX;
|
||||
private int _cursorTileY;
|
||||
|
||||
// Click-vs-drag detection. Tile is captured at mouse-down so that incidental
|
||||
// camera pan from hand-jitter between press and release doesn't shift the
|
||||
// reported tile — at fit-zoom, one screen pixel of drag is ~15 world pixels.
|
||||
private Vector2 _mouseDownPos;
|
||||
private int _mouseDownTileX;
|
||||
private int _mouseDownTileY;
|
||||
private bool _mouseDownTracked;
|
||||
private const float ClickSlopPixels = 4f;
|
||||
|
||||
public WorldMapScreen(WorldGenContext ctx)
|
||||
{
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
public void Initialize(Game1 game)
|
||||
{
|
||||
_game = game;
|
||||
_input = new InputManager();
|
||||
_sb = new SpriteBatch(game.GraphicsDevice);
|
||||
|
||||
var gdw = new GraphicsDeviceWrapper(game.GraphicsDevice);
|
||||
_camera = new Camera2D(gdw);
|
||||
|
||||
// Start camera centred on the world
|
||||
_camera.Position = new Vector2(
|
||||
C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS * 0.5f,
|
||||
C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS * 0.5f);
|
||||
// Default zoom: fit the world in the window
|
||||
float fitZoom = Math.Min(
|
||||
(float)game.GraphicsDevice.Viewport.Width / (C.WORLD_WIDTH_TILES * C.WORLD_TILE_PIXELS),
|
||||
(float)game.GraphicsDevice.Viewport.Height / (C.WORLD_HEIGHT_TILES * C.WORLD_TILE_PIXELS));
|
||||
_camera.AdjustZoom(fitZoom / Camera2D.MinZoom - 1f, new Vector2(
|
||||
game.GraphicsDevice.Viewport.Width * 0.5f,
|
||||
game.GraphicsDevice.Viewport.Height * 0.5f));
|
||||
|
||||
// Build tile atlas from generated biome defs
|
||||
_atlas = new TileAtlas(game.GraphicsDevice);
|
||||
_atlas.GeneratePlaceholders(_ctx.World.BiomeDefs!);
|
||||
|
||||
_renderer = new WorldMapRenderer(_ctx, _atlas);
|
||||
|
||||
BuildOverlay();
|
||||
}
|
||||
|
||||
private void BuildOverlay()
|
||||
{
|
||||
_debugLabel = new Label
|
||||
{
|
||||
Text = "",
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(8),
|
||||
Padding = new Thickness(8, 4, 8, 4),
|
||||
Background = new SolidBrush(new Color(0, 0, 0, 180)),
|
||||
};
|
||||
_overlayDesktop = new Desktop { Root = _debugLabel };
|
||||
UpdateOverlayText();
|
||||
}
|
||||
|
||||
public void Update(GameTime gameTime)
|
||||
{
|
||||
_input.Update();
|
||||
|
||||
// Ignore input when the game window isn't focused. Otherwise, clicks on
|
||||
// other windows (e.g. the Claude desktop app) would still register here
|
||||
// and overwrite the clipboard with a bogus tile coordinate.
|
||||
if (!_game.IsActive) return;
|
||||
|
||||
// ESC → back to title
|
||||
if (_input.JustPressed(Keys.Escape))
|
||||
{
|
||||
_game.Screens.Pop();
|
||||
return;
|
||||
}
|
||||
|
||||
float dt = (float)gameTime.ElapsedGameTime.TotalSeconds;
|
||||
float panSpeed = 400f / _camera.Zoom; // world pixels per second
|
||||
|
||||
// Keyboard pan (WASD / arrow keys)
|
||||
Vector2 panDir = Vector2.Zero;
|
||||
if (_input.IsDown(Keys.W) || _input.IsDown(Keys.Up)) panDir.Y -= 1;
|
||||
if (_input.IsDown(Keys.S) || _input.IsDown(Keys.Down)) panDir.Y += 1;
|
||||
if (_input.IsDown(Keys.A) || _input.IsDown(Keys.Left)) panDir.X -= 1;
|
||||
if (_input.IsDown(Keys.D) || _input.IsDown(Keys.Right)) panDir.X += 1;
|
||||
if (panDir != Vector2.Zero)
|
||||
_camera.Pan(panDir * panSpeed * dt);
|
||||
|
||||
// Track mouse-down position and tile BEFORE drag-handling consumes the
|
||||
// frame. Capturing the tile here (rather than re-reading it on release)
|
||||
// ensures the clipboard reports the tile that was actually clicked,
|
||||
// independent of any camera pan the click may have incidentally caused.
|
||||
if (_input.LeftJustDown)
|
||||
{
|
||||
_mouseDownPos = _input.MousePosition;
|
||||
var downWorld = _camera.ScreenToWorld(_input.MousePosition);
|
||||
_mouseDownTileX = (int)MathF.Floor(downWorld.X / C.WORLD_TILE_PIXELS);
|
||||
_mouseDownTileY = (int)MathF.Floor(downWorld.Y / C.WORLD_TILE_PIXELS);
|
||||
_mouseDownTracked = true;
|
||||
}
|
||||
|
||||
// Mouse drag pan
|
||||
var dragDelta = _input.ConsumeDragDelta(_camera);
|
||||
if (dragDelta != Vector2.Zero)
|
||||
_camera.Pan(dragDelta);
|
||||
|
||||
// Mouse wheel zoom
|
||||
int scroll = _input.ScrollDelta;
|
||||
if (scroll != 0)
|
||||
{
|
||||
float zoomDelta = scroll > 0 ? 0.12f : -0.12f;
|
||||
_camera.AdjustZoom(zoomDelta, _input.MousePosition);
|
||||
}
|
||||
|
||||
// Resolve cursor → tile coordinate for overlay + click handler
|
||||
var worldPos = _camera.ScreenToWorld(_input.MousePosition);
|
||||
_cursorTileX = (int)MathF.Floor(worldPos.X / C.WORLD_TILE_PIXELS);
|
||||
_cursorTileY = (int)MathF.Floor(worldPos.Y / C.WORLD_TILE_PIXELS);
|
||||
|
||||
// On release without drag, copy debug info to clipboard. Use the tile
|
||||
// captured at mouse-down so hand-jitter between press and release can't
|
||||
// shift the reported tile via incidental camera pan.
|
||||
if (_input.LeftJustUp && _mouseDownTracked)
|
||||
{
|
||||
_mouseDownTracked = false;
|
||||
if (Vector2.Distance(_input.MousePosition, _mouseDownPos) <= ClickSlopPixels)
|
||||
Clipboard.TrySetText($"seed={_ctx.World.WorldSeed} tile=({_mouseDownTileX},{_mouseDownTileY})");
|
||||
}
|
||||
|
||||
UpdateOverlayText();
|
||||
}
|
||||
|
||||
private void UpdateOverlayText()
|
||||
{
|
||||
_debugLabel.Text =
|
||||
$"Seed: {_ctx.World.WorldSeed}\n" +
|
||||
$"Tile: ({_cursorTileX}, {_cursorTileY})";
|
||||
}
|
||||
|
||||
public void Draw(GameTime gameTime, SpriteBatch _)
|
||||
{
|
||||
_game.GraphicsDevice.Clear(new Color(5, 10, 20));
|
||||
_renderer.Draw(_sb, _camera, gameTime);
|
||||
_overlayDesktop.Render();
|
||||
}
|
||||
|
||||
public void Deactivate() { }
|
||||
public void Reactivate() { }
|
||||
|
||||
// Dispose rendering resources when screen is removed
|
||||
~WorldMapScreen() => (_renderer as IDisposable)?.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>Theriapolis.Game</RootNamespace>
|
||||
<AssemblyName>Theriapolis.Game</AssemblyName>
|
||||
<LangVersion>12</LangVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.2.1105" />
|
||||
<PackageReference Include="Myra" Version="0.9.470" />
|
||||
<PackageReference Include="FontStashSharp.MonoGame" Version="1.3.9" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Theriapolis.Core\Theriapolis.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,223 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Game.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Author-curated UI text that the wizard surfaces but isn't worth pushing
|
||||
/// into JSON — skill descriptions, language metadata, ability labels, and
|
||||
/// the informational class↔clade recommendation table.
|
||||
///
|
||||
/// Sourced from the Phase 5 character-creator design handoff
|
||||
/// (`_design_handoff/character_creation/` snapshot, src/data.jsx). Keeping
|
||||
/// these here rather than in <c>Content/Data/</c> lets the design tone live
|
||||
/// next to the code that consumes it.
|
||||
/// </summary>
|
||||
public static class CodexCopy
|
||||
{
|
||||
/// <summary>STR/DEX/CON/INT/WIS/CHA → long display name.</summary>
|
||||
public static readonly System.Collections.Generic.IReadOnlyDictionary<AbilityId, string> AbilityLabels =
|
||||
new System.Collections.Generic.Dictionary<AbilityId, string>
|
||||
{
|
||||
[AbilityId.STR] = "Strength",
|
||||
[AbilityId.DEX] = "Dexterity",
|
||||
[AbilityId.CON] = "Constitution",
|
||||
[AbilityId.INT] = "Intellect",
|
||||
[AbilityId.WIS] = "Wisdom",
|
||||
[AbilityId.CHA] = "Charisma",
|
||||
};
|
||||
|
||||
/// <summary>SizeCategory snake_case → pretty label.</summary>
|
||||
public static string SizeLabel(string sizeKey) => sizeKey switch
|
||||
{
|
||||
"small" => "Small",
|
||||
"medium" => "Medium",
|
||||
"medium_large" => "Medium-Large",
|
||||
"large" => "Large",
|
||||
_ => sizeKey,
|
||||
};
|
||||
|
||||
/// <summary>Skill id (snake_case) → display name.</summary>
|
||||
public static string SkillName(string skillId) => skillId switch
|
||||
{
|
||||
"acrobatics" => "Acrobatics",
|
||||
"animal_handling" => "Animal Handling",
|
||||
"arcana" => "Arcana",
|
||||
"athletics" => "Athletics",
|
||||
"deception" => "Deception",
|
||||
"history" => "History",
|
||||
"insight" => "Insight",
|
||||
"intimidation" => "Intimidation",
|
||||
"investigation" => "Investigation",
|
||||
"medicine" => "Medicine",
|
||||
"nature" => "Nature",
|
||||
"perception" => "Perception",
|
||||
"performance" => "Performance",
|
||||
"persuasion" => "Persuasion",
|
||||
"religion" => "Religion",
|
||||
"sleight_of_hand" => "Sleight of Hand",
|
||||
"stealth" => "Stealth",
|
||||
"survival" => "Survival",
|
||||
_ => skillId,
|
||||
};
|
||||
|
||||
/// <summary>One-sentence skill description in the codex's tone (from the design's data.jsx).</summary>
|
||||
public static string SkillDescription(string skillId) => skillId switch
|
||||
{
|
||||
"acrobatics" => "Tumbling, balance, and the kind of footwork that keeps you upright on a coliseum sand-floor or a warren-rope. Body-cunning under pressure.",
|
||||
"animal_handling" => "Reading and steering non-sentient beasts — feral hounds, draft-kine, the wild cousins of your own clade.",
|
||||
"arcana" => "Knowledge of the older magics: scent-sorcery, blood-sigil, the half-forgotten rites that pre-date the Covenant of Claws.",
|
||||
"athletics" => "Raw physical effort. Climbing scaffold, swimming the foul canal, hauling a packmate from the pit, breaking a hold.",
|
||||
"deception" => "Speaking convincingly past your scent. The art of the false posture, the planted rumor, the answer that is technically true.",
|
||||
"history" => "The long memory of Theriapolis — the Imperium's fall, the Compact's ratification, which clade owes which other a centuries-old debt.",
|
||||
"insight" => "Reading another's true posture beneath their words. Catching the off-note in a snarl, the held breath, the lie in a friendly tail.",
|
||||
"intimidation" => "Bared-teeth diplomacy. The threat made plain enough that violence is not required to extract compliance.",
|
||||
"investigation" => "Methodical search and inference: scene-reading, document-sifting, the patient accumulation of small facts into a verdict.",
|
||||
"medicine" => "Field surgery, poultice-craft, knowing which clade tolerates which tincture. Stabilizing the dying without finishing them.",
|
||||
"nature" => "Knowledge of the wild outside the city wall — terrain, weather, plant-lore, and the unsigned beasts that observe no Covenant.",
|
||||
"perception" => "Awareness through every sense your clade gives you: ear-cock, scent-prickle, the half-glimpsed shape at the edge of vision.",
|
||||
"performance" => "Holding an audience — coliseum crowd, courtroom gallery, market square. Song, oratory, the body that compels watching.",
|
||||
"persuasion" => "Open-handed argument. The case made on its merits, the appeal to mutual benefit, the patient construction of agreement.",
|
||||
"religion" => "The hymn-cycles of the Cervid liturgy, the Compact's sacred clauses, the small household rites your clade keeps without thinking.",
|
||||
"sleight_of_hand" => "Quiet fingers — pickpocketing, palmed coins, the swap performed under another's nose.",
|
||||
"stealth" => "Movement unseen and unsmelled. Wind-checking, scent-suppression, the slow weight-shift on a creaking floor.",
|
||||
"survival" => "Field-craft beyond the wall: tracking, foraging, fire-making, knowing which run-off is safe to drink.",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
/// <summary>Skill id → governing ability.</summary>
|
||||
public static AbilityId SkillAbility(string skillId)
|
||||
{
|
||||
try { return SkillIdExtensions.FromJson(skillId).Ability(); }
|
||||
catch { return AbilityId.STR; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SkillId"/> enum → snake_case JSON id. Inverse of
|
||||
/// <see cref="SkillIdExtensions.FromJson"/>. Required because
|
||||
/// <c>enum.ToString().ToLowerInvariant()</c> produces "sleightofhand"
|
||||
/// for <c>SleightOfHand</c>, which doesn't match the JSON-keyed
|
||||
/// dictionaries (skill name / skill description).
|
||||
/// </summary>
|
||||
public static string SkillIdToJson(SkillId s) => s switch
|
||||
{
|
||||
SkillId.Acrobatics => "acrobatics",
|
||||
SkillId.AnimalHandling => "animal_handling",
|
||||
SkillId.Arcana => "arcana",
|
||||
SkillId.Athletics => "athletics",
|
||||
SkillId.Deception => "deception",
|
||||
SkillId.History => "history",
|
||||
SkillId.Insight => "insight",
|
||||
SkillId.Intimidation => "intimidation",
|
||||
SkillId.Investigation => "investigation",
|
||||
SkillId.Medicine => "medicine",
|
||||
SkillId.Nature => "nature",
|
||||
SkillId.Perception => "perception",
|
||||
SkillId.Performance => "performance",
|
||||
SkillId.Persuasion => "persuasion",
|
||||
SkillId.Religion => "religion",
|
||||
SkillId.SleightOfHand => "sleight_of_hand",
|
||||
SkillId.Stealth => "stealth",
|
||||
SkillId.Survival => "survival",
|
||||
_ => s.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
/// <summary>Language id → display name.</summary>
|
||||
public static string LanguageName(string langId) => langId switch
|
||||
{
|
||||
"common" => "Common",
|
||||
"canid" => "Canid",
|
||||
"felid" => "Felid",
|
||||
"mustelid" => "Mustelid",
|
||||
"ursid" => "Ursid",
|
||||
"cervid" => "Cervid",
|
||||
"bovid" => "Bovid",
|
||||
"leporid" => "Leporid",
|
||||
_ => langId,
|
||||
};
|
||||
|
||||
/// <summary>Language id → flavor description for hover/detail panels.</summary>
|
||||
public static string LanguageDescription(string langId) => langId switch
|
||||
{
|
||||
"common" => "The market-and-courthouse trade tongue of Theriapolis. Spoken by every clade.",
|
||||
"canid" => "Pack-tongue of the Canidae. Heavy with subsonic registers and scent-words non-Canid speakers cannot fully parse.",
|
||||
"felid" => "Sinuous and tonal, with a parallel tail-and-ear pidgin. Felid speakers trade in implication and pause.",
|
||||
"mustelid" => "Quick, percussive trade-speech of the Mustelidae. Famous for its dense vocabulary of musks, debts, and small grievances.",
|
||||
"ursid" => "Slow, low-register growl-speech. Ursid grammar prefers final emphasis — the important word always comes last.",
|
||||
"cervid" => "Old, hymn-shaped tongue of the Cervidae. Most speakers know Cervid as a song-language for funerals, treaties, and the long calendar.",
|
||||
"bovid" => "Patient, formal speech of the herd-clades. The language of guild-councils and oaths.",
|
||||
"leporid" => "Rapid, twitch-paced chatter of the Leporidae. Uses tense markers for danger and runs faster than most non-Leporidae can follow.",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
/// <summary>Item id → pretty display name (mirrors items.json's <c>name</c> for the subset used by starting kits).</summary>
|
||||
public static string ItemName(string itemId) => itemId switch
|
||||
{
|
||||
"rend_sword" => "Rend-sword",
|
||||
"chain_shirt" => "Chain Shirt",
|
||||
"buckler" => "Buckler",
|
||||
"healers_kit" => "Healer's Kit",
|
||||
"rations_predator" => "Rations (predator)",
|
||||
"rations_prey" => "Rations (prey)",
|
||||
"hoof_club" => "Hoof Club",
|
||||
"chain_mail" => "Chain Mail",
|
||||
"standard_shield" => "Standard Shield",
|
||||
"paw_axe" => "Paw-axe",
|
||||
"hide_vest" => "Hide Vest",
|
||||
"thorn_blade" => "Thorn-blade",
|
||||
"studded_leather" => "Studded Leather",
|
||||
"claw_bow" => "Claw-bow",
|
||||
"poultice_universal" => "Universal Poultice",
|
||||
"scent_mask_basic" => "Basic Scent-mask",
|
||||
"fang_knife" => "Fang-knife",
|
||||
"leather_harness" => "Leather Harness",
|
||||
"pheromone_vial_calm" => "Pheromone Vial (calm)",
|
||||
"pheromone_vial_fear" => "Pheromone Vial (fear)",
|
||||
"rope_claw_braid" => "Claw-braid Rope",
|
||||
_ => itemId,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Class id → list of clade ids that suit it. Drives the "★ Suits Clade"
|
||||
/// recommendation badge on the calling step. Informational only — the
|
||||
/// player can still pick any class with any clade.
|
||||
/// </summary>
|
||||
public static readonly System.Collections.Generic.IReadOnlyDictionary<string, string[]> ClassCladeRecommendations =
|
||||
new System.Collections.Generic.Dictionary<string, string[]>
|
||||
{
|
||||
["fangsworn"] = new[] { "canidae", "felidae", "ursidae" },
|
||||
["bulwark"] = new[] { "bovidae", "ursidae" },
|
||||
["feral"] = new[] { "ursidae", "mustelidae", "bovidae" },
|
||||
["shadow_pelt"] = new[] { "felidae", "mustelidae", "leporidae" },
|
||||
["scent_broker"] = new[] { "canidae", "mustelidae" },
|
||||
["covenant_keeper"] = new[] { "canidae", "bovidae", "cervidae" },
|
||||
["muzzle_speaker"] = new[] { "felidae", "leporidae" },
|
||||
["claw_wright"] = new[] { "mustelidae", "leporidae" },
|
||||
};
|
||||
|
||||
/// <summary>Returns true if <paramref name="cladeId"/> is one of the recommended clades for <paramref name="classId"/>.</summary>
|
||||
public static bool IsSuited(string classId, string cladeId)
|
||||
{
|
||||
if (!ClassCladeRecommendations.TryGetValue(classId, out var clades)) return false;
|
||||
foreach (var c in clades)
|
||||
if (string.Equals(c, cladeId, System.StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>STR/DEX/.../CHA in canonical order (matches the design's `ABILITIES`).</summary>
|
||||
public static readonly AbilityId[] AbilityOrder = new[]
|
||||
{
|
||||
AbilityId.STR, AbilityId.DEX, AbilityId.CON, AbilityId.INT, AbilityId.WIS, AbilityId.CHA,
|
||||
};
|
||||
|
||||
/// <summary>Roman numeral 1..7 for stepper labels ("Folio I of VII").</summary>
|
||||
public static string Romanize(int n) => n switch
|
||||
{
|
||||
1 => "I", 2 => "II", 3 => "III", 4 => "IV",
|
||||
5 => "V", 6 => "VI", 7 => "VII", 8 => "VIII",
|
||||
_ => n.ToString(),
|
||||
};
|
||||
|
||||
/// <summary>"+N" for n >= 0, "-N" otherwise.</summary>
|
||||
public static string Signed(int n) => n >= 0 ? $"+{n}" : n.ToString();
|
||||
}
|
||||
Reference in New Issue
Block a user