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:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -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() { }
}
+610
View File
@@ -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() { }
}
+26
View File
@@ -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);
}
}
+249
View File
@@ -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() { }
}
+322
View File
@@ -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(); }
}
+195
View File
@@ -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() { }
}
+908
View File
@@ -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();
}
}
+163
View File
@@ -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,
};
}
+143
View File
@@ -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() { }
}
+68
View File
@@ -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);
}
}
+259
View File
@@ -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(); }
}
+114
View File
@@ -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).");
}
}
+188
View File
@@ -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();
}