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; /// /// Phase 6 M3 — full dialogue UI driven by . /// Pushed by 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 open_shop effect: pushes ; resumes /// dialogue when shop closes. /// 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; } /// /// 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 /// (scent_literacy) and tracked in /// /// 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 /// from . Scent Mastery /// (master_nose, level 11) reads up to 3 tags; baseline Scent /// Literacy reads the top 1. /// 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 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); } }