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