Files
TheriapolisV3/_design_handoff/character_creation/Theriapolis.Game/Screens/CharacterCreationScreen.cs
T

375 lines
15 KiB
C#
Raw Normal View History

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
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;
namespace Theriapolis.Game.Screens;
/// <summary>
/// Phase 5 M2: pre-game character creation. Player picks clade, species,
/// class, background, stat method, skills, and name; the screen builds a
/// <see cref="Character"/> and threads it through worldgen into the play
/// session.
///
/// This is intentionally minimal-but-complete: every selector is visible at
/// once with sensible defaults, so a player can confirm immediately. M3+ may
/// expand stat-array manual assignment, multi-step wizard polish, and the
/// portrait/preview pane.
/// </summary>
public sealed class CharacterCreationScreen : IScreen
{
private readonly ulong _seed;
private Game1 _game = null!;
private Desktop _desktop = null!;
// Loaded content
private ContentResolver _content = null!;
private CladeDef[] _clades = null!;
private SpeciesDef[] _allSpecies = null!;
private ClassDef[] _classes = null!;
private BackgroundDef[] _backgrounds = null!;
// Live selections
private CladeDef? _clade;
private SpeciesDef? _species;
private ClassDef? _class;
private BackgroundDef? _background;
private string _name = "Wanderer";
private bool _useRoll = false;
private AbilityScores _baseAbilities = new(15, 14, 13, 12, 10, 8); // Standard Array, default order
private readonly HashSet<SkillId> _chosenSkills = new();
// Stat-roll seeding: ms-since-game-start at the moment the screen opened
// (so successive rerolls advance ms). M2 dev override: hold SHIFT while
// pressing Reroll to use a fixed override (helpful for screenshots).
private readonly long _gameStartMs;
private long _msAtScreenOpen;
public CharacterCreationScreen(ulong seed)
{
_seed = seed;
_gameStartMs = Environment.TickCount64;
}
public void Initialize(Game1 game)
{
_game = game;
_msAtScreenOpen = 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();
// Sensible 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];
AssignStandardArrayByClassPriority();
AutoPickSkills();
BuildUI();
}
private void BuildUI()
{
var root = new VerticalStackPanel
{
Spacing = 6,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Myra.Graphics2D.Thickness(20),
};
root.Widgets.Add(new Label { Text = "CHARACTER CREATION", HorizontalAlignment = HorizontalAlignment.Center });
root.Widgets.Add(new Label { Text = $"Seed: 0x{_seed:X}", HorizontalAlignment = HorizontalAlignment.Center });
root.Widgets.Add(new Label { Text = " " });
// Clade row
root.Widgets.Add(new Label { Text = "Clade:" });
root.Widgets.Add(BuildSelectorRow(
_clades,
c => c.Name,
c => c == _clade,
c => { _clade = c; _species = _allSpecies.FirstOrDefault(s => s.CladeId == c.Id); RebuildUI(); }));
// Species row (filtered to selected clade)
root.Widgets.Add(new Label { Text = "Species:" });
var filteredSpecies = _allSpecies.Where(s => _clade is null || s.CladeId == _clade.Id).ToArray();
root.Widgets.Add(BuildSelectorRow(
filteredSpecies,
s => s.Name + " (" + s.Size + ")",
s => s == _species,
s => { _species = s; RebuildUI(); }));
// Class row
root.Widgets.Add(new Label { Text = "Class:" });
root.Widgets.Add(BuildSelectorRow(
_classes,
c => c.Name + " (d" + c.HitDie + ")",
c => c == _class,
c => { _class = c; AutoPickSkills(); AssignStandardArrayByClassPriority(); RebuildUI(); }));
// Background row
root.Widgets.Add(new Label { Text = "Background:" });
root.Widgets.Add(BuildSelectorRow(
_backgrounds,
b => b.Name,
b => b == _background,
b => { _background = b; RebuildUI(); }));
// Stat method
root.Widgets.Add(new Label { Text = " " });
var statRow = new HorizontalStackPanel { Spacing = 8 };
statRow.Widgets.Add(MakeButton("Standard Array", !_useRoll,
() => { _useRoll = false; AssignStandardArrayByClassPriority(); RebuildUI(); }));
statRow.Widgets.Add(MakeButton("Roll 4d6 drop lowest", _useRoll,
() => { _useRoll = true; RollAndAssign(); RebuildUI(); }));
if (_useRoll)
statRow.Widgets.Add(MakeButton("Reroll", false, () => { RollAndAssign(); RebuildUI(); }));
root.Widgets.Add(statRow);
// Stat readout (post clade+species mods)
root.Widgets.Add(new Label { Text = FormatStats() });
// Skills (only show if class is picked)
if (_class is not null)
{
root.Widgets.Add(new Label { Text = $"Skills (pick {_class.SkillsChoose}):" });
var skillRow = new HorizontalStackPanel { Spacing = 4 };
foreach (var raw in _class.SkillOptions)
{
SkillId s;
try { s = SkillIdExtensions.FromJson(raw); } catch { continue; }
bool picked = _chosenSkills.Contains(s);
skillRow.Widgets.Add(MakeButton(raw + (picked ? " ✓" : ""), picked, () =>
{
if (picked) _chosenSkills.Remove(s);
else _chosenSkills.Add(s);
RebuildUI();
}));
}
root.Widgets.Add(skillRow);
}
// Name
root.Widgets.Add(new Label { Text = " " });
var nameRow = new HorizontalStackPanel { Spacing = 8 };
nameRow.Widgets.Add(new Label { Text = "Name:", VerticalAlignment = VerticalAlignment.Center });
var nameInput = new TextBox { Text = _name, Width = 240 };
nameInput.TextChanged += (_, _) => _name = nameInput.Text ?? "Wanderer";
nameRow.Widgets.Add(nameInput);
root.Widgets.Add(nameRow);
// Validation status
var status = new Label { Text = "", HorizontalAlignment = HorizontalAlignment.Center };
if (TryBuildPreview(out var error))
status.Text = "Ready.";
else
status.Text = $"Cannot confirm: {error}";
root.Widgets.Add(status);
// Confirm + Back
root.Widgets.Add(new Label { Text = " " });
var btnRow = new HorizontalStackPanel { Spacing = 16, HorizontalAlignment = HorizontalAlignment.Center };
var backBtn = new TextButton { Text = "Back", Width = 120 };
backBtn.Click += (_, _) => _game.Screens.Pop();
btnRow.Widgets.Add(backBtn);
var confirmBtn = new TextButton { Text = "Confirm", Width = 200 };
confirmBtn.Click += (_, _) => OnConfirm();
confirmBtn.Enabled = TryBuildPreview(out _);
btnRow.Widgets.Add(confirmBtn);
root.Widgets.Add(btnRow);
_desktop = new Desktop { Root = root };
}
private void RebuildUI() => BuildUI();
private static TextButton MakeButton(string text, bool selected, Action onClick)
{
var btn = new TextButton
{
Text = (selected ? "→ " : " ") + text,
Padding = new Myra.Graphics2D.Thickness(6, 2, 6, 2),
};
btn.Click += (_, _) => onClick();
return btn;
}
private static HorizontalStackPanel BuildSelectorRow<T>(
IEnumerable<T> items,
Func<T, string> label,
Func<T, bool> isSelected,
Action<T> onSelect)
{
var row = new HorizontalStackPanel { Spacing = 4 };
foreach (var item in items)
row.Widgets.Add(MakeButton(label(item), isSelected(item), () => onSelect(item)));
return row;
}
/// <summary>Validates current selections + builds a preview character. Returns false on the first error.</summary>
private bool TryBuildPreview(out string error)
{
error = "";
if (_clade is null || _species is null || _class is null || _background is null)
{ error = "Pick clade, species, class, and background."; return false; }
if (_chosenSkills.Count != _class.SkillsChoose)
{ error = $"Pick exactly {_class.SkillsChoose} skill(s)."; return false; }
if (string.IsNullOrWhiteSpace(_name))
{ error = "Enter a name."; return false; }
var b = new CharacterBuilder
{
Clade = _clade,
Species = _species,
ClassDef = _class,
Background = _background,
BaseAbilities = _baseAbilities,
Name = _name,
};
foreach (var s in _chosenSkills) b.ChooseSkill(s);
return b.Validate(out error);
}
private void OnConfirm()
{
if (!TryBuildPreview(out var err)) return;
var b = new CharacterBuilder
{
Clade = _clade,
Species = _species,
ClassDef = _class,
Background = _background,
BaseAbilities = _baseAbilities,
Name = _name,
};
foreach (var s in _chosenSkills) b.ChooseSkill(s);
// Apply the class's starting kit so the new character is equipped on
// arrival — pass the loaded items table from the resolver.
var character = b.Build(_content.Items);
// Pop ourselves and push worldgen with the pending character. PlayScreen
// attaches it to the spawned PlayerActor on Initialize.
_game.Screens.Pop();
_game.Screens.Push(new WorldGenProgressScreen(_seed, pendingCharacter: character, pendingName: _name));
}
// ── Stats helpers ────────────────────────────────────────────────────
/// <summary>
/// Standard Array (15/14/13/12/10/8) assigned in class-priority order so
/// the default character is functional. M3 may add manual swapping.
/// </summary>
private void AssignStandardArrayByClassPriority()
{
var values = (int[])AbilityScores.StandardArray.Clone();
AssignByClassPriority(values);
}
private void RollAndAssign()
{
ulong msNow = (ulong)(Environment.TickCount64 - _gameStartMs);
var rng = SeededRng.ForSubsystem(_seed, C.RNG_STAT_ROLL ^ msNow);
int[] values = new int[6];
for (int i = 0; i < 6; i++) values[i] = CharacterBuilder.Roll4d6DropLowest(rng);
AssignByClassPriority(values);
}
private void AssignByClassPriority(int[] values)
{
// Sort values descending so highest goes to primary ability.
Array.Sort(values, (a, b) => b - a);
var primary = _class?.PrimaryAbility ?? Array.Empty<string>();
var allAbilities = new[] { "STR", "DEX", "CON", "INT", "WIS", "CHA" };
var order = new List<string>();
foreach (var p in primary) order.Add(p.ToUpperInvariant());
// After primaries, prefer CON for survivability, then physical/mental in default order.
foreach (var a in new[] { "CON", "DEX", "STR", "WIS", "INT", "CHA" })
if (!order.Contains(a)) order.Add(a);
var assigned = new Dictionary<string, int>();
for (int i = 0; i < 6; i++) assigned[order[i]] = values[i];
_baseAbilities = new AbilityScores(
assigned["STR"], assigned["DEX"], assigned["CON"],
assigned["INT"], assigned["WIS"], assigned["CHA"]);
}
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 { /* ignore unknown */ }
}
}
private string FormatStats()
{
if (_clade is null || _species is null) return "";
// Apply mods to base for display
var mods = new Dictionary<AbilityId, int>();
void Add(string raw, int v)
{
if (TryParseAbility(raw, out var id))
mods[id] = (mods.TryGetValue(id, out var x) ? x : 0) + v;
}
foreach (var kv in _clade.AbilityMods) Add(kv.Key, kv.Value);
foreach (var kv in _species.AbilityMods) Add(kv.Key, kv.Value);
var final = _baseAbilities.Plus(mods);
return $"STR {final.STR} ({Sign(AbilityScores.Mod(final.STR))}) " +
$"DEX {final.DEX} ({Sign(AbilityScores.Mod(final.DEX))}) " +
$"CON {final.CON} ({Sign(AbilityScores.Mod(final.CON))})\n" +
$"INT {final.INT} ({Sign(AbilityScores.Mod(final.INT))}) " +
$"WIS {final.WIS} ({Sign(AbilityScores.Mod(final.WIS))}) " +
$"CHA {final.CHA} ({Sign(AbilityScores.Mod(final.CHA))})";
}
private static string Sign(int n) => n >= 0 ? $"+{n}" : n.ToString();
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 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() { }
}