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; /// /// Custom-rendered character-creation wizard. State model is a verbatim port /// of (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 Steps/ that builds /// its own widget subtree against this screen's mutable state. /// 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 StatPool = new(); public readonly System.Collections.Generic.Dictionary StatAssign = new(); public readonly System.Collections.Generic.List StatHistory = new(); public int? PendingPoolIdx; // Skill state public readonly System.Collections.Generic.HashSet 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; /// /// Popover layer for hover trigger widgets in the right-column aside. /// Exposed so can attach hover triggers to /// the same parchment-and-gilt popover the page-main cards use. /// 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(); var order = new System.Collections.Generic.List(); 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(); 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(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? 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 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); } /// /// Two-column body layout: page-main (flex) on the left, fixed-width aside /// panel on the right. Mirrors the React design's .page 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. /// 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); } /// /// 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. /// 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); } }