Files
Christopher Wiebe b451f83174 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>
2026-04-30 20:40:51 -07:00

396 lines
15 KiB
C#

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);
}
}