using System.Collections.Generic;
using Cthangover.Core.Audio;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Mods.Dialog.Action;
using Cthangover.Mods.Dialog.Action.Impls;
using Cthangover.Mods.Dialog.Executable;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
using Cthangover.Core.UI.Event;
using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog
{
using Cthangover.Core.Events;
///
/// The visible dialog UI: dual avatars with shader-based hide-color mode,
/// answer box spawning, and click/keyboard advance. Validates that no existing
/// dialog locker is running before accepting a new queue, ensuring only one
/// dialog can occupy the box at a time. Integrates with the audio
/// system for skip sounds on mouse click. Uses the same Widget lifecycle but
/// overrides HideDestruct to reset avatar textures and release the locker.
/// The shader material for avatars is resolved through the mod system on _Ready,
/// supporting mod-provided avatar effects.
///
public partial class DialogBox : Widget, IDialogBox
{
[Export] private PackedScene answerBoxScene;
[Export] private Label textField;
[Export] private Label titleField;
[Export] private Control titleBody;
[Export] private TextureRect firstAvatar;
[Export] private TextureRect secondAvatar;
[Export] private Texture2D emptyAvatar;
private IAudioService audioService;
private AnswerBox answerBox;
private SceneTreeTimer waitTimer;
/// Resolves the body, text and title labels, avatars and the audio service, and applies the mod-provided avatar shader.
protected override void OnReady()
{
MouseFilter = MouseFilterEnum.Ignore;
ModEventBus.Subscribe(OnSceneExited);
if (Body == null)
{
var found = FindChild("DialogBody", true, false);
if (found != null)
Set("body", found);
}
audioService = GetNodeOrNull("/root/AudioService") as IAudioService;
textField ??= GetNode("DialogBody/TextField");
titleField ??= GetNode("TitleBody/TitleField");
titleBody ??= GetNode("TitleBody");
firstAvatar ??= GetNode("FirstAvatar");
secondAvatar ??= GetNode("SecondAvatar");
var avatarShader = Shaders.Resolve("avatar");
if (avatarShader != null)
{
if (firstAvatar != null && firstAvatar.Material == null)
firstAvatar.Material = new ShaderMaterial { Shader = avatarShader };
if (secondAvatar != null && secondAvatar.Material == null)
secondAvatar.Material = new ShaderMaterial { Shader = avatarShader };
}
}
/// Mutable lock object that prevents concurrent dialogs. Set when a queue starts, cleared when the dialog ends.
public ExecutableEvent Locker { get; set; }
/// The execution engine. Created once at construction and reused for all dialog queues.
public DialogRuntime Runtime { get; } = new();
/// Whether the answer/choice box is currently visible.
public bool IsAnswerBoxShowed => answerBox != null;
/// If false, new dialog queues are rejected. Set externally to prevent dialog during non-dialog scenes.
public bool IsActive { get; set; } = true;
///
/// Called by answer items when the player picks a choice. Hides the answer box and jumps the runtime to the
/// variant's GoTo target; a variant without a target falls through to the action after the choice (re-running
/// the choice itself would show the same answers again forever).
///
public void SelectVariant(SelectVariant variant)
{
if (answerBox != null)
{
answerBox.Hide();
answerBox = null;
}
if (string.IsNullOrEmpty(variant.GoTo))
Runtime.Next();
else
Runtime.TryGoTo(variant.GoTo);
Runtime.Run();
ScheduleWaitTimer();
}
/// Sets the title bar text. Null or empty hides the title bar.
public void SetTitle(string title)
{
if (title == null) title = string.Empty;
titleField.Text = title;
titleBody.Visible = title != string.Empty;
}
/// Instantiates the answer_box scene prefab (bound in dialog_box.tscn), adds it as a child, and populates it with the given choice variants.
public void SetVariants(ICollection variants)
{
if (answerBoxScene == null)
{
GameLogger.Log("DIALOG", "SetVariants: answerBoxScene is not bound in the dialog_box scene", LogLevel.Error);
return;
}
var box = answerBoxScene.Instantiate();
AddChild(box);
answerBox = box;
answerBox.CreateVariantsUI(variants);
}
/// Sets the main dialog text. Hides the Body canvas item when text is null or whitespace.
public void SetText(string text)
{
if (text == null) text = string.Empty;
textField.Text = text;
var body = Body as CanvasItem;
if (body != null)
body.Visible = !string.IsNullOrWhiteSpace(text);
}
/// Sets the left avatar texture. Null hides the slot. enables shader silhouette mode when true.
public void SetFirstAvatar(Texture2D avatar, bool hideColor = false)
{
if (avatar == null)
{
firstAvatar.Visible = false;
}
else
{
firstAvatar.Texture = avatar;
firstAvatar.Visible = true;
if (firstAvatar.Material is ShaderMaterial mat)
mat.SetShaderParameter("hide_mode", hideColor ? 1 : 0);
}
}
/// Sets the right avatar texture. Null hides the slot. enables shader silhouette mode when true.
public void SetSecondAvatar(Texture2D avatar, bool hideColor = false)
{
if (avatar == null)
{
secondAvatar.Visible = false;
}
else
{
secondAvatar.Texture = avatar;
secondAvatar.Visible = true;
if (secondAvatar.Material is ShaderMaterial mat)
mat.SetShaderParameter("hide_mode", hideColor ? 1 : 0);
}
}
/// Resets avatars to empty and releases the dialog locker, preparing for the next dialog session.
protected override void HideDestruct()
{
ClearWaitTimer();
firstAvatar.Texture = emptyAvatar;
firstAvatar.Visible = false;
secondAvatar.Texture = emptyAvatar;
secondAvatar.Visible = false;
Locker = null;
}
///
/// Loads and starts a dialog sequence. Validates that: (1) dialog is not null, (2) this DialogBox is active,
/// (3) no dialog is running — neither one with a locker nor a locker-less one (an interactive's click dialog),
/// see . On success, shows the box and delegates to .
/// Returns false when the queue was rejected, so callers (event chains) do not count a run that never happened.
///
public bool SetDialogQueueAndRun(DialogQueue dialog, IEnumerable endDialogQueue, int startIndex, ExecutableEvent locker)
{
if (dialog == null)
{
GameLogger.Log("DIALOG", $"can't run '{locker?.GetType().FullName}', dialog is null", LogLevel.Error);
return false;
}
if (!IsActive)
{
GameLogger.Log("DIALOG", $"can't run '{locker?.GetType().FullName}', DialogBox is not active", LogLevel.Error);
return false;
}
if (IsRunning)
{
GameLogger.Log("DIALOG", $"can't run '{locker?.GetType().FullName}', another dialog is already running!", LogLevel.Error);
return false;
}
GameLogger.Log("DIALOG", $"SetDialogQueueAndRun: locker='{locker?.GetType().Name}', queue size={dialog?.Queue.Count}");
Runtime.SetDialogQueueAndRun(this, dialog.Queue, endDialogQueue, startIndex);
Runtime.Dialog = dialog;
Locker = locker;
Show();
Runtime.Run();
ScheduleWaitTimer();
return true;
}
///
/// Completion entry point for actions that finish on their own ( ): advances
/// past and schedules the wait timer of whatever comes next, exactly like a click
/// or an elapsed delay. Ignored when is no longer the current one (a stale
/// completion of an action the dialog already left) or while the debugger holds the runtime.
///
public void CompleteAction(IActionCommand action)
{
if (Runtime.IsHeld || action == null || Runtime.CurrentAction != action)
return;
Runtime.Next();
Runtime.Run();
ScheduleWaitTimer();
}
///
/// Advances the dialog to the next action. Skips if the runtime is waiting for an answer or has ended.
/// Respects (waits for destruct) and (waits for elapsed duration).
/// Called by click/keyboard input, the wait timer, and self-completing WaitEvent actions.
///
public void NextAction()
{
if (Runtime.IsHeld || Runtime.IsWaitAnswer)
return;
if (Runtime.IsEnd)
{
GameLogger.Log("DIALOG", "NextAction: dialog ended, hiding");
Hide();
return;
}
var action = Runtime.CurrentAction;
if (action == null)
{
GameLogger.Log("DIALOG", "NextAction: no current action, hiding");
Hide();
return;
}
double now = Godot.Time.GetTicksUsec() / 1_000_000.0;
if (action.WaitType == WaitType.WaitEvent && !action.IsDestructed)
return;
if (action.WaitType == WaitType.WaitTime && now - action.StartTime < action.WaitTime)
return;
GameLogger.Log("DIALOG", $"NextAction: advancing past '{action.GetType().Name}' (idx={Runtime.CurrentAction?.GetType().Name})");
Runtime.Next();
Runtime.Run();
ScheduleWaitTimer();
}
/// Advances the dialog on ui_accept or on a click outside the body, unless a choice is pending or the queue has ended.
protected override void OnInput(InputEvent @event)
{
if (Runtime.IsHeld || Runtime.IsEnd || Runtime.IsWaitAnswer)
return;
if (@event.IsActionPressed("ui_accept"))
{
if (IsTyping())
return;
GameLogger.Log("DIALOG", "input: advancing dialog");
NextAction();
}
else if (@event is InputEventMouseButton btn && btn.ButtonIndex == MouseButton.Left && btn.Pressed)
{
var action = Runtime.CurrentAction;
if (action == null || action.WaitType == WaitType.WaitTime || action.WaitType == WaitType.WaitEvent)
return;
if (IsOverOtherControl())
return;
if (Body is Control bodyControl)
{
var bodyRect = bodyControl.GetGlobalRect();
if (bodyRect.HasPoint(btn.Position))
{
GameLogger.Log("DIALOG", "input: advancing dialog");
audioService.PlaySound("ui/skip", SoundType.UI);
NextAction();
}
}
}
}
///
/// True while a text field has keyboard focus: Enter and Space belong to
/// it, not to the dialog (the dialog reads ui_accept in
/// _Input , before the GUI).
///
private bool IsTyping() => GetViewport()?.GuiGetFocusOwner() is LineEdit or TextEdit;
///
/// True when the mouse is over a control that is not part of this box and
/// stops the mouse (a tool panel, a button drawn over the dialog area):
/// the click is meant for that control.
///
private bool IsOverOtherControl()
{
var hovered = GetViewport()?.GuiGetHoveredControl();
return hovered != null && hovered != this && !IsAncestorOf(hovered)
&& hovered.MouseFilter == MouseFilterEnum.Stop;
}
///
/// Continues a dialog the debugger held ( ):
/// runs from the held action on, exactly as if it had never stopped.
/// Does nothing when the runtime is not held.
///
public void Resume()
{
if (!Runtime.IsHeld)
return;
Runtime.IsHeld = false;
Runtime.Run();
ScheduleWaitTimer();
}
///
/// Moves a running dialog to another action and holds it there
/// ( ): a pending wait timer and an open
/// answer box are dropped, since they belong to the action left behind;
/// the shown text stays until the new action replaces it.
///
public bool MoveTo(int queueIndex)
{
if (!IsRunning)
return false;
ClearWaitTimer();
if (answerBox != null)
{
answerBox.Hide();
answerBox = null;
}
return Runtime.MoveTo(queueIndex);
}
///
/// Stops the running dialog without finishing it ( )
/// and frees the box for another queue: closes the answer box, hides
/// and releases the locker. The scenario does not count as completed.
///
public void Abort()
{
ClearWaitTimer();
if (answerBox != null)
{
answerBox.Hide();
answerBox = null;
}
Runtime.Abort();
Hide();
Locker = null;
}
/// Detaches the scene-exit hook when the box leaves the tree.
protected override void OnExitTree()
{
ModEventBus.Unsubscribe(OnSceneExited);
}
///
/// Leaving a logical scene ends a dialog that is still running in it. The box belongs to the shell and
/// survives scene switches, so nothing else would release its locker: a switch in the middle of a dialog
/// would keep the box busy, and the next scene's events and the player's walking would wait for it. The
/// dialog is aborted, not finished: its end actions and do not run.
///
private void OnSceneExited(SceneExitedEvent evt)
{
if (!IsRunning)
return;
GameLogger.Log("DIALOG", $"scene '{evt.SceneName}' left during a dialog ('{Locker?.ID ?? "click dialog"}'): dialog aborted", LogLevel.Warning);
Abort();
}
///
/// Whether a dialog occupies the box: one started by an event (Locker is not null) or a locker-less queue
/// that has not ended yet (an interactive's click dialog runs without a locker). Event chains and the click
/// runner wait for false before starting anything, which keeps one dialog at a time.
///
public bool IsRunning => Locker != null || !Runtime.IsEnd;
/// Whether the current dialog queue reached its end (also true before the first queue loads).
public bool IsDialogEnded => Runtime.IsEnd;
/// Whether the dialog is waiting for the player to pick a variant.
public bool IsWaitingAnswer => Runtime.IsWaitAnswer;
/// Number of answer variants currently shown (0 when the answer box is hidden).
public int AnswerVariantCount => answerBox?.CreateModels()?.Count ?? 0;
/// Selects the shown variant at . Returns false when nothing is shown or the index is out of range.
public bool TrySelectVariant(int index)
{
var models = answerBox?.CreateModels();
if (models == null || models.Count == 0 || index < 0 || index >= models.Count)
return false;
var list = new List(models);
SelectVariant(list[index]);
return true;
}
///
/// Schedules a one-shot that advances the dialog past the
/// current action once its
/// elapses. Replaces the former per-frame polling: the runtime now yields until the timer
/// fires exactly once, so no Update work happens between delays. Nothing is scheduled while the
/// debugger holds the runtime: the current action has not run yet, its wait starts on
/// .
///
private void ScheduleWaitTimer()
{
ClearWaitTimer();
if (Runtime.IsHeld || !Runtime.IsWaitTime)
return;
var wait = Runtime.CurrentAction?.WaitTime ?? 0f;
if (wait <= 0f)
{
NextAction();
return;
}
waitTimer = GetTree().CreateTimer(wait);
waitTimer.Timeout += OnWaitTimerTimeout;
}
///
/// Fires when the delay timer elapses. The timer already guarantees the wait has
/// elapsed, so the runtime is advanced directly — no wall-clock re-check. The
/// guard skips a stale timer that fired after the
/// dialog already moved on; the hold guard keeps a held, not yet run action in place.
///
private void OnWaitTimerTimeout()
{
waitTimer = null;
if (Runtime.IsHeld || !Runtime.IsWaitTime)
return;
Runtime.Next();
Runtime.Run();
ScheduleWaitTimer();
}
///
/// Cancels a pending delay timer. Called before scheduling a new one and when the dialog
/// hides, so a stale timer can never advance a later, unrelated dialog session.
///
private void ClearWaitTimer()
{
if (waitTimer == null)
return;
waitTimer.Timeout -= OnWaitTimerTimeout;
waitTimer = null;
}
}
}