Files

451 lines
16 KiB
C#
Raw Permalink Normal View History

2026-05-10 21:19:13 -07:00
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('_', ' ');
}
}