using System; using System.Collections.Generic; using Godot; using Theriapolis.Core; using Theriapolis.Core.Data; using Theriapolis.Core.Entities; using Theriapolis.Core.Rules.Dialogue; using Theriapolis.Core.Rules.Reputation; using Theriapolis.GodotHost.UI; namespace Theriapolis.GodotHost.Scenes; /// /// M7.5 — dialogue overlay. Pushed by when the /// player presses F next to a friendly / neutral NPC. Mirrors /// Theriapolis.Game/Screens/InteractionScreen.cs from the MonoGame /// build: speaker header + bias / disposition tag + optional Scent Literacy /// overlay; scrollback of the last /// entries; numbered option list; number-key + Esc / F input. /// /// Effect routing after each ChooseOption: /// - Drains into /// playScreen.QuestEngine.Start so quest journal entries /// land in the right order (the journal UI is M8 territory but the /// engine fires immediately). /// - When flips true, M7 /// surfaces a toast — the real ShopScreen lands with M8. /// public partial class InteractionScreen : CanvasLayer { private readonly NpcActor _npc; private readonly PlayScreen _playScreen; private DialogueRunner? _runner; private Control _root = null!; private VBoxContainer _historyPanel = null!; private VBoxContainer _optionsPanel = null!; private bool _consumedOpeningKeys; public InteractionScreen(NpcActor npc, PlayScreen playScreen) { _npc = npc ?? throw new ArgumentNullException(nameof(npc)); _playScreen = playScreen ?? throw new ArgumentNullException(nameof(playScreen)); } public override void _Ready() { Layer = 50; ProcessMode = ProcessModeEnum.WhenPaused; GetTree().Paused = true; _runner = TryBuildRunner(); BuildLayout(); } private DialogueRunner? TryBuildRunner() { var content = _playScreen.Content; var pc = _playScreen.PlayerCharacter(); if (content is null || pc is null) return null; if (string.IsNullOrEmpty(_npc.DialogueId)) return null; if (!content.Dialogues.TryGetValue(_npc.DialogueId, out var tree)) return null; var pos = _playScreen.PlayerPosition; var ctx = new DialogueContext(_npc, pc, _playScreen.Reputation, _playScreen.Flags, content) { PlayerWorldTileX = (int)(pos.X / C.WORLD_TILE_PIXELS), PlayerWorldTileY = (int)(pos.Y / C.WORLD_TILE_PIXELS), WorldClockSeconds = _playScreen.ClockSeconds, }; return new DialogueRunner(tree, ctx, _playScreen.WorldSeed); } private void BuildLayout() { _root = new Control { MouseFilter = Control.MouseFilterEnum.Stop, ProcessMode = ProcessModeEnum.WhenPaused, }; _root.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); AddChild(_root); var scrim = new ColorRect { Color = new Color(0, 0, 0, 0.55f), MouseFilter = Control.MouseFilterEnum.Ignore, }; scrim.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); _root.AddChild(scrim); var center = new CenterContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; center.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); _root.AddChild(center); var panel = new PanelContainer { ThemeTypeVariation = "Card", Theme = CodexTheme.Build(), CustomMinimumSize = new Vector2(760, 0), }; center.AddChild(panel); var margin = new MarginContainer(); margin.AddThemeConstantOverride("margin_left", 28); margin.AddThemeConstantOverride("margin_right", 28); margin.AddThemeConstantOverride("margin_top", 22); margin.AddThemeConstantOverride("margin_bottom", 22); panel.AddChild(margin); var col = new VBoxContainer(); col.AddThemeConstantOverride("separation", 10); margin.AddChild(col); // Header col.AddChild(BuildHeader()); // Spacer col.AddChild(new Control { CustomMinimumSize = new Vector2(0, 4) }); // History _historyPanel = new VBoxContainer { CustomMinimumSize = new Vector2(680, 0) }; _historyPanel.AddThemeConstantOverride("separation", 4); col.AddChild(_historyPanel); // Options _optionsPanel = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; _optionsPanel.AddThemeConstantOverride("separation", 4); col.AddChild(_optionsPanel); // Footer col.AddChild(new Label { Text = "(1-9 to choose · Esc to leave · F also closes)", ThemeTypeVariation = "Eyebrow", HorizontalAlignment = HorizontalAlignment.Center, }); Refresh(); } private Control BuildHeader() { var header = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter }; header.AddThemeConstantOverride("separation", 2); header.AddChild(new Label { Text = _npc.DisplayName, ThemeTypeVariation = "H2", HorizontalAlignment = HorizontalAlignment.Center, }); string roleLine = FormatRoleLine(_npc.RoleTag); if (!string.IsNullOrEmpty(roleLine)) { header.AddChild(new Label { Text = roleLine, ThemeTypeVariation = "Eyebrow", HorizontalAlignment = HorizontalAlignment.Center, }); } var content = _playScreen.Content; var pc = _playScreen.PlayerCharacter(); if (content is not null && pc is not null) { 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.AddChild(new Label { Text = $"[{profile}] · {DispositionLabels.DisplayName(br.Label)} {br.Total:+#;-#;0}", ThemeTypeVariation = "Eyebrow", HorizontalAlignment = HorizontalAlignment.Center, }); string? scentLine = ScentReadingFor(_npc, pc); if (scentLine is not null) { header.AddChild(new Label { Text = scentLine, ThemeTypeVariation = "Eyebrow", HorizontalAlignment = HorizontalAlignment.Center, }); } } return header; } private void Refresh() { ClearChildren(_historyPanel); ClearChildren(_optionsPanel); if (_runner is null) { _historyPanel.AddChild(new Label { Text = "(They have nothing to say yet.)", AutowrapMode = TextServer.AutowrapMode.WordSmart, }); _historyPanel.AddChild(new Label { Text = "— no dialogue tree authored for this NPC. " + "Stock trees ship as content fills in.", ThemeTypeVariation = "Eyebrow", AutowrapMode = TextServer.AutowrapMode.WordSmart, }); var close = MakeOptionButton("1. Goodbye"); close.Pressed += Close; _optionsPanel.AddChild(close); return; } // Render history — last C.DIALOGUE_HISTORY_LINES entries. int start = Math.Max(0, _runner.History.Count - 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(0.86f, 0.86f, 0.78f), DialogueSpeaker.Pc => new Color(0.67f, 0.78f, 0.86f), DialogueSpeaker.Narration => new Color(0.63f, 0.71f, 0.55f), _ => Colors.White, }; var line = new Label { Text = prefix + entry.Text, AutowrapMode = TextServer.AutowrapMode.WordSmart, CustomMinimumSize = new Vector2(680, 0), }; line.AddThemeColorOverride("font_color", color); _historyPanel.AddChild(line); } if (_runner.IsOver) { var close = MakeOptionButton("1. (close)"); close.Pressed += Close; _optionsPanel.AddChild(close); return; } // Render options. Number by *display* index (visible options 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 = MakeOptionButton(label); btn.Pressed += () => OnOptionPicked(captured); _optionsPanel.AddChild(btn); if (displayN >= C.DIALOGUE_MAX_OPTIONS_PER_NODE) break; } } private void OnOptionPicked(int origIndex) { if (_runner is null) return; _runner.ChooseOption(origIndex); // M7.6 / 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 (_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(); // open_shop effect — M8 stub. Toast acknowledges the request and // clears the flag so re-entry doesn't loop on the same node. if (_runner.Context.ShopRequested) { _runner.Context.ShopRequested = false; _playScreen.Toast($"Shop ships with M8 — {_npc.DisplayName} waits patiently."); } } public override void _UnhandledInput(InputEvent @event) { if (@event is not InputEventKey { Pressed: true } key) return; if (key.Echo) return; // Belt-and-braces: PlayScreen.AddChild(this) happens during _Process, // so the F-press that opened this overlay shouldn't reach _Input // here (different frame). The flag absorbs the rare case where it // does. if (!_consumedOpeningKeys) { _consumedOpeningKeys = true; if (key.Keycode is Key.F or Key.Escape) return; } switch (key.Keycode) { case Key.Escape: case Key.F: GetViewport().SetInputAsHandled(); Close(); return; case Key.Enter: case Key.KpEnter: if (_runner is { IsOver: true }) { GetViewport().SetInputAsHandled(); Close(); } return; } // Number keys 1..9 (top-row and numpad). Godot's Key enum has a // long underlying type (so it can hold Unicode + modifier bits) — // cast the arithmetic result to int. int picked = key.Keycode switch { >= Key.Key1 and <= Key.Key9 => (int)(key.Keycode - Key.Key1) + 1, >= Key.Kp1 and <= Key.Kp9 => (int)(key.Keycode - Key.Kp1) + 1, _ => 0, }; if (picked > 0 && _runner is not null && !_runner.IsOver) { GetViewport().SetInputAsHandled(); HandleNumberPick(picked); } } 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; } } } private void Close() { GetTree().Paused = false; QueueFree(); } // ────────────────────────────────────────────────────────────────────── // Helpers private static Button MakeOptionButton(string text) { return new Button { Text = text, CustomMinimumSize = new Vector2(680, 40), SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter, Alignment = HorizontalAlignment.Left, FocusMode = Control.FocusModeEnum.None, }; } private static void ClearChildren(Node node) { foreach (Node child in node.GetChildren()) child.QueueFree(); } 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); } /// Phase 6.5 M1 — Scent Literacy overlay. Returns null when /// the PC doesn't have the feature, so the header skips the line. private static string? ScentReadingFor(NpcActor npc, Theriapolis.Core.Rules.Character.Character pc) { bool hasFeature = pc.LearnedFeatureIds.Contains("scent_literacy") || pc.ClassDef.Id == "scent_broker"; if (!hasFeature) return null; string clade = npc.Resident?.Clade ?? npc.Template?.Behavior ?? "unknown"; string species = npc.Resident?.Species ?? "—"; int hpPct = npc.MaxHp > 0 ? (int)Math.Round(100.0 * npc.CurrentHp / npc.MaxHp) : 100; string hp = hpPct == 100 ? "—" : $"{hpPct}%"; int tagCount = pc.LearnedFeatureIds.Contains("master_nose") ? 3 : 1; var tags = npc.ComputeScentTags(tagCount); string tagSuffix = ""; if (tags.Count > 0) { var rendered = new List(tags.Count); foreach (var t in tags) rendered.Add("⚠ " + 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('_', ' '); } }