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()