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:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M7.5 — dialogue overlay. Pushed by <see cref="PlayScreen"/> when the
|
||||||
|
/// player presses F next to a friendly / neutral NPC. Mirrors
|
||||||
|
/// <c>Theriapolis.Game/Screens/InteractionScreen.cs</c> from the MonoGame
|
||||||
|
/// build: speaker header + bias / disposition tag + optional Scent Literacy
|
||||||
|
/// overlay; scrollback of the last <see cref="C.DIALOGUE_HISTORY_LINES"/>
|
||||||
|
/// entries; numbered option list; number-key + Esc / F input.
|
||||||
|
///
|
||||||
|
/// Effect routing after each <c>ChooseOption</c>:
|
||||||
|
/// - Drains <see cref="DialogueContext.StartQuestRequests"/> into
|
||||||
|
/// <c>playScreen.QuestEngine.Start</c> so quest journal entries
|
||||||
|
/// land in the right order (the journal UI is M8 territory but the
|
||||||
|
/// engine fires immediately).
|
||||||
|
/// - When <see cref="DialogueContext.ShopRequested"/> flips true, M7
|
||||||
|
/// surfaces a toast — the real ShopScreen lands with M8.
|
||||||
|
/// </summary>
|
||||||
|
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<char> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Phase 6.5 M1 — Scent Literacy overlay. Returns null when
|
||||||
|
/// the PC doesn't have the feature, so the header skips the line.</summary>
|
||||||
|
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<string>(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('_', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,13 @@ public partial class PlayScreen : Control
|
|||||||
private readonly QuestEngine _questEngine = new();
|
private readonly QuestEngine _questEngine = new();
|
||||||
private QuestContext? _questCtx;
|
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
|
// M7.3 — save round-trip plumbing
|
||||||
private readonly Dictionary<ChunkCoord, HashSet<int>> _killedByChunk = new();
|
private readonly Dictionary<ChunkCoord, HashSet<int>> _killedByChunk = new();
|
||||||
// Phase 5 M5: mid-combat encounter snapshot waiting for the CombatHUD
|
// Phase 5 M5: mid-combat encounter snapshot waiting for the CombatHUD
|
||||||
@@ -231,6 +238,24 @@ public partial class PlayScreen : Control
|
|||||||
if (tactical)
|
if (tactical)
|
||||||
_streamer.EnsureLoadedAround(p.Position, C.TACTICAL_WINDOW_WORLD_TILES);
|
_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.
|
// Counter-scale markers so on-screen size stays constant.
|
||||||
float zoom = _render.Camera.Zoom.X;
|
float zoom = _render.Camera.Zoom.X;
|
||||||
if (zoom > 0f)
|
if (zoom > 0f)
|
||||||
@@ -318,6 +343,34 @@ public partial class PlayScreen : Control
|
|||||||
public Theriapolis.Core.Rules.Character.Character? PlayerCharacter()
|
public Theriapolis.Core.Rules.Character.Character? PlayerCharacter()
|
||||||
=> _actors?.Player?.Character;
|
=> _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)
|
private Vector2 ScreenToWorld(Vector2 screenPos)
|
||||||
=> _render.Camera.GetCanvasTransform().AffineInverse() * screenPos;
|
=> _render.Camera.GetCanvasTransform().AffineInverse() * screenPos;
|
||||||
|
|
||||||
@@ -766,6 +819,10 @@ public partial class PlayScreen : Control
|
|||||||
? "Mouse-wheel out to leave tactical."
|
? "Mouse-wheel out to leave tactical."
|
||||||
: "Mouse-wheel in for tactical.";
|
: "Mouse-wheel in for tactical.";
|
||||||
|
|
||||||
|
string interactBlock = _interactCandidate is { } npc
|
||||||
|
? $"\n[F] Talk to {npc.DisplayName}"
|
||||||
|
: "";
|
||||||
|
|
||||||
_hudLabel.Text =
|
_hudLabel.Text =
|
||||||
charBlock +
|
charBlock +
|
||||||
$"Seed: 0x{_ctx.World.WorldSeed:X}\n" +
|
$"Seed: 0x{_ctx.World.WorldSeed:X}\n" +
|
||||||
@@ -773,7 +830,7 @@ public partial class PlayScreen : Control
|
|||||||
$"{viewBlock}\n" +
|
$"{viewBlock}\n" +
|
||||||
$"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" +
|
$"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" +
|
||||||
$"{status}\n" +
|
$"{status}\n" +
|
||||||
"F5 quicksaves · Esc opens pause";
|
"F5 quicksaves · Esc opens pause" + interactBlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class TitleScreen : Control
|
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";
|
private const string WizardScenePath = "res://Scenes/Wizard.tscn";
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
|
|||||||
Reference in New Issue
Block a user