396 lines
15 KiB
C#
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);
|
||
|
|
}
|
||
|
|
}
|