diff --git a/Theriapolis.Godot/Scenes/InteractionScreen.cs b/Theriapolis.Godot/Scenes/InteractionScreen.cs new file mode 100644 index 0000000..ad1c98b --- /dev/null +++ b/Theriapolis.Godot/Scenes/InteractionScreen.cs @@ -0,0 +1,450 @@ +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('_', ' '); + } +} diff --git a/Theriapolis.Godot/Scenes/PlayScreen.cs b/Theriapolis.Godot/Scenes/PlayScreen.cs index 85abe53..d87112a 100644 --- a/Theriapolis.Godot/Scenes/PlayScreen.cs +++ b/Theriapolis.Godot/Scenes/PlayScreen.cs @@ -62,6 +62,13 @@ public partial class PlayScreen : Control private readonly QuestEngine _questEngine = new(); private QuestContext? _questCtx; + // M7.5 — interact candidate cached per tick. Cleared when no + // friendly/neutral NPC is in range; the HUD shows "[F] Talk to ..." + // while non-null. + private NpcActor? _interactCandidate; + // Edge-detect F for the talk handler so a held key doesn't fire twice. + private bool _fWasDown; + // M7.3 — save round-trip plumbing private readonly Dictionary> _killedByChunk = new(); // Phase 5 M5: mid-combat encounter snapshot waiting for the CombatHUD @@ -231,6 +238,24 @@ public partial class PlayScreen : Control if (tactical) _streamer.EnsureLoadedAround(p.Position, C.TACTICAL_WINDOW_WORLD_TILES); + // M7.5 — friendly / neutral interact candidate. Only computed in + // tactical mode; world-map scale doesn't surface NPC interactions. + if (tactical) + _interactCandidate = EncounterTrigger.FindInteractCandidate(_actors); + else + _interactCandidate = null; + + // F-press → push dialogue overlay. Edge-detect so a held key doesn't + // re-open the screen while the previous one is still up. + bool fNow = Godot.Input.IsKeyPressed(Key.F); + bool fJustDown = fNow && !_fWasDown; + _fWasDown = fNow; + if (fJustDown && _interactCandidate is not null) + { + AddChild(new InteractionScreen(_interactCandidate, this)); + _interactCandidate = null; + } + // Counter-scale markers so on-screen size stays constant. float zoom = _render.Camera.Zoom.X; if (zoom > 0f) @@ -318,6 +343,34 @@ public partial class PlayScreen : Control public Theriapolis.Core.Rules.Character.Character? PlayerCharacter() => _actors?.Player?.Character; + // ────────────────────────────────────────────────────────────────────── + // M7.5 accessors — let InteractionScreen build a DialogueContext + + // DialogueRunner from PlayScreen-owned aggregates without copying them. + + internal Theriapolis.Core.Rules.Reputation.PlayerReputation Reputation => _reputation; + internal Dictionary Flags => _flags; + internal QuestEngine QuestEngine => _questEngine; + internal WorldState World => _ctx.World; + internal ulong WorldSeed => _ctx.World.WorldSeed; + internal long ClockSeconds => _clock.InGameSeconds; + internal Vec2 PlayerPosition => _actors?.Player?.Position ?? new Vec2(0, 0); + internal ContentResolver? Content => _content; + + /// Hand back a quest context wired to current actors/rep/flags + /// — used by InteractionScreen to fire start_quest effects after a + /// dialogue option resolves. + internal QuestContext? BuildQuestContextForDialogue() + { + if (_content is null || _questCtx is null) return null; + _questCtx.PlayerCharacter = _actors?.Player?.Character; + return _questCtx; + } + + /// Surfaced to the toast layer so InteractionScreen can flash + /// "Shop ships with M8" without poking PlayScreen's private save-flash + /// machinery directly. M8 will swap this for a real ShopScreen push. + public void Toast(string text) => FlashSavedToast(text); + private Vector2 ScreenToWorld(Vector2 screenPos) => _render.Camera.GetCanvasTransform().AffineInverse() * screenPos; @@ -766,6 +819,10 @@ public partial class PlayScreen : Control ? "Mouse-wheel out to leave tactical." : "Mouse-wheel in for tactical."; + string interactBlock = _interactCandidate is { } npc + ? $"\n[F] Talk to {npc.DisplayName}" + : ""; + _hudLabel.Text = charBlock + $"Seed: 0x{_ctx.World.WorldSeed:X}\n" + @@ -773,7 +830,7 @@ public partial class PlayScreen : Control $"{viewBlock}\n" + $"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" + $"{status}\n" + - "F5 quicksaves · Esc opens pause"; + "F5 quicksaves · Esc opens pause" + interactBlock; } // ────────────────────────────────────────────────────────────────────── diff --git a/Theriapolis.Godot/Scenes/TitleScreen.cs b/Theriapolis.Godot/Scenes/TitleScreen.cs index 50ae3ce..77f914b 100644 --- a/Theriapolis.Godot/Scenes/TitleScreen.cs +++ b/Theriapolis.Godot/Scenes/TitleScreen.cs @@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes; /// public partial class TitleScreen : Control { - private const string VersionLabel = "PORT / GODOT · M7.4"; + private const string VersionLabel = "PORT / GODOT · M7.5"; private const string WizardScenePath = "res://Scenes/Wizard.tscn"; public override void _Ready()