M7.5: Interact prompt + dialogue overlay

InteractionScreen — CanvasLayer overlay (Layer=50, WhenPaused) that
pauses the tree on open and runs a Core DialogueRunner against
playScreen-owned aggregates (Reputation, Flags, ContentResolver,
QuestEngine). Codex-themed centered panel ~760 px with header
(NPC name, role line "Innkeeper of Millhaven" via
LastIndexOf('.')-split TitleCase, bias profile · disposition tag
from EffectiveDisposition.Breakdown, optional Scent Literacy line
for scent_broker / scent_literacy / master_nose feature holders),
history scrollback (last C.DIALOGUE_HISTORY_LINES, per-speaker
text colour for NPC / PC / Narration), numbered option list
(skill checks prefixed [SKILL DC N]), footer hint. Input: 1-9
top-row + numpad, Enter to dismiss when over, Esc / F to leave.
Godot's Key enum is long-backed for unicode + modifier bits, so
the arithmetic cast through int is explicit.

Effect routing on each ChooseOption: drains
runner.Context.StartQuestRequests into _playScreen.QuestEngine.Start
with a freshly-rebound QuestContext (PlayerCharacter pinned each
call) — quest journal UI is M8 but the engine fires immediately.
runner.Context.ShopRequested raises a "Shop ships with M8" toast
via PlayScreen.Toast and clears the flag so re-entry doesn't loop.
Stub NPCs (no dialogue_id, missing tree, or null content) get the
"(They have nothing to say yet.)" panel + Goodbye button — same
fallback the MonoGame source ships.

PlayScreen interact tick. Tactical-mode _Process now polls
EncounterTrigger.FindInteractCandidate(_actors) and caches the
result in _interactCandidate (cleared at world-map zoom). HUD
appends "[F] Talk to {DisplayName}" when non-null. Edge-detected
F press → AddChild(new InteractionScreen(npc, this)), candidate
cleared immediately so a held F can't stack overlays. M8 will
wire real LOS into the FindInteractCandidate losBlocked callback;
M7.5 ships with the default AlwaysClear.

Added internal accessors PlayScreen now surfaces so the overlay
doesn't poke private state: Reputation, Flags, QuestEngine, World,
WorldSeed, ClockSeconds, PlayerPosition, Content,
BuildQuestContextForDialogue(), Toast(text). All scoped internal —
not part of any public API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-10 21:19:13 -07:00
parent b1fc3f244b
commit 289c918d6c
3 changed files with 509 additions and 2 deletions
+58 -1
View File
@@ -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<ChunkCoord, HashSet<int>> _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<string, int> 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;
/// <summary>Hand back a quest context wired to current actors/rep/flags
/// — used by InteractionScreen to fire start_quest effects after a
/// dialogue option resolves.</summary>
internal QuestContext? BuildQuestContextForDialogue()
{
if (_content is null || _questCtx is null) return null;
_questCtx.PlayerCharacter = _actors?.Player?.Character;
return _questCtx;
}
/// <summary>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.</summary>
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;
}
// ──────────────────────────────────────────────────────────────────────