Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user