Event-driven dialog queue, story flag command, mod unit tests

Push-based event chains, runtime fixes (single end, goto lifecycle, if,
busy box, held delay, empty options, timed/animated/effect actions), the
'flag name value' DSL command, autoplay --dialogs=N, 19 mod unit tests.
This commit is contained in:
cthangover committed 2026-10-21 12:31:52 +02:00
1 parent 9f86bdbb0c
commit bd55716349
86 files changed
+3173 -2181

No files matched your search

+7 -2
View File
@@ -63,12 +63,17 @@ namespace Cthangover.Mods.Dialog.Action
ID = Guid.NewGuid().ToString();
}
/// <summary>Idempotent construction: calls <see cref="DoConstruct"/> once and sets <see cref="IsConstructed"/> to true.</summary>
/// <summary>
/// Idempotent construction: calls <see cref="DoConstruct"/> once and sets <see cref="IsConstructed"/> to true.
/// Construction also clears <see cref="IsDestructed"/>: an action reached again through a looping goto is a
/// new life of the same object, so its next <see cref="Destruct"/> must release what this construction took.
/// </summary>
public void Construct()
{
if(IsConstructed) return;
DoConstruct();
IsConstructed = true;
IsDestructed = false;
}
/// <summary>Idempotent desctruction: calls <see cref="DoDestruct"/> once, then sets <see cref="IsConstructed"/> false and <see cref="IsDestructed"/> true.</summary>
@@ -83,7 +88,7 @@ namespace Cthangover.Mods.Dialog.Action
/// <summary>Constructs if not already done, records start time, and delegates execution to <see cref="DoRun"/>.</summary>
public void Run(DialogRuntime runtime)
{
if (!IsConstructed) DoConstruct();
Construct();
StartTime = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0);
DoRun(runtime);
}
+14 -14
View File
@@ -1,14 +1,14 @@
namespace Cthangover.Mods.Dialog.Action
{
/// <summary>
/// High-level action category discriminator. Currently minimal (Text, End)
/// — may expand as additional action families are added.
/// </summary>
public enum ActionType
{
/// <summary>A dialog text display action that pauses for click input.</summary>
Text,
/// <summary>Terminates the dialog queue.</summary>
End
}
}
namespace Cthangover.Mods.Dialog.Action
{
/// <summary>
/// High-level action category discriminator. Currently minimal (Text, End)
/// — may expand as additional action families are added.
/// </summary>
public enum ActionType
{
/// <summary>A dialog text display action that pauses for click input.</summary>
Text,
/// <summary>Terminates the dialog queue.</summary>
End
}
}
+17 -17
View File
@@ -1,17 +1,17 @@
namespace Cthangover.Mods.Dialog.Action
{
/// <summary>
/// Controls when an action's Construct() fires. OnStartQueue — construct
/// when the dialog queue is first loaded (good for preloading); OnStartAction —
/// lazy-construct just before the action runs (saves resources for unreached
/// branches). Default for most actions is OnStartAction.
/// </summary>
public enum ConstructType
{
/// <summary>Construct when the dialog queue is first loaded. Useful for actions that need to preload resources.</summary>
OnStartQueue,
/// <summary>Construct lazily just before the action runs. Default for most actions — saves resources for unreached branches.</summary>
OnStartAction
}
}
namespace Cthangover.Mods.Dialog.Action
{
/// <summary>
/// Controls when an action's Construct() fires. OnStartQueue — construct
/// when the dialog queue is first loaded (good for preloading); OnStartAction —
/// lazy-construct just before the action runs (saves resources for unreached
/// branches). Default for most actions is OnStartAction.
/// </summary>
public enum ConstructType
{
/// <summary>Construct when the dialog queue is first loaded. Useful for actions that need to preload resources.</summary>
OnStartQueue,
/// <summary>Construct lazily just before the action runs. Default for most actions — saves resources for unreached branches.</summary>
OnStartAction
}
}
+20 -20
View File
@@ -1,20 +1,20 @@
namespace Cthangover.Mods.Dialog.Action
{
/// <summary>
/// Controls when an action's Destruct() fires. OnEndAction — immediately after
/// the action advances (typical for most commands). OnDelayed — deferred until
/// specifically cleaned up (for visual state that must persist across actions).
/// OnEndQueue — destruct when the entire dialog queue finishes (for resources
/// that must survive the whole dialog but not beyond).
/// </summary>
public enum DestructType
{
/// <summary>Destruct immediately when the action is advanced past. Default for most commands.</summary>
OnEndAction,
/// <summary>Destruct is deferred — must be manually triggered or triggered via <see cref="ActionCommand.DelayedDestruct"/>.</summary>
OnDelayed,
/// <summary>Destruct when the entire dialog queue finishes. Use for resources that must survive the whole dialog but not beyond.</summary>
OnEndQueue,
}
}
namespace Cthangover.Mods.Dialog.Action
{
/// <summary>
/// Controls when an action's Destruct() fires. OnEndAction — immediately after
/// the action advances (typical for most commands). OnDelayed — deferred until
/// specifically cleaned up (for visual state that must persist across actions).
/// OnEndQueue — destruct when the entire dialog queue finishes (for resources
/// that must survive the whole dialog but not beyond).
/// </summary>
public enum DestructType
{
/// <summary>Destruct immediately when the action is advanced past. Default for most commands.</summary>
OnEndAction,
/// <summary>Destruct is deferred — must be manually triggered or triggered via <see cref="ActionCommand.DelayedDestruct"/>.</summary>
OnDelayed,
/// <summary>Destruct when the entire dialog queue finishes. Use for resources that must survive the whole dialog but not beyond.</summary>
OnEndQueue,
}
}
+13 -13
View File
@@ -1,13 +1,13 @@
namespace Cthangover.Mods.Dialog.Action
{
/// <summary>
/// Minimal destructible contract — anything in the dialog runtime's object list
/// that must be cleaned up when a dialog ends.
/// </summary>
public interface IActionDestruct
{
/// <summary>Called to release resources, disconnect signals, and mark the object as finished. Must be idempotent.</summary>
void Destruct();
}
}
namespace Cthangover.Mods.Dialog.Action
{
/// <summary>
/// Minimal destructible contract — anything in the dialog runtime's object list
/// that must be cleaned up when a dialog ends.
/// </summary>
public interface IActionDestruct
{
/// <summary>Called to release resources, disconnect signals, and mark the object as finished. Must be idempotent.</summary>
void Destruct();
}
}
@@ -1,7 +1,6 @@
using System.Collections.Generic;
using Cthangover.Mods.Effects.Animation;
using Cthangover.Core.Factories.Impls;
using Cthangover.Mods.Dialog.Action;
using Cthangover.Core.UI.Event;
using Cthangover.Mods.Interface.View;
using Godot;
using Cthangover.Core.UI;
@@ -15,106 +14,150 @@ namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Frame-animates the scene foreground by swapping ViewBox textures at a
/// configurable speed. Loads frames from SpriteIDs on construction
/// (OnStartQueue). Unlike AnimationController which crossfades, this does
/// hard cuts — simpler but acceptable for foreground effects. The speed
/// switches to NextFrameSpeed after the first frame so first-frame delay
/// can differ from subsequent frames. Registers as IOnUpdateEvent for
/// per-frame advancement. Non-looping mode auto-stops at last frame.
/// configurable speed: the first frame lasts 1/<see cref="Speed"/> seconds,
/// every later one 1/<see cref="NextFrameSpeed"/>. Hard cuts, no crossfade.
/// Frames come from <see cref="Textures"/> or are resolved from
/// <see cref="SpriteIDs"/> through <see cref="BackgroundFactory"/> (the same
/// texture names a <c>foreground</c> command takes) on construction
/// (OnStartQueue).
/// <para/>
/// Event-driven: each frame schedules one <see cref="SceneTreeTimer"/> for the
/// next cut — no per-frame update. The dialog does not wait (NoWait); the
/// animation keeps playing while the dialog goes on and stops when the queue
/// ends (OnEndQueue), when another animation starts or when a
/// <c>foreground</c> command replaces the picture (<see cref="StopActive"/>).
/// </summary>
public class ActionAnimatedForeground : ActionCommand, IOnUpdateEvent
public class ActionAnimatedForeground : ActionCommand
{
/// <summary>Update priority. Runs after higher-priority listeners. Value 0.</summary>
public int Priority => 0;
private static ActionAnimatedForeground active;
/// <summary>List of sprite resource paths to display as animation frames. Loaded in <see cref="DoConstruct"/>.</summary>
/// <summary>Texture names resolved through <see cref="BackgroundFactory"/>; ignored when <see cref="Textures"/> is set.</summary>
public List<string> SpriteIDs { get; set; }
/// <summary>Initial frame rate multiplier for the first frame.</summary>
/// <summary>Ready frames used instead of resolving <see cref="SpriteIDs"/> (bypasses the factory like ActionForeground.Texture).</summary>
public List<Texture2D> Textures { get; set; }
/// <summary>Frames per second of the first frame.</summary>
public float Speed { get; set; } = 1f;
/// <summary>Frame rate multiplier for subsequent frames after the first. Allows different pacing for the initial frame.</summary>
/// <summary>Frames per second of every frame after the first one.</summary>
public float NextFrameSpeed { get; set; } = 1f;
/// <summary>Whether the animation wraps after the last frame. When false, playback stops at the final frame.</summary>
/// <summary>Whether playback restarts from the first frame after the last one; otherwise it stops on the last frame.</summary>
public bool IsLoop { get; set; } = true;
private int currentFrame;
private float elapsed;
private bool isAnimating;
private List<Texture2D> frames;
/// <summary>Whether the animation is still cutting frames.</summary>
public bool IsAnimating { get; private set; }
/// <summary>Preloads frames when the dialog queue is first loaded, so textures are ready before playback.</summary>
private int currentFrame;
private List<Texture2D> frames;
private SceneTreeTimer frameTimer;
/// <summary>Frames are resolved when the queue starts, so the first cut does not stall on loading.</summary>
public override ConstructType ConstructType { get; set; } = ConstructType.OnStartQueue;
/// <summary>Animation runs in the background — the dialog queue continues immediately.</summary>
/// <summary>The animation lives until the dialog ends, not until the next action.</summary>
public override DestructType DestructType { get; set; } = DestructType.OnEndQueue;
/// <summary>The dialog goes on while the foreground animates.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Preloads every sprite in <see cref="SpriteIDs"/> into memory so playback never stalls on I/O.</summary>
/// <summary>Stops the animation that currently drives the foreground, if any; the shown frame stays.</summary>
public static void StopActive()
{
active?.Stop();
}
/// <summary>Resolves the frames.</summary>
public override void DoConstruct()
{
base.DoConstruct();
LoadFrames();
}
/// <summary>Stops the animation loop; the last shown frame stays on screen.</summary>
/// <summary>Stops the frame timer; the last shown frame stays on the ViewBox.</summary>
public override void DoDestruct()
{
base.DoDestruct();
isAnimating = false;
Stop();
}
private void LoadFrames()
{
frames = new List<Texture2D>();
var source = Textures;
if (source != null)
{
foreach (var tex in source)
if (tex != null)
frames.Add(tex);
return;
}
if (SpriteIDs == null)
return;
foreach (var id in SpriteIDs)
{
var tex = GD.Load<Texture2D>(id);
var tex = BackgroundFactory.Instance.Get(id);
if (tex != null)
frames.Add(tex);
}
}
/// <summary>Resets playback to the first frame and shows it on the ViewBox; per-frame stepping happens through <see cref="IOnUpdateEvent.OnUpdate"/>.</summary>
/// <summary>Stops a previous animation, shows the first frame on the ViewBox and schedules the next cut.</summary>
public override void DoRun(DialogRuntime runtime)
{
StopActive();
currentFrame = 0;
elapsed = 0f;
isAnimating = frames != null && frames.Count > 0;
IsAnimating = frames != null && frames.Count > 0;
if (!IsAnimating)
return;
if (isAnimating)
{
var viewBox = Scene.Find<ViewBox>("ViewBox");
viewBox?.SetForegroundTexture(frames[0]);
}
active = this;
ShowFrame();
ScheduleNextFrame(Speed);
}
void IOnUpdateEvent.OnUpdate()
private void ShowFrame()
{
if (!isAnimating || frames == null || frames.Count == 0)
return;
var viewBox = Scene.Find<ViewBox>("ViewBox");
if (viewBox == null)
viewBox?.SetForegroundTexture(frames[currentFrame]);
}
private void ScheduleNextFrame(float speed)
{
if (Godot.Engine.GetMainLoop() is not SceneTree tree)
return;
elapsed += 1f / 60f;
var frameDuration = 1f / Mathf.Max(Speed, 0.01f);
if (elapsed >= frameDuration)
frameTimer = tree.CreateTimer(1f / Mathf.Max(speed, 0.01f));
frameTimer.Timeout += OnFrameTimeout;
}
private void OnFrameTimeout()
{
frameTimer = null;
if (!IsAnimating)
return;
currentFrame++;
if (currentFrame >= frames.Count)
{
elapsed -= frameDuration;
currentFrame++;
if (currentFrame >= frames.Count)
if (!IsLoop)
{
if (IsLoop)
currentFrame = 0;
else
{
isAnimating = false;
return;
}
Stop();
return;
}
viewBox.SetForegroundTexture(frames[currentFrame]);
Speed = NextFrameSpeed;
currentFrame = 0;
}
ShowFrame();
ScheduleNextFrame(NextFrameSpeed);
}
private void Stop()
{
IsAnimating = false;
if (frameTimer != null)
{
frameTimer.Timeout -= OnFrameTimeout;
frameTimer = null;
}
if (active == this)
active = null;
}
}
}
+81 -81
View File
@@ -1,81 +1,81 @@
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Scenes;
using Cthangover.Mods.Dialog.Action;
using Cthangover.Mods.Time;
using Cthangover.Mods.Interface.View;
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.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Sets the scene background on the ViewBox, including depth and albedo maps
/// for the lighting system. Resolves textures through BackgroundFactory on
/// construction (OnStartQueue) — depth and albedo are loaded from the same
/// sprite ID with "_depth" / "_albedo" suffixes. Kills any in-progress
/// background transition before setting the new texture. Textures are nulled
/// after application to prevent stale reuse across dialog restarts.
/// Sets SceneContextNode.LastBackgroundID so the scene manager can restore
/// the correct background on scene revisit.
/// </summary>
public class ActionBackground : ActionCommand
{
/// <summary>Sprite resource ID resolved through <see cref="BackgroundFactory"/> on construction. Also loads depth (_depth) and albedo (_albedo) variants.</summary>
public string SpriteID { get; set; }
/// <summary>Direct texture override. When non-null, bypasses factory resolution. Set to null after application to prevent stale reuse.</summary>
public Texture2D Texture { get; set; }
/// <summary>Depth map for the lighting system, loaded from <see cref="SpriteID"/> + "_depth".</summary>
public Texture2D DepthTexture { get; set; }
/// <summary>Albedo color map for the lighting system, loaded from <see cref="SpriteID"/> + "_albedo".</summary>
public Texture2D AlbedoTexture { get; set; }
/// <summary>Preloads textures when the dialog queue is first created, resolving SpriteID through the factory.</summary>
public override ConstructType ConstructType { get; set; } = ConstructType.OnStartQueue;
/// <summary>Background is set imperatively — the dialog continues immediately.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Resolves <see cref="SpriteID"/> plus its <c>_depth</c>/<c>_albedo</c> variants through the background factory.</summary>
public override void DoConstruct()
{
base.DoConstruct();
if (Texture == null && !string.IsNullOrEmpty(SpriteID))
Texture = BackgroundFactory.Instance.Get(SpriteID);
if (!string.IsNullOrEmpty(SpriteID))
{
DepthTexture = BackgroundFactory.Instance.Get(SpriteID + "_depth");
AlbedoTexture = BackgroundFactory.Instance.Get(SpriteID + "_albedo");
}
}
/// <summary>
/// Applies the background to the ViewBox, kills any in-flight background
/// transition and feeds the depth/albedo maps to the light controller.
/// Consumed textures are nulled so a reused action cannot re-apply stale data.
/// </summary>
public override void DoRun(DialogRuntime runtime)
{
if (Texture == null)
return;
SceneContextNode.LastBackgroundID = SpriteID;
var viewBox = Scene.Find<ViewBox>("ViewBox");
viewBox?.KillTransitionTween();
viewBox?.SetBackgroundTexture(Texture);
var controller = UiLightController.Instance;
controller?.SetupDepthMap(DepthTexture);
controller?.SetupAlbedoMap(AlbedoTexture);
Texture = null;
DepthTexture = null;
AlbedoTexture = null;
}
}
}
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Scenes;
using Cthangover.Mods.Dialog.Action;
using Cthangover.Mods.Time;
using Cthangover.Mods.Interface.View;
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.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Sets the scene background on the ViewBox, including depth and albedo maps
/// for the lighting system. Resolves textures through BackgroundFactory on
/// construction (OnStartQueue) — depth and albedo are loaded from the same
/// sprite ID with "_depth" / "_albedo" suffixes. Kills any in-progress
/// background transition before setting the new texture. Textures are nulled
/// after application to prevent stale reuse across dialog restarts.
/// Sets SceneContextNode.LastBackgroundID so the scene manager can restore
/// the correct background on scene revisit.
/// </summary>
public class ActionBackground : ActionCommand
{
/// <summary>Sprite resource ID resolved through <see cref="BackgroundFactory"/> on construction. Also loads depth (_depth) and albedo (_albedo) variants.</summary>
public string SpriteID { get; set; }
/// <summary>Direct texture override. When non-null, bypasses factory resolution. Set to null after application to prevent stale reuse.</summary>
public Texture2D Texture { get; set; }
/// <summary>Depth map for the lighting system, loaded from <see cref="SpriteID"/> + "_depth".</summary>
public Texture2D DepthTexture { get; set; }
/// <summary>Albedo color map for the lighting system, loaded from <see cref="SpriteID"/> + "_albedo".</summary>
public Texture2D AlbedoTexture { get; set; }
/// <summary>Preloads textures when the dialog queue is first created, resolving SpriteID through the factory.</summary>
public override ConstructType ConstructType { get; set; } = ConstructType.OnStartQueue;
/// <summary>Background is set imperatively — the dialog continues immediately.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Resolves <see cref="SpriteID"/> plus its <c>_depth</c>/<c>_albedo</c> variants through the background factory.</summary>
public override void DoConstruct()
{
base.DoConstruct();
if (Texture == null && !string.IsNullOrEmpty(SpriteID))
Texture = BackgroundFactory.Instance.Get(SpriteID);
if (!string.IsNullOrEmpty(SpriteID))
{
DepthTexture = BackgroundFactory.Instance.Get(SpriteID + "_depth");
AlbedoTexture = BackgroundFactory.Instance.Get(SpriteID + "_albedo");
}
}
/// <summary>
/// Applies the background to the ViewBox, kills any in-flight background
/// transition and feeds the depth/albedo maps to the light controller.
/// Consumed textures are nulled so a reused action cannot re-apply stale data.
/// </summary>
public override void DoRun(DialogRuntime runtime)
{
if (Texture == null)
return;
SceneContextNode.LastBackgroundID = SpriteID;
var viewBox = Scene.Find<ViewBox>("ViewBox");
viewBox?.KillTransitionTween();
viewBox?.SetBackgroundTexture(Texture);
var controller = UiLightController.Instance;
controller?.SetupDepthMap(DepthTexture);
controller?.SetupAlbedoMap(AlbedoTexture);
Texture = null;
DepthTexture = null;
AlbedoTexture = null;
}
}
}
@@ -1,5 +1,5 @@
using Cthangover.Mods.Dialog.Action;
using Godot;
using Cthangover.Mods.Dialog.Action;
using Godot;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
@@ -8,26 +8,26 @@ using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Sets the background tint color. DoRun is currently a no-op — the Color
/// property is stored for external systems to read. Likely intended as a
/// declarative instruction consumed by a background-rendering node rather
/// than applied imperatively.
/// </summary>
public class ActionBackgroundColor : ActionCommand
{
/// <summary>Target tint color. Stored declaratively — consumed by background-rendering nodes rather than applied imperatively here.</summary>
public Color Color { get; set; } = Colors.Black;
/// <summary>Imperative — the dialog continues immediately after this action is processed.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Deliberate no-op: the tint is declarative data for background-rendering nodes, not applied here.</summary>
public override void DoRun(DialogRuntime runtime)
{
}
}
}
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Sets the background tint color. DoRun is currently a no-op — the Color
/// property is stored for external systems to read. Likely intended as a
/// declarative instruction consumed by a background-rendering node rather
/// than applied imperatively.
/// </summary>
public class ActionBackgroundColor : ActionCommand
{
/// <summary>Target tint color. Stored declaratively — consumed by background-rendering nodes rather than applied imperatively here.</summary>
public Color Color { get; set; } = Colors.Black;
/// <summary>Imperative — the dialog continues immediately after this action is processed.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Deliberate no-op: the tint is declarative data for background-rendering nodes, not applied here.</summary>
public override void DoRun(DialogRuntime runtime)
{
}
}
}
@@ -13,10 +13,13 @@ namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Tween-driven background fade in/out. Animates the modulate alpha of the
/// dialog box's parent Control over Duration seconds. Creates and manages
/// its own Tween internally, killing any previous tween before starting a
/// new one to prevent overlap. Can optionally set WaitType to WaitTime with
/// a matching duration so the dialog pauses during the transition.
/// dialog box's parent Control over Duration seconds. The fade belongs to the
/// background, not to the action: by default (NoWait) the runtime destructs the
/// action right after it runs, and the fade must go on. So the tween is shared
/// by all instances — a new fade kills the previous one to prevent overlap —
/// and destructing the action leaves it running; the tween is bound to the
/// background node and dies with it. Can optionally set WaitType to WaitTime
/// with a matching duration so the dialog pauses during the transition.
/// </summary>
public class ActionBackgroundShowHide : ActionCommand
{
@@ -25,7 +28,7 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Duration of the fade tween in seconds.</summary>
public float Duration { get; set; } = 1f;
private Tween activeTween;
private static Tween activeTween;
/// <summary>Default NoWait — overridden by the DialogQueue builder if the wait flag is set, changing to WaitTime to pause during transition.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
@@ -47,18 +50,12 @@ namespace Cthangover.Mods.Dialog.Action.Impls
}
}
/// <summary>Kills the fade tween so a destructed action cannot keep animating.</summary>
public override void DoDestruct()
{
base.DoDestruct();
StopTween();
}
private void StopTween()
private static void StopTween()
{
if (activeTween != null)
{
activeTween.Kill();
if (GodotObject.IsInstanceValid(activeTween))
activeTween.Kill();
activeTween = null;
}
}
+48 -48
View File
@@ -1,5 +1,5 @@
using Cthangover.Mods.Dialog.Action;
using Godot;
using Cthangover.Mods.Dialog.Action;
using Godot;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
@@ -8,49 +8,49 @@ using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Waits for a fixed duration. When HiddenMode is true, hides the dialog
/// body for the duration (for dramatic pauses or cutscene beats). ShowText,
/// if set, displays a temporary message during the wait. DoDestruct is a
/// no-op because body visibility is implicitly restored on next Show.
/// </summary>
public class ActionDelay : ActionCommand
{
/// <summary>When true, hides the dialog body for the duration of the wait (dramatic pauses, cutscene beats).</summary>
public bool HiddenMode { get; set; }
/// <summary>Optional temporary text displayed while waiting. Set to null to keep the current dialog text.</summary>
public string ShowText { get; set; }
/// <summary>Pauses the dialog until <see cref="WaitTime"/> elapses.</summary>
public override WaitType WaitType { get; set; } = WaitType.WaitTime;
/// <summary>Duration of the pause in seconds. Default 1.0.</summary>
public override float WaitTime { get; set; } = 1f;
/// <summary>Shows the optional <see cref="ShowText"/> and, in <see cref="HiddenMode"/>, hides the dialog body for the pause duration.</summary>
public override void DoRun(DialogRuntime runtime)
{
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
if (ShowText != null)
dialogBox.SetText(ShowText);
if (HiddenMode)
dialogBox.Body.SetDeferred("visible", false);
}
/// <summary>No-op: the dialog body hidden by <see cref="HiddenMode"/> is restored implicitly by the next show.</summary>
public override void DoDestruct()
{
base.DoDestruct();
if (HiddenMode)
{
// Body restored on next Show
}
}
}
}
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Waits for a fixed duration. When HiddenMode is true, hides the dialog
/// body for the duration (for dramatic pauses or cutscene beats). ShowText,
/// if set, displays a temporary message during the wait. DoDestruct is a
/// no-op because body visibility is implicitly restored on next Show.
/// </summary>
public class ActionDelay : ActionCommand
{
/// <summary>When true, hides the dialog body for the duration of the wait (dramatic pauses, cutscene beats).</summary>
public bool HiddenMode { get; set; }
/// <summary>Optional temporary text displayed while waiting. Set to null to keep the current dialog text.</summary>
public string ShowText { get; set; }
/// <summary>Pauses the dialog until <see cref="WaitTime"/> elapses.</summary>
public override WaitType WaitType { get; set; } = WaitType.WaitTime;
/// <summary>Duration of the pause in seconds. Default 1.0.</summary>
public override float WaitTime { get; set; } = 1f;
/// <summary>Shows the optional <see cref="ShowText"/> and, in <see cref="HiddenMode"/>, hides the dialog body for the pause duration.</summary>
public override void DoRun(DialogRuntime runtime)
{
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
if (ShowText != null)
dialogBox.SetText(ShowText);
if (HiddenMode)
dialogBox.Body.SetDeferred("visible", false);
}
/// <summary>No-op: the dialog body hidden by <see cref="HiddenMode"/> is restored implicitly by the next show.</summary>
public override void DoDestruct()
{
base.DoDestruct();
if (HiddenMode)
{
// Body restored on next Show
}
}
}
}
+41 -15
View File
@@ -9,57 +9,83 @@ using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
using Cthangover.Mods.Interface.View;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Spawns a visual effect from the EffectFactory by ID. The spawned node
/// persists until the action is destructed. Uses DestructType.OnDelayed
/// because the effect must outlive the action's run — the effect node's own
/// animation or lifetime controls when it disappears. DoRun is a no-op
/// because construction handles spawning.
/// Spawns a visual effect from the EffectFactory by ID (or a ready
/// <see cref="Prefab"/>, bypassing the factory like ActionBackground.Texture).
/// The scene is instantiated on construction and put into the ViewBox's UI
/// layer (above all scene visuals, below the dialog) when the action runs; the
/// node persists until the action is destructed. Uses DestructType.OnDelayed
/// because the effect must outlive the action's run: the runtime frees it when
/// the dialog ends (End destructs every constructed action), unless the effect
/// node frees itself first.
/// </summary>
public class ActionEffect : ActionCommand
{
/// <summary>Identifier resolved through <see cref="EffectFactory"/> to a PackedScene. The spawned node persists until destructed.</summary>
public string EffectID { get; set; }
/// <summary>Effect scene to spawn instead of resolving <see cref="EffectID"/>; null resolves through the factory.</summary>
public PackedScene Prefab { get; set; }
private Node spawnedEffect;
/// <summary>The dialog continues immediately — the spawned effect plays independently.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>OnDelayed ensures the effect node outlives the action's run phase. Destruct is called manually when the effect finishes.</summary>
/// <summary>OnDelayed keeps the effect node alive past the action's run; it is freed when the dialog ends.</summary>
public override DestructType DestructType { get; set; } = DestructType.OnDelayed;
/// <summary>Resolves <see cref="EffectID"/> through <see cref="EffectFactory"/> and instantiates the effect scene.</summary>
/// <summary>Resolves the effect scene (<see cref="Prefab"/> or <see cref="EffectID"/> through <see cref="EffectFactory"/>) and instantiates it.</summary>
public override void DoConstruct()
{
base.DoConstruct();
if (string.IsNullOrEmpty(EffectID))
return;
var packedScene = EffectFactory.Instance.Get(EffectID);
var packedScene = Prefab;
if (packedScene == null)
{
GameLogger.Log("DIALOG", $"ActionEffect: effect '{EffectID}' not found", LogLevel.Error);
return;
if (string.IsNullOrEmpty(EffectID))
return;
packedScene = EffectFactory.Instance.Get(EffectID);
if (packedScene == null)
{
GameLogger.Log("DIALOG", $"ActionEffect: effect '{EffectID}' not found", LogLevel.Error);
return;
}
}
spawnedEffect = packedScene.Instantiate();
}
/// <summary>No-op: spawning happens during construction so the effect is ready before the queue reaches this point.</summary>
/// <summary>Shows the instantiated effect: adds it to the ViewBox UI layer. Without a ViewBox the effect is dropped with an error.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (spawnedEffect == null || spawnedEffect.IsInsideTree())
return;
var layer = Scene.Find<ViewBox>("ViewBox")?.GetInteractiveLayer("ui");
if (layer == null)
{
GameLogger.Log("DIALOG", $"ActionEffect: no ViewBox to show effect '{EffectID}'", LogLevel.Error);
spawnedEffect.QueueFree();
spawnedEffect = null;
return;
}
layer.AddChild(spawnedEffect);
}
/// <summary>Frees the spawned effect node; paired with the OnDelayed destruct so effects outlive the action's run.</summary>
/// <summary>Frees the spawned effect node (unless it already freed itself); paired with the OnDelayed destruct so effects outlive the action's run.</summary>
public override void DoDestruct()
{
base.DoDestruct();
if (spawnedEffect != null)
{
spawnedEffect.QueueFree();
if (GodotObject.IsInstanceValid(spawnedEffect))
spawnedEffect.QueueFree();
spawnedEffect = null;
}
}
+21 -21
View File
@@ -1,5 +1,5 @@
using Cthangover.Mods.Dialog.Action;
using Godot;
using Cthangover.Mods.Dialog.Action;
using Godot;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
@@ -8,22 +8,22 @@ using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// No-op placeholder action. Used as a labeled jump target (Point) for GoTo
/// commands — provides an addressable marker in the dialog sequence without
/// any visible effect. NoWait so the dialog skips through labels instantly.
/// </summary>
public class ActionEmpty : ActionCommand
{
/// <summary>Label actions skip immediately so the dialog doesn't pause on markers.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>No-op: the action exists only to carry the jump-target ID.</summary>
public override void DoRun(DialogRuntime runtime)
{
}
}
}
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// No-op placeholder action. Used as a labeled jump target (Point) for GoTo
/// commands — provides an addressable marker in the dialog sequence without
/// any visible effect. NoWait so the dialog skips through labels instantly.
/// </summary>
public class ActionEmpty : ActionCommand
{
/// <summary>Label actions skip immediately so the dialog doesn't pause on markers.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>No-op: the action exists only to carry the jump-target ID.</summary>
public override void DoRun(DialogRuntime runtime)
{
}
}
}
+22 -22
View File
@@ -1,5 +1,5 @@
using Cthangover.Mods.Dialog.Action;
using Godot;
using Cthangover.Mods.Dialog.Action;
using Godot;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
@@ -8,23 +8,23 @@ using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Terminates the dialog queue immediately. Calls Runtime.End() which
/// destructs all remaining actions, processes the cleanup queue, hides
/// the dialog box, and fires the dialog-end event.
/// </summary>
public class ActionEnd : ActionCommand
{
/// <summary>Ends the dialog queue immediately — no pause, no return.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Terminates the queue via <see cref="DialogRuntime.End"/> — destructs remainders, runs cleanup, hides the box, fires the end event.</summary>
public override void DoRun(DialogRuntime runtime)
{
runtime.End();
}
}
}
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Terminates the dialog queue immediately. Calls Runtime.End() which
/// destructs all remaining actions, processes the cleanup queue, hides
/// the dialog box, and fires the dialog-end event.
/// </summary>
public class ActionEnd : ActionCommand
{
/// <summary>Ends the dialog queue immediately — no pause, no return.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Terminates the queue via <see cref="DialogRuntime.End"/> — destructs remainders, runs cleanup, hides the box, fires the end event.</summary>
public override void DoRun(DialogRuntime runtime)
{
runtime.End();
}
}
}
@@ -38,9 +38,10 @@ namespace Cthangover.Mods.Dialog.Action.Impls
Texture = BackgroundFactory.Instance.Get(SpriteID);
}
/// <summary>Applies the preloaded texture to the ViewBox foreground, then nulls it so a reused action cannot re-apply stale data.</summary>
/// <summary>Stops a running foreground animation (it would overwrite the picture), applies the preloaded texture to the ViewBox foreground, then nulls it so a reused action cannot re-apply stale data.</summary>
public override void DoRun(DialogRuntime runtime)
{
ActionAnimatedForeground.StopActive();
var viewBox = Scene.Find<ViewBox>("ViewBox");
viewBox?.SetForegroundTexture(Texture);
Texture = null;
+25 -25
View File
@@ -1,5 +1,5 @@
using Cthangover.Mods.Dialog.Action;
using Godot;
using Cthangover.Mods.Dialog.Action;
using Godot;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
@@ -8,26 +8,26 @@ using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Unconditional jump to a labeled action in the dialog queue. NoWait so
/// the jump happens transparently. The target is resolved by action ID
/// (set via ActionCommand.SetID or the Point()/Empty() fluent methods).
/// </summary>
public class ActionGoTo : ActionCommand
{
/// <summary>Target action ID to jump to. Resolved by linear search through the dialog queue.</summary>
public string GoTo { get; set; }
/// <summary>Jump happens transparently — the dialog continues from the target without pausing.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Jumps the queue index to the action whose ID matches <see cref="GoTo"/>.</summary>
public override void DoRun(DialogRuntime runtime)
{
runtime.TryGoTo(GoTo);
}
}
}
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Unconditional jump to a labeled action in the dialog queue. NoWait so
/// the jump happens transparently. The target is resolved by action ID
/// (set via ActionCommand.SetID or the Point()/Empty() fluent methods).
/// </summary>
public class ActionGoTo : ActionCommand
{
/// <summary>Target action ID to jump to. Resolved by linear search through the dialog queue.</summary>
public string GoTo { get; set; }
/// <summary>Jump happens transparently — the dialog continues from the target without pausing.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Jumps the queue index to the action whose ID matches <see cref="GoTo"/>.</summary>
public override void DoRun(DialogRuntime runtime)
{
runtime.TryGoTo(GoTo);
}
}
}
+24 -24
View File
@@ -1,24 +1,24 @@
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Hides the dialog box. Used in cutscenes where UI chrome should be
/// temporarily hidden while animations or background transitions play.
/// Paired with ActionShowDialog to restore visibility.
/// </summary>
public class ActionHideDialog : ActionCommand
{
/// <summary>Hides the dialog immediately — the queue continues without pause.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Hides the dialog box chrome while keeping the queue running.</summary>
public override void DoRun(DialogRuntime runtime)
{
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
dialogBox.Visible = false;
}
}
}
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Hides the dialog box. Used in cutscenes where UI chrome should be
/// temporarily hidden while animations or background transitions play.
/// Paired with ActionShowDialog to restore visibility.
/// </summary>
public class ActionHideDialog : ActionCommand
{
/// <summary>Hides the dialog immediately — the queue continues without pause.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Hides the dialog box chrome while keeping the queue running.</summary>
public override void DoRun(DialogRuntime runtime)
{
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
dialogBox.Visible = false;
}
}
}
+4 -4
View File
@@ -14,7 +14,10 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>
/// Conditional branch: evaluates a Func&lt;bool&gt; and jumps to TrueGoTo or
/// FalseGoTo based on the result. If the chosen target is null/empty, falls
/// through to Next(). NoWait so branching is transparent to the player.
/// through to the next action. NoWait so branching is transparent to the player:
/// the runtime itself advances past a NoWait action, so the branch never calls
/// <see cref="DialogRuntime.Next"/> — doing it here as well skipped the action
/// after the <c>if</c> without running it.
/// </summary>
public class ActionIf : ActionCommand
{
@@ -34,7 +37,6 @@ namespace Cthangover.Mods.Dialog.Action.Impls
if (Condition == null)
{
GameLogger.Log("DIALOG", "ActionIf: Condition delegate is null", LogLevel.Error);
runtime.Next();
return;
}
@@ -43,8 +45,6 @@ namespace Cthangover.Mods.Dialog.Action.Impls
if (!string.IsNullOrEmpty(goTo))
runtime.TryGoTo(goTo);
else
runtime.Next();
}
}
}
+40 -40
View File
@@ -1,40 +1,40 @@
using Cthangover.Mods.Time;
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.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Applies static lights from a JSON string to the UiLightController.
/// An empty or null string clears all static lights. The JSON is deserialized
/// into LightDef objects with viewport-relative coordinates, which are then
/// converted to pixel positions. Supports up to 10 static lights plus the
/// player's dynamic lamp.
/// </summary>
public class ActionLightSet : ActionCommand
{
/// <summary>JSON string defining static light positions and properties. Empty or null clears all static lights.</summary>
public string LightsJson { get; set; }
/// <summary>Lights are set imperatively — the dialog continues immediately.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Clears static lights when <see cref="LightsJson"/> is empty, otherwise parses and applies the light definitions to the scene's <see cref="UiLightController"/>.</summary>
public override void DoRun(DialogRuntime runtime)
{
var controller = Scene.Find<UiLightController>("Lights");
if (controller == null)
return;
if (string.IsNullOrEmpty(LightsJson))
controller.ClearStaticLights();
else
controller.SetStaticLights(LightsJson);
}
}
}
using Cthangover.Mods.Time;
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.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Applies static lights from a JSON string to the UiLightController.
/// An empty or null string clears all static lights. The JSON is deserialized
/// into LightDef objects with viewport-relative coordinates, which are then
/// converted to pixel positions. Supports up to 10 static lights plus the
/// player's dynamic lamp.
/// </summary>
public class ActionLightSet : ActionCommand
{
/// <summary>JSON string defining static light positions and properties. Empty or null clears all static lights.</summary>
public string LightsJson { get; set; }
/// <summary>Lights are set imperatively — the dialog continues immediately.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Clears static lights when <see cref="LightsJson"/> is empty, otherwise parses and applies the light definitions to the scene's <see cref="UiLightController"/>.</summary>
public override void DoRun(DialogRuntime runtime)
{
var controller = Scene.Find<UiLightController>("Lights");
if (controller == null)
return;
if (string.IsNullOrEmpty(LightsJson))
controller.ClearStaticLights();
else
controller.SetStaticLights(LightsJson);
}
}
}
@@ -1,33 +1,33 @@
using Cthangover.Mods.Dialog.Action;
using Cthangover.Mods.Time;
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.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Toggles the light controller's time-of-day mode. When UseTime is false,
/// static lighting is used; when true, lighting responds to the in-game clock.
/// </summary>
public class ActionLightUseTime : ActionCommand
{
/// <summary>True = lights respond to in-game time of day; false = static lighting mode.</summary>
public bool UseTime { get; set; } = true;
/// <summary>Time mode toggle is instant — the dialog continues without pause.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Switches the scene's <see cref="UiLightController"/> between time-of-day lighting and static lights.</summary>
public override void DoRun(DialogRuntime runtime)
{
var controller = Scene.Find<UiLightController>("Lights");
if (controller != null)
controller.IsUseLight = UseTime;
}
}
}
using Cthangover.Mods.Dialog.Action;
using Cthangover.Mods.Time;
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.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Toggles the light controller's time-of-day mode. When UseTime is false,
/// static lighting is used; when true, lighting responds to the in-game clock.
/// </summary>
public class ActionLightUseTime : ActionCommand
{
/// <summary>True = lights respond to in-game time of day; false = static lighting mode.</summary>
public bool UseTime { get; set; } = true;
/// <summary>Time mode toggle is instant — the dialog continues without pause.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Switches the scene's <see cref="UiLightController"/> between time-of-day lighting and static lights.</summary>
public override void DoRun(DialogRuntime runtime)
{
var controller = Scene.Find<UiLightController>("Lights");
if (controller != null)
controller.IsUseLight = UseTime;
}
}
}
+37 -37
View File
@@ -1,37 +1,37 @@
using Cthangover.Core.Audio;
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.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Starts background music playback via IAudioService. Uses MusicType.Ambient
/// so the music layer is treated as atmospheric background rather than UI
/// feedback. If the same music track is already playing, the audio service handles
/// deduplication.
/// </summary>
public class ActionMusic : ActionCommand
{
/// <summary>Music track identifier resolved through the audio system. Empty strings are silently ignored.</summary>
public string Music { get; set; }
/// <summary>Music starts playing in the background — the dialog continues immediately.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Starts the named track as ambient background music through <see cref="IAudioService"/>; empty names are ignored.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (string.IsNullOrEmpty(Music))
return;
var audioService = Scene.Find<Node>("AudioService") as IAudioService;
audioService?.PlayMusic(Music, MusicType.Ambient);
}
}
}
using Cthangover.Core.Audio;
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.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Starts background music playback via IAudioService. Uses MusicType.Ambient
/// so the music layer is treated as atmospheric background rather than UI
/// feedback. If the same music track is already playing, the audio service handles
/// deduplication.
/// </summary>
public class ActionMusic : ActionCommand
{
/// <summary>Music track identifier resolved through the audio system. Empty strings are silently ignored.</summary>
public string Music { get; set; }
/// <summary>Music starts playing in the background — the dialog continues immediately.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Starts the named track as ambient background music through <see cref="IAudioService"/>; empty names are ignored.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (string.IsNullOrEmpty(Music))
return;
var audioService = Scene.Find<Node>("AudioService") as IAudioService;
audioService?.PlayMusic(Music, MusicType.Ambient);
}
}
}
+29 -29
View File
@@ -1,29 +1,29 @@
using Cthangover.Core.Audio;
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.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Pauses background music playback via IAudioService without stopping the
/// track. Can be resumed with ActionMusicPlay.
/// </summary>
public class ActionMusicPause : ActionCommand
{
/// <summary>Pause is instant — the dialog continues without waiting for audio acknowledgment.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Pauses the current music track without stopping it, so <see cref="ActionMusicPlay"/> can resume it.</summary>
public override void DoRun(DialogRuntime runtime)
{
var audioService = Scene.Find<Node>("AudioService") as IAudioService;
audioService?.PauseMusic(true);
}
}
}
using Cthangover.Core.Audio;
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.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Pauses background music playback via IAudioService without stopping the
/// track. Can be resumed with ActionMusicPlay.
/// </summary>
public class ActionMusicPause : ActionCommand
{
/// <summary>Pause is instant — the dialog continues without waiting for audio acknowledgment.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Pauses the current music track without stopping it, so <see cref="ActionMusicPlay"/> can resume it.</summary>
public override void DoRun(DialogRuntime runtime)
{
var audioService = Scene.Find<Node>("AudioService") as IAudioService;
audioService?.PauseMusic(true);
}
}
}
+28 -28
View File
@@ -1,28 +1,28 @@
using Cthangover.Core.Audio;
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.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Resumes paused background music. Paired with ActionMusicPause.
/// </summary>
public class ActionMusicPlay : ActionCommand
{
/// <summary>Resume is instant — the dialog continues without waiting for audio.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Resumes music paused by <see cref="ActionMusicPause"/>.</summary>
public override void DoRun(DialogRuntime runtime)
{
var audioService = Scene.Find<Node>("AudioService") as IAudioService;
audioService?.PauseMusic(false);
}
}
}
using Cthangover.Core.Audio;
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.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Resumes paused background music. Paired with ActionMusicPause.
/// </summary>
public class ActionMusicPlay : ActionCommand
{
/// <summary>Resume is instant — the dialog continues without waiting for audio.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Resumes music paused by <see cref="ActionMusicPause"/>.</summary>
public override void DoRun(DialogRuntime runtime)
{
var audioService = Scene.Find<Node>("AudioService") as IAudioService;
audioService?.PauseMusic(false);
}
}
}
+31 -31
View File
@@ -1,4 +1,4 @@
using Cthangover.Core.Utils;
using Cthangover.Core.Utils;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
@@ -7,33 +7,33 @@ using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Executes an arbitrary Action delegate inside the dialog queue.
/// Allows imperative code injection (e.g. quest state changes, UI toggles)
/// without creating a dedicated action class. NoWait so the queue advances
/// immediately after the delegate returns.
/// </summary>
public class ActionRun : ActionCommand
{
/// <summary>Arbitrary delegate executed during dialog sequencing. Null delegates are logged and skipped.</summary>
public System.Action Executable { get; set; }
/// <summary>The delegate runs and the dialog advances — no pausing.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Invokes <see cref="Executable"/>; a null delegate is logged and skipped so the queue keeps flowing.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (Executable == null)
{
GameLogger.Log("DIALOG", "ActionRun: Executable delegate is null", LogLevel.Error);
return;
}
Executable();
}
}
}
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Executes an arbitrary Action delegate inside the dialog queue.
/// Allows imperative code injection (e.g. quest state changes, UI toggles)
/// without creating a dedicated action class. NoWait so the queue advances
/// immediately after the delegate returns.
/// </summary>
public class ActionRun : ActionCommand
{
/// <summary>Arbitrary delegate executed during dialog sequencing. Null delegates are logged and skipped.</summary>
public System.Action Executable { get; set; }
/// <summary>The delegate runs and the dialog advances — no pausing.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Invokes <see cref="Executable"/>; a null delegate is logged and skipped so the queue keeps flowing.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (Executable == null)
{
GameLogger.Log("DIALOG", "ActionRun: Executable delegate is null", LogLevel.Error);
return;
}
Executable();
}
}
}
@@ -0,0 +1,25 @@
using Cthangover.Core.Scenes;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Sets a story flag of the running game (<see cref="ScenarioCondition.SetFlag"/>).
/// Unlike <see cref="ActionSet"/>, whose variables end with the dialog, a
/// flag is game state: it is saved, read by scenario conditions
/// (<c>condition: door == "open"</c>) and its change rescans the scene's
/// event chain, so a scenario waiting for it can start right after.
/// </summary>
public class ActionSetFlag : ActionCommand
{
/// <summary>Flag name, as used in conditions.</summary>
public string Name { get; set; }
/// <summary>Flag value; numeric values compare as integers in conditions.</summary>
public string Value { get; set; }
/// <summary>Setting a flag is instant — the dialog continues without pause.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <inheritdoc/>
public override void DoRun(DialogRuntime runtime) => ScenarioCondition.SetFlag(Name, Value ?? string.Empty);
}
}
@@ -35,7 +35,7 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Scene switch is deferred — the dialog ends immediately with no return.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Marks the dialog locker as OneRun and defers <c>SceneManager.SwitchScene</c> so the tree is not mutated mid-queue.</summary>
/// <summary>Defers <c>SceneManager.SwitchScene</c> so the tree is not mutated mid-queue; the owning scenario is not forced into OneRun.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (string.IsNullOrEmpty(SceneName))
+25 -15
View File
@@ -14,10 +14,12 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// subscriber and fires OnUpdateCallback(float t) each frame with a normalized
/// progress value (0→1) over WaitTime seconds. WaitType is WaitEvent so the
/// dialog runtime pauses until elapsed >= WaitTime, at which point the action
/// advances the runtime directly (event-driven — no DialogBox poll) and
/// destructs itself. OnStartCallback/OnFinishCallback bracket the animation
/// for setup/teardown. Fixed 60fps step for deterministic progress regardless
/// of actual frame rate.
/// completes itself through <see cref="DialogBox.CompleteAction"/> (event-driven —
/// no DialogBox poll) and the box advances the queue, scheduling the timer of a
/// following <c>delay</c> like any other advance. OnStartCallback/OnFinishCallback
/// bracket the animation for setup/teardown. Progress follows the controller's
/// process delta, so the timeline lasts WaitTime seconds of game time at any frame
/// rate (and scales with <c>Engine.TimeScale</c> exactly like <c>delay</c> timers).
/// </summary>
public class ActionTimedUpdate : ActionCommand, IOnUpdateEvent
{
@@ -31,7 +33,7 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Fires once when the elapsed time exceeds <see cref="WaitTime"/> or when destructed.</summary>
public System.Action OnFinishCallback { get; set; }
/// <summary>Pauses the dialog until the elapsed time meets WaitTime — the action then advances the runtime itself.</summary>
/// <summary>Pauses the dialog until the elapsed time meets WaitTime — the action then completes itself.</summary>
public override WaitType WaitType { get; set; } = WaitType.WaitEvent;
/// <summary>Total duration in seconds over which <see cref="OnUpdateCallback"/> receives normalized progress.</summary>
public override float WaitTime { get; set; } = 1f;
@@ -39,12 +41,13 @@ namespace Cthangover.Mods.Dialog.Action.Impls
private float elapsed;
private bool isRunning;
private DialogRuntime runtime;
private SceneEventController controller;
/// <summary>Registers this action as an <see cref="IOnUpdateEvent"/> listener on the scene's event controller.</summary>
public override void DoConstruct()
{
base.DoConstruct();
var controller = Scene.Find<SceneEventController>("EventController");
controller = Scene.Find<SceneEventController>("EventController");
controller?.AddUpdateEventListener(this);
}
@@ -63,8 +66,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls
base.DoDestruct();
isRunning = false;
runtime = null;
var controller = Scene.Find<SceneEventController>("EventController");
controller?.RemoveUpdateEventListener(this);
controller = null;
OnFinishCallback?.Invoke();
}
@@ -73,19 +76,26 @@ namespace Cthangover.Mods.Dialog.Action.Impls
if (!isRunning)
return;
elapsed += 1f / 60f;
elapsed += (float)(controller?.GetProcessDeltaTime() ?? 0d);
var t = WaitTime > 0f ? Mathf.Min(elapsed / WaitTime, 1f) : 1f;
OnUpdateCallback?.Invoke(t);
if (elapsed >= WaitTime)
if (elapsed < WaitTime)
return;
// Advancing destructs this action, which clears the runtime field:
// keep the runtime in a local for the whole completion.
isRunning = false;
var owner = runtime;
if (owner?.DialogBox != null)
{
// Advances the runtime directly instead of relying on a DialogBox poll.
// Next() destructs this action (firing OnFinishCallback and detaching the
// update listener), then Run() continues the queue past the WaitEvent point.
isRunning = false;
runtime?.Next();
runtime?.Run();
owner.DialogBox.CompleteAction(this);
}
else if (owner != null)
{
owner.Next();
owner.Run();
}
}
}
+24 -24
View File
@@ -1,24 +1,24 @@
namespace Cthangover.Mods.Dialog.Action
{
/// <summary>
/// Determines how the DialogRuntime advances past this action. NoWait chains
/// immediately to the next action (for imperative commands). WaitClick pauses
/// until user input (click/key). WaitSelect pauses for variant selection.
/// WaitTime pauses for a fixed duration. WaitEvent pauses until an external
/// event marks the action as destructed.
/// </summary>
public enum WaitType
{
/// <summary>Don't wait — the runtime advances to the next action immediately in the same Run call.</summary>
NoWait,
/// <summary>Pause until the player clicks or presses the accept key. Used by dialog text actions.</summary>
WaitClick,
/// <summary>Pause until the player selects a choice variant. Used by choice menus.</summary>
WaitSelect,
/// <summary>Pause for a fixed duration in seconds (<see cref="ActionCommand.WaitTime"/>).</summary>
WaitTime,
/// <summary>Pause until an external event marks the action as destructed (<see cref="ActionCommand.IsDestructed"/>).</summary>
WaitEvent,
}
}
namespace Cthangover.Mods.Dialog.Action
{
/// <summary>
/// Determines how the DialogRuntime advances past this action. NoWait chains
/// immediately to the next action (for imperative commands). WaitClick pauses
/// until user input (click/key). WaitSelect pauses for variant selection.
/// WaitTime pauses for a fixed duration. WaitEvent pauses until an external
/// event marks the action as destructed.
/// </summary>
public enum WaitType
{
/// <summary>Don't wait — the runtime advances to the next action immediately in the same Run call.</summary>
NoWait,
/// <summary>Pause until the player clicks or presses the accept key. Used by dialog text actions.</summary>
WaitClick,
/// <summary>Pause until the player selects a choice variant. Used by choice menus.</summary>
WaitSelect,
/// <summary>Pause for a fixed duration in seconds (<see cref="ActionCommand.WaitTime"/>).</summary>
WaitTime,
/// <summary>Pause until an external event marks the action as destructed (<see cref="ActionCommand.IsDestructed"/>).</summary>
WaitEvent,
}
}
+70 -70
View File
@@ -1,70 +1,70 @@
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Dialog.Action.Impls;
using Godot;
using Cthangover.Core.UI;
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
{
/// <summary>
/// Renders a single dialog choice option. Walks up the scene tree in OnReady
/// to find the nearest IDialogBox parent — this avoids coupling to a specific
/// dialog implementation and lets answer items work regardless of nesting depth.
/// On click, calls SelectVariant on the found dialog box, passing the bound
/// SelectVariant model (which carries the GoTo target and display text).
/// </summary>
public partial class AnswerItem : ListItem<SelectVariant>
{
[Export] private Label textField;
private DialogBox dialogBox;
/// <summary>Binds the choice button from the answer_item scene prefab and finds the owning dialog box by walking up the tree, so items work at any nesting depth.</summary>
protected override void OnReady()
{
textField ??= GetNode<Label>("Button/TextLabel");
var btn = GetNode<Button>("Button");
if (btn != null)
btn.Pressed += OnClick;
if (dialogBox == null)
{
var parent = GetParent();
while (parent != null)
{
if (parent is DialogBox db)
{
dialogBox = db;
break;
}
parent = parent.GetParent();
}
}
}
/// <summary>Binds the variant model and sets the button label text to <see cref="SelectVariant.Text"/>.</summary>
public override void Construct(SelectVariant variant)
{
base.Construct(variant);
textField.Text = variant.Text;
}
private void OnClick()
{
dialogBox?.SelectVariant(Model);
}
/// <summary>Required by <see cref="IListItem{TModel}"/>. Intentionally empty — the parent AnswerBox handles cleanup.</summary>
public override void Destruct()
{ }
}
}
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Dialog.Action.Impls;
using Godot;
using Cthangover.Core.UI;
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
{
/// <summary>
/// Renders a single dialog choice option. Walks up the scene tree in OnReady
/// to find the nearest IDialogBox parent — this avoids coupling to a specific
/// dialog implementation and lets answer items work regardless of nesting depth.
/// On click, calls SelectVariant on the found dialog box, passing the bound
/// SelectVariant model (which carries the GoTo target and display text).
/// </summary>
public partial class AnswerItem : ListItem<SelectVariant>
{
[Export] private Label textField;
private DialogBox dialogBox;
/// <summary>Binds the choice button from the answer_item scene prefab and finds the owning dialog box by walking up the tree, so items work at any nesting depth.</summary>
protected override void OnReady()
{
textField ??= GetNode<Label>("Button/TextLabel");
var btn = GetNode<Button>("Button");
if (btn != null)
btn.Pressed += OnClick;
if (dialogBox == null)
{
var parent = GetParent();
while (parent != null)
{
if (parent is DialogBox db)
{
dialogBox = db;
break;
}
parent = parent.GetParent();
}
}
}
/// <summary>Binds the variant model and sets the button label text to <see cref="SelectVariant.Text"/>.</summary>
public override void Construct(SelectVariant variant)
{
base.Construct(variant);
textField.Text = variant.Text;
}
private void OnClick()
{
dialogBox?.SelectVariant(Model);
}
/// <summary>Required by <see cref="IListItem{TModel}"/>. Intentionally empty — the parent AnswerBox handles cleanup.</summary>
public override void Destruct()
{ }
}
}
+49 -17
View File
@@ -80,7 +80,11 @@ namespace Cthangover.Mods.Dialog
/// <summary>If false, new dialog queues are rejected. Set externally to prevent dialog during non-dialog scenes.</summary>
public bool IsActive { get; set; } = true;
/// <summary>Called by answer items when the player picks a choice. Hides the answer box and jumps the runtime to the variant's GoTo target.</summary>
/// <summary>
/// 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).
/// </summary>
public void SelectVariant(SelectVariant variant)
{
if (answerBox != null)
@@ -89,7 +93,10 @@ namespace Cthangover.Mods.Dialog
answerBox = null;
}
Runtime.TryGoTo(variant.GoTo);
if (string.IsNullOrEmpty(variant.GoTo))
Runtime.Next();
else
Runtime.TryGoTo(variant.GoTo);
Runtime.Run();
ScheduleWaitTimer();
}
@@ -172,32 +179,51 @@ namespace Cthangover.Mods.Dialog
/// <summary>
/// Loads and starts a dialog sequence. Validates that: (1) dialog is not null, (2) this DialogBox is active,
/// (3) no existing dialog locker is running. On success, shows the box and delegates to <see cref="DialogRuntime.SetDialogQueueAndRun"/>.
/// (3) no dialog is running — neither one with a locker nor a locker-less one (an interactive's click dialog),
/// see <see cref="IsRunning"/>. On success, shows the box and delegates to <see cref="DialogRuntime.SetDialogQueueAndRun"/>.
/// Returns false when the queue was rejected, so callers (event chains) do not count a run that never happened.
/// </summary>
public void SetDialogQueueAndRun(DialogQueue dialog, IEnumerable<IActionCommand> endDialogQueue, int startIndex, ExecutableEvent locker)
public bool SetDialogQueueAndRun(DialogQueue dialog, IEnumerable<IActionCommand> endDialogQueue, int startIndex, ExecutableEvent locker)
{
if (dialog == null)
{
GameLogger.Log("DIALOG", $"can't run '{locker?.GetType().FullName}', dialog is null", LogLevel.Error);
return;
return false;
}
if (!IsActive)
{
GameLogger.Log("DIALOG", $"can't run '{locker?.GetType().FullName}', DialogBox is not active", LogLevel.Error);
return;
return false;
}
if (Locker != null)
if (IsRunning)
{
GameLogger.Log("DIALOG", $"can't run '{locker?.GetType().FullName}', locker already executing!", LogLevel.Error);
return;
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}");
Locker = locker;
Show();
Runtime.SetDialogQueueAndRun(this, dialog.Queue, endDialogQueue, startIndex);
Runtime.Dialog = dialog;
Locker = locker;
Show();
Runtime.Run();
ScheduleWaitTimer();
return true;
}
/// <summary>
/// Completion entry point for actions that finish on their own (<see cref="WaitType.WaitEvent"/>): advances
/// past <paramref name="action"/> and schedules the wait timer of whatever comes next, exactly like a click
/// or an elapsed delay. Ignored when <paramref name="action"/> is no longer the current one (a stale
/// completion of an action the dialog already left) or while the debugger holds the runtime.
/// </summary>
public void CompleteAction(IActionCommand action)
{
if (Runtime.IsHeld || action == null || Runtime.CurrentAction != action)
return;
Runtime.Next();
Runtime.Run();
ScheduleWaitTimer();
}
@@ -347,8 +373,12 @@ namespace Cthangover.Mods.Dialog
Locker = null;
}
/// <summary>Whether a dialog is currently running (Locker is not null).</summary>
public bool IsRunning => Locker != null;
/// <summary>
/// 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.
/// </summary>
public bool IsRunning => Locker != null || !Runtime.IsEnd;
/// <summary>Whether the current dialog queue reached its end (also true before the first queue loads).</summary>
public bool IsDialogEnded => Runtime.IsEnd;
@@ -375,13 +405,15 @@ namespace Cthangover.Mods.Dialog
/// Schedules a one-shot <see cref="SceneTreeTimer"/> that advances the dialog past the
/// current <see cref="WaitType.WaitTime"/> action once its <see cref="ActionCommand.WaitTime"/>
/// elapses. Replaces the former per-frame polling: the runtime now yields until the timer
/// fires exactly once, so no Update work happens between delays.
/// 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
/// <see cref="Resume"/>.
/// </summary>
private void ScheduleWaitTimer()
{
ClearWaitTimer();
if (!Runtime.IsWaitTime)
if (Runtime.IsHeld || !Runtime.IsWaitTime)
return;
var wait = Runtime.CurrentAction?.WaitTime ?? 0f;
@@ -399,13 +431,13 @@ namespace Cthangover.Mods.Dialog
/// 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
/// <see cref="Runtime.IsWaitTime"/> guard skips a stale timer that fired after the
/// dialog already moved on.
/// dialog already moved on; the hold guard keeps a held, not yet run action in place.
/// </summary>
private void OnWaitTimerTimeout()
{
waitTimer = null;
if (!Runtime.IsWaitTime)
if (Runtime.IsHeld || !Runtime.IsWaitTime)
return;
Runtime.Next();
+8
View File
@@ -130,6 +130,14 @@ namespace Cthangover.Mods.Dialog
return action;
}
/// <summary>Sets a story flag of the game (saved, read by scenario conditions) when the queue reaches this point.</summary>
public ActionSetFlag Flag(string name, string value)
{
var action = new ActionSetFlag { Name = name, Value = value };
Queue.Add(action);
return action;
}
/// <summary>Sets a runtime variable by name, using a lazy-evaluated callback for dynamic values not known at script authoring time.</summary>
public ActionSet Set(string name, Func<string> callback)
{
+35 -12
View File
@@ -25,8 +25,14 @@ namespace Cthangover.Mods.Dialog
/// GoTo searches the queue linearly by action ID and jumps the index; the
/// source action's destruct fires on jump. The isLastGoTo flag prevents double
/// advancement when a GoTo is the last action in its branch. End() processes
/// an optional cleanup queue (endDialogQueue) before tearing down, and raises
/// the dialog-end event via the SceneEventController for chaining.
/// an optional cleanup queue (endDialogQueue) before tearing down, and publishes
/// <see cref="DialogEndedEvent"/> on the <see cref="ModEventBus"/> exactly once per
/// queue — event chains pick the next scenario from it.
/// <para/>
/// An action may end the queue (<c>end</c>, a goto to a missing label) or start
/// another dialog from inside <see cref="Run"/>. Every load, end and abort bumps
/// a generation counter, and <see cref="Run"/> stops as soon as the generation
/// changed under it: the rest of the loop belongs to a queue that no longer runs.
/// Infinite loop protection caps at 100k iterations per Run call.
/// </summary>
public class DialogRuntime
@@ -37,6 +43,7 @@ namespace Cthangover.Mods.Dialog
private int goToActionIndex;
private int index;
private bool isLastGoTo;
private int generation;
private IDictionary<string, string> variables = new Dictionary<string, string>();
/// <summary>The dialog box instance this runtime drives. Set when a new queue is loaded.</summary>
@@ -131,8 +138,9 @@ namespace Cthangover.Mods.Dialog
}
/// <summary>
/// Loads a new dialog queue and prepares for execution. Resets variables, sets the dialog box reference,
/// ends any previous queue (destructing its actions), copies the new queue, and constructs OnStartQueue actions.
/// Loads a new dialog queue and prepares for execution. Ends any previous queue first — with its own
/// end actions and dialog box, before anything of the new queue is set — then resets variables, sets the
/// dialog box reference, copies the new queue, and constructs OnStartQueue actions.
/// Note: does NOT call Run() — the caller must call Run() separately.
/// </summary>
public void SetDialogQueueAndRun(DialogBox dialogBox,
@@ -140,14 +148,16 @@ namespace Cthangover.Mods.Dialog
IEnumerable<IActionCommand> endQueue,
int startIndex = 0)
{
variables = new Dictionary<string, string>();
DialogBox = dialogBox;
IsHeld = false;
this.endDialogQueue = endQueue == null ? null : endQueue.ToList();
if (Lists.IsNotEmpty(this.dialogQueue))
End();
generation++;
variables = new Dictionary<string, string>();
DialogBox = dialogBox;
IsHeld = false;
isLastGoTo = false;
this.endDialogQueue = endQueue == null ? null : endQueue.ToList();
this.dialogQueue = queue.ToList();
index = startIndex;
@@ -215,12 +225,14 @@ namespace Cthangover.Mods.Dialog
/// <summary>
/// Main execution loop. Iterates through actions, calling <see cref="IActionCommand.Run"/> on each and advancing
/// automatically for <see cref="WaitType.NoWait"/> actions. Stops the loop on any wait-type action (WaitClick, WaitSelect,
/// WaitTime, WaitEvent) so the runtime yields control until the condition is satisfied. Protected against infinite loops
/// WaitTime, WaitEvent) so the runtime yields control until the condition is satisfied. Returns right after an action
/// that ended or replaced the queue (see the generation note on the class). Protected against infinite loops
/// with a 100,000-iteration cap.
/// </summary>
public void Run()
{
var iteration = 0;
var runGeneration = generation;
for (;;)
{
var action = CurrentAction;
@@ -241,6 +253,8 @@ namespace Cthangover.Mods.Dialog
}
action.Run(this);
if (runGeneration != generation)
return;
if (iteration++ > 100000)
{
@@ -266,7 +280,8 @@ namespace Cthangover.Mods.Dialog
/// <summary>
/// Advances the queue index by one. Destructs the current action if its <see cref="DestructType"/> is OnEndAction.
/// Processes any delayed-destruct targets registered by the current action. Calls <see cref="End"/> if the queue is exhausted.
/// Processes any delayed-destruct targets registered by the current action. Calls <see cref="End"/> if the queue is
/// already exhausted (nothing to advance to).
/// Respects <see cref="isLastGoTo"/> — if a GoTo was the final action, the index is not incremented.
/// </summary>
public void Next()
@@ -291,6 +306,7 @@ namespace Cthangover.Mods.Dialog
{
GameLogger.Log("DIALOG", "Next: queue exhausted, ending");
End();
return;
}
index++;
@@ -298,11 +314,17 @@ namespace Cthangover.Mods.Dialog
/// <summary>
/// Terminates the dialog queue. Destructs all remaining constructed actions, hides the dialog box, processes the
/// end-dialog cleanup queue (if any), and raises the dialog-end event via <see cref="SceneEventController"/>.
/// end-dialog cleanup queue (if any), and publishes <see cref="DialogEndedEvent"/> on the <see cref="ModEventBus"/>.
/// Guards against end-queue actions starting a new dialog mid-cleanup by checking if the queue changed.
/// Idempotent: a queue that already ended (or never started) is not ended again, so the event is published
/// once per queue however many paths reach the end.
/// </summary>
public void End()
{
if (dialogQueue == null)
return;
generation++;
GameLogger.Log("DIALOG", $"End: {dialogQueue?.Count ?? 0} actions in queue, {(endDialogQueue?.Count ?? 0)} end actions");
if (Lists.IsNotEmpty(dialogQueue))
@@ -369,6 +391,7 @@ namespace Cthangover.Mods.Dialog
action.Destruct();
}
generation++;
dialogQueue = null;
endDialogQueue = null;
index = 0;
+62 -42
View File
@@ -1,6 +1,6 @@
using Cthangover.Core.Scenes;
using Cthangover.Mods.Dialog;
using Cthangover.Core.UI.Event;
using Cthangover.Core.Events;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Core.UI;
@@ -13,21 +13,23 @@ using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.Executable
{
/// <summary>
/// Base for auto-triggering dialog events. A standalone event (not inside an
/// IExecutableEventChain) only re-fires when the condition state changes
/// (bumped <see cref="ConditionStateMonitor.Version"/>), after its own running
/// dialog ends, or on the first run — with a 0.5s debounce. Checks that the
/// DialogBox is free (IsRunning == false) before building and executing a
/// dialog queue. IsOneRun prevents re-triggering after first execution;
/// CanRepeat allows the event to fire again on subsequent scenes/visits.
/// CheckConditions is virtual — ScenarioEvent overrides it to evaluate script
/// conditions.
/// Base for auto-triggering dialog events. Inside an IExecutableEventChain the
/// chain decides when the event runs. A standalone event (not inside a chain)
/// drives itself, event-driven: it asks for a deferred check when it becomes
/// ready, when the condition state changes (<see cref="ConditionStateChangedEvent"/>)
/// and when a dialog ends (<see cref="DialogEndedEvent"/>), and runs when the
/// state changed since its own last run (or on the first run) and the
/// DialogBox is free. Checks that the DialogBox is free (IsRunning == false)
/// before building and executing a dialog queue. IsOneRun prevents
/// re-triggering after first execution; CanRepeat allows the event to fire
/// again on subsequent scenes/visits. CheckConditions is virtual —
/// ScenarioEvent overrides it to evaluate script conditions.
/// </summary>
public partial class ExecutableEvent : Node, IOnUpdateEvent, IExecutableEvent
public partial class ExecutableEvent : Node, IExecutableEvent
{
protected DialogBox dialogBox;
private SceneEventController eventController;
private float timestamp;
private bool isStandalone;
private bool checkQueued;
// Version at which the event last ran (MinValue forces the first run).
private long _lastRunVersion = long.MinValue;
@@ -35,7 +37,7 @@ namespace Cthangover.Mods.Dialog.Executable
private bool _awaitingOwnEnd;
/// <summary>
/// <c>true</c> until <see cref="RunDialog"/> is called for the first time.
/// <c>true</c> until <see cref="RunDialog"/> starts the event's dialog for the first time.
/// Used by chains to determine whether a one-shot event has already fired.
/// </summary>
public bool IsFirstRun { get; private set; } = true;
@@ -66,13 +68,7 @@ namespace Cthangover.Mods.Dialog.Executable
/// </summary>
public virtual string ID => GetType().FullName;
/// <summary>
/// Priority within the event chain — lower values execute first. Default
/// is <c>1</c>, placing this after high-priority handlers.
/// </summary>
public int Priority => 1;
/// <summary>Finds the shared <see cref="DialogBox"/> and registers with the event controller for update polling unless a parent chain already drives this event.</summary>
/// <summary>Finds the shared <see cref="DialogBox"/>; a standalone event (no parent chain) subscribes to its triggers and asks for the first check.</summary>
public override void _Ready()
{
if (dialogBox == null)
@@ -84,30 +80,56 @@ namespace Cthangover.Mods.Dialog.Executable
}
}
var isInChain = GetParent() is IExecutableEventChain;
if (!isInChain)
isStandalone = GetParent() is not IExecutableEventChain;
if (isStandalone)
{
eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
eventController?.AddUpdateEventListener(this);
ModEventBus.Subscribe<ConditionStateChangedEvent>(OnConditionStateChanged);
ModEventBus.Subscribe<DialogEndedEvent>(OnDialogEnded);
RequestCheck();
}
GameLogger.Log("EVENT", $"{GetType().Name}._Ready: dialogBox={(dialogBox != null ? "found" : "null")}, chain={isInChain}, registered={!isInChain}", LogLevel.Debug);
GameLogger.Log("EVENT", $"{GetType().Name}._Ready: dialogBox={(dialogBox != null ? "found" : "null")}, chain={!isStandalone}, standalone={isStandalone}", LogLevel.Debug);
}
/// <summary>Detaches from the event controller's update polling when the event leaves the tree.</summary>
/// <summary>Detaches a standalone event from its triggers when it leaves the tree.</summary>
public override void _ExitTree()
{
if (eventController != null)
eventController.RemoveUpdateEventListener(this);
if (!isStandalone)
return;
ModEventBus.Unsubscribe<ConditionStateChangedEvent>(OnConditionStateChanged);
ModEventBus.Unsubscribe<DialogEndedEvent>(OnDialogEnded);
}
private void OnConditionStateChanged(ConditionStateChangedEvent evt)
{
RequestCheck();
}
private void OnDialogEnded(DialogEndedEvent evt)
{
RequestCheck();
}
// Coalesces triggers of one frame into one deferred check.
private void RequestCheck()
{
if (checkQueued)
return;
checkQueued = true;
Callable.From(Check).CallDeferred();
}
/// <summary>
/// Debounced poll (twice per second) that re-checks the repeat guards and
/// the <c>ConditionStateMonitor</c> version, then triggers the dialog when
/// the scene state changed since the last run.
/// Deferred check of a standalone event: re-checks the repeat guards and the
/// <c>ConditionStateMonitor</c> version, then triggers the dialog when the
/// scene state changed since the last run.
/// </summary>
public void OnUpdate()
private void Check()
{
checkQueued = false;
if (!IsInstanceValid(this) || !IsInsideTree())
return;
if (!IsFirstRun && !CanRepeat)
return;
@@ -125,11 +147,6 @@ namespace Cthangover.Mods.Dialog.Executable
if (ConditionStateMonitor.Version == _lastRunVersion)
return;
var time = Godot.Time.GetTicksUsec() / 1_000_000.0;
if (time - timestamp < 0.5f)
return;
timestamp = (float)time;
if (dialogBox.IsRunning)
return;
@@ -140,8 +157,10 @@ namespace Cthangover.Mods.Dialog.Executable
/// <summary>
/// Builds a <see cref="DialogQueue"/> via <see cref="CreateDialog"/> and queues
/// it on the <see cref="DialogBox"/>, passing <c>this</c> as the locker to
/// block other dialog attempts. Returns <c>false</c> if the one-shot already
/// ran, the dialog box is unavailable, or another locker is active.
/// block other dialog attempts. Returns <c>false</c> — and leaves
/// <see cref="IsFirstRun"/> untouched — if the one-shot already ran, the dialog
/// box is unavailable or busy, or the box rejected the queue (e.g. inactive
/// during a scene switch): a run that never happened is not counted.
/// </summary>
public bool RunDialog()
{
@@ -151,7 +170,7 @@ namespace Cthangover.Mods.Dialog.Executable
if (dialogBox == null)
return false;
if (dialogBox.Locker != null)
if (dialogBox.IsRunning)
return false;
GameLogger.Log("EVENT", $"{GetType().Name}.RunDialog: starting dialog...");
@@ -159,7 +178,8 @@ namespace Cthangover.Mods.Dialog.Executable
var dlg = new DialogQueue();
CreateDialog(dlg);
dialogBox.SetDialogQueueAndRun(dlg, null, StartIndex(dlg), this);
if (!dialogBox.SetDialogQueueAndRun(dlg, null, StartIndex(dlg), this))
return false;
IsFirstRun = false;
return true;
}
@@ -1,88 +0,0 @@
using Cthangover.Mods.Dialog;
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.Executable
{
/// <summary>
/// Abstract chain base: manages a list of ExecutableEvents and exposes
/// Play()/Stop()/IsActive for external control. GetNext() is abstract so
/// subclasses define their own selection strategy (sequential, conditional,
/// random). Play() calls RunDialog() on the next event, which internally
/// calls SetDialogQueueAndRun with `this` as the locker — the chain is
/// blocked until the dialog finishes.
/// </summary>
public abstract partial class ExecutableEventChainBase : Godot.Node, IExecutableEventChain
{
protected bool isActive = true;
protected System.Collections.Generic.List<ExecutableEvent> chain;
protected DialogBox dialogBox;
/// <summary>
/// Controls whether the chain is actively polling. Setting to <c>false</c>
/// pauses event dispatch; setting to <c>true</c> resumes.
/// </summary>
public bool IsActive
{
get => isActive;
set { isActive = value; }
}
/// <summary>
/// Pauses the chain. No further events will be dispatched until
/// <see cref="Play"/> is called.
/// </summary>
public void Stop()
{
IsActive = false;
}
/// <summary>
/// Activates the chain and immediately attempts to run the next event
/// returned by <see cref="GetNext"/>. If no event is available, the
/// chain deactivates itself.
/// </summary>
public void Play()
{
IsActive = true;
var next = GetNext();
if (next == null)
{
IsActive = false;
return;
}
next.RunDialog();
}
protected abstract ExecutableEvent GetNext();
/// <summary>Interface implementation: casts to <see cref="ExecutableEvent"/> and registers it with the chain node.</summary>
public virtual void AddEvent(IExecutableEvent evt)
{
var typed = (ExecutableEvent)evt;
AddChild(typed);
chain ??= new System.Collections.Generic.List<ExecutableEvent>();
chain.Add(typed);
}
/// <summary>Interface implementation: frees every registered event and empties the chain list.</summary>
public virtual void ClearEvents()
{
if (chain == null)
return;
foreach (var evt in chain)
evt.QueueFree();
chain.Clear();
}
}
}
@@ -1,90 +0,0 @@
using Cthangover.Core.Scenes;
using Cthangover.Mods.Dialog;
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.Interactive;
namespace Cthangover.Mods.Dialog.Executable
{
/// <summary>
/// Sequential list-based chain: rescans GetNext() only when the condition
/// state changes (bumped <see cref="ConditionStateMonitor.Version"/>), on the
/// end of its own running dialog, or on the first scan — with a debounce.
/// When an event runs, it's removed from the list first, so it's consumed and
/// the chain shrinks until empty (no self-loop is possible, so no skip-guard
/// is needed). Respects IsOneRun (skips already-run events) and
/// CheckConditions (skips events whose conditions fail). Use for ordered
/// event sequences where each event should play once in order.
/// </summary>
public partial class ExecutableEventListChain : ExecutableEventChainBase
{
private float checkDelay = 1f;
private float timestamp;
private long _lastScanVersion = long.MinValue;
private bool _pendingDialog;
private bool _wakeup;
/// <summary>Debounced chain driver: waits out the running dialog, rescans when the condition state changed and starts the next unconsumed event.</summary>
public override void _Process(double delta)
{
if (!isActive || chain.Count == 0)
{
IsActive = false;
return;
}
if (_pendingDialog)
{
if (dialogBox == null || dialogBox.Locker != null)
return;
_pendingDialog = false;
_lastScanVersion = ConditionStateMonitor.Version;
_wakeup = true;
return;
}
double time = Godot.Time.GetTicksUsec() / 1_000_000.0;
if(time - timestamp < checkDelay)
return;
timestamp = (float)time;
var version = ConditionStateMonitor.Version;
if (version == _lastScanVersion && !_wakeup)
return;
_wakeup = false;
_lastScanVersion = version;
var next = GetNext();
if(next == null)
return;
chain.Remove(next);
next.RunDialog();
_pendingDialog = true;
}
protected override ExecutableEvent GetNext()
{
if (Lists.IsEmpty(chain))
return null;
foreach (var additional in chain)
{
if(additional.IsOneRun && !additional.IsFirstRun)
continue;
if (additional.CheckConditions)
return additional;
}
return null;
}
}
}
+89 -70
View File
@@ -1,7 +1,6 @@
using System.Collections.Generic;
using Cthangover.Core.Events;
using Cthangover.Mods.Dialog;
using Cthangover.Core.UI.Event;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Godot;
@@ -15,35 +14,41 @@ using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.Executable
{
/// <summary>
/// The primary event chain for a scene. Registers as IOnUpdateEvent and
/// rescans registered ExecutableEvents only when the condition state changes
/// (bumped <see cref="ConditionStateMonitor.Version"/>), on dialog end, or on
/// the first scan — with a 0.5s debounce. On _Ready, auto-collects child
/// ExecutableEvent nodes and handles deferred scene switches (pending scene
/// name from SceneManager). Exposes LastBackgroundID so action commands can
/// remember which background was active for scene restore on revisit.
/// AddEvent/RemoveEvent/ClearEvents allow dynamic event management via code.
/// Events are checked in insertion order; the first satisfying both IsOneRun
/// and CheckConditions gets executed. A skip-guard prevents re-running the
/// same event at the same state version, breaking self-trigger loops while
/// preserving A→B chaining.
/// The primary event chain for a scene: picks the next ExecutableEvent to run
/// in the dialog box. Event-driven — there is no per-frame work. A scan is
/// requested when the condition state changes (<see cref="ConditionStateChangedEvent"/>),
/// when a dialog ends (<see cref="DialogEndedEvent"/>), when an event is added,
/// when the chain becomes ready and when it is re-activated. Requests are
/// coalesced into one deferred scan at the end of the frame: a burst of flag
/// changes costs one pass, the state is read after the change completed (some
/// publishers raise their event before storing the value), and a scan never
/// runs inside the dialog runtime that ended a queue.
/// <para/>
/// On _Ready the chain collects child ExecutableEvent nodes and replays a
/// pending scene switch (pending scene name from SceneManager). Exposes
/// LastBackgroundID so action commands can remember which background was
/// active for scene restore on revisit. AddEvent/RemoveEvent/ClearEvents allow
/// dynamic event management via code. Events are checked in insertion order;
/// the first satisfying both IsOneRun and CheckConditions gets executed.
/// <para/>
/// Self-loop guard: the chain remembers, per event, the condition version at
/// the moment its dialog ended, and does not run that event again until the
/// state changed after it. An event's own state changes (made during its
/// dialog) therefore never re-trigger it, A→B chaining still works, and two
/// repeatable events cannot ping-pong A→B→A while nothing changes.
/// </summary>
public partial class ExecutableMainEventChain : Control, IExecutableEventChain, IOnUpdateEvent
public partial class ExecutableMainEventChain : Control, IExecutableEventChain
{
private bool isActive = true;
private List<ExecutableEvent> allEvents = new();
private DialogBox dialogBox;
private SceneEventController eventController;
private float timestamp;
// Version of the last event scan (MinValue forces the first scan).
private long _lastScanVersion = long.MinValue;
// Version at the moment the chain's own last-run event finished its dialog.
private long _lastRunEndVersion = long.MinValue;
// The last event the chain actually started.
private ExecutableEvent _lastRunEvent;
// "Wake up and rescan" flag, set on dialog end or new event added.
private bool _wakeup;
// Condition version at the end of each event's last dialog run by this chain.
private readonly Dictionary<ExecutableEvent, long> endVersions = new();
// The event whose dialog the chain started and is waiting to end.
private ExecutableEvent running;
// A deferred scan is already queued for this frame.
private bool scanQueued;
/// <summary>
/// Stores the last background ID set by action commands (e.g. "bg_home").
@@ -52,20 +57,18 @@ namespace Cthangover.Mods.Dialog.Executable
public string LastBackgroundID { get; set; }
/// <summary>
/// Event priority for <see cref="SceneEventController"/> sorting. Value
/// <c>1</c> places this chain after high-priority handlers but before
/// general subscribers.
/// </summary>
public int Priority => 1;
/// <summary>
/// Enables or disables event polling. When <c>false</c>,
/// <see cref="OnUpdate"/> returns immediately without dispatching events.
/// Enables or disables event dispatch. While <c>false</c>, scans do nothing;
/// switching back to <c>true</c> requests a scan.
/// </summary>
public bool IsActive
{
get => isActive;
set { isActive = value; }
set
{
isActive = value;
if (value)
RequestScan();
}
}
/// <summary>
@@ -83,11 +86,14 @@ namespace Cthangover.Mods.Dialog.Executable
evt.QueueFree();
}
allEvents.Clear();
endVersions.Clear();
running = null;
}
/// <summary>
/// Adds an <see cref="ExecutableEvent"/> to the chain and reparents it via
/// <c>AddChild</c>. Events are dispatched in insertion order.
/// <c>AddChild</c>. Events are dispatched in insertion order; adding one
/// requests a scan.
/// </summary>
/// <param name="evt">The event to register and parent to this chain.</param>
public void AddEvent(IExecutableEvent evt)
@@ -97,7 +103,7 @@ namespace Cthangover.Mods.Dialog.Executable
GameLogger.Log("CHAIN", $"AddEvent: {typed.GetType().Name} [condition={id}] — calling AddChild");
AddChild(typed);
allEvents.Add(typed);
_wakeup = true;
RequestScan();
GameLogger.Log("CHAIN", $" AddEvent: done, allEvents.Count={allEvents.Count}");
}
@@ -110,21 +116,23 @@ namespace Cthangover.Mods.Dialog.Executable
{
if (allEvents.Remove(evt))
{
endVersions.Remove(evt);
if (running == evt)
running = null;
evt.QueueFree();
GameLogger.Log("CHAIN", $"removed event '{evt.GetType().Name}'");
}
}
/// <summary>Finds the shared <see cref="DialogBox"/> and event controller, collects child events, publishes the shell-ready event and replays any pending scene switch.</summary>
/// <summary>Finds the shared <see cref="DialogBox"/>, subscribes to the scan triggers, collects child events, publishes the shell-ready event, replays any pending scene switch and requests the first scan.</summary>
public override void _Ready()
{
GameLogger.Log("CHAIN", $"_Ready: starting, tree scene='{GetTree()?.CurrentScene?.Name}'");
dialogBox = SceneContextNode.FindNode<DialogBox>("DialogBox");
eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
eventController?.AddUpdateEventListener(this);
ModEventBus.Subscribe<DialogEndedEvent>(OnDialogEnded);
ModEventBus.Subscribe<ConditionStateChangedEvent>(OnConditionStateChanged);
foreach (var child in GetChildren())
{
@@ -146,56 +154,66 @@ namespace Cthangover.Mods.Dialog.Executable
Callable.From(() => sceneManager.SwitchScene(pending)).CallDeferred();
}
RequestScan();
GameLogger.Log("CHAIN", $"_Ready: done, allEvents={allEvents.Count}");
}
/// <summary>Detaches the update and dialog-end listeners from the event controller when the chain leaves the tree.</summary>
/// <summary>Detaches the scan triggers when the chain leaves the tree.</summary>
public override void _ExitTree()
{
eventController?.RemoveUpdateEventListener(this);
ModEventBus.Unsubscribe<DialogEndedEvent>(OnDialogEnded);
ModEventBus.Unsubscribe<ConditionStateChangedEvent>(OnConditionStateChanged);
}
/// <summary>
/// Called when any dialog queue finishes. Wakes the chain so the next event
/// can be picked up without waiting for a state change, and — for the chain's
/// own last-run event — records the version at its dialog end so that the
/// state bumps it produced are considered accounted for (breaks self-loops).
/// Called when any dialog queue finishes. For the chain's own running event
/// records the version at its dialog end, so the state changes it produced
/// are accounted for (self-loop guard); then requests a scan so the next
/// event starts right away.
/// </summary>
private void OnDialogEnded(DialogEndedEvent evt)
{
_wakeup = true;
if (_lastRunEvent != null && evt.LockerId == _lastRunEvent.ID)
_lastRunEndVersion = ConditionStateMonitor.Version;
if (running != null && evt.LockerId == running.ID)
{
endVersions[running] = ConditionStateMonitor.Version;
running = null;
}
RequestScan();
}
/// <summary>Debounced scan that starts the next runnable event when the condition state changed since the last completed run.</summary>
public void OnUpdate()
private void OnConditionStateChanged(ConditionStateChangedEvent evt)
{
if (!isActive)
RequestScan();
}
/// <summary>
/// Asks for a scan at the end of the frame. Any number of requests within
/// one frame result in a single scan.
/// </summary>
public void RequestScan()
{
if (scanQueued)
return;
scanQueued = true;
Callable.From(Scan).CallDeferred();
}
/// <summary>Starts the first runnable event, unless the chain is inactive or the dialog box is busy or frozen (a dialog end or a state change asks again).</summary>
private void Scan()
{
scanQueued = false;
if (!IsInstanceValid(this) || !IsInsideTree() || !isActive)
return;
var time = Godot.Time.GetTicksUsec() / 1_000_000.0;
if (time - timestamp < 0.5f)
return;
timestamp = (float)time;
if (dialogBox == null || dialogBox.IsRunning)
if (dialogBox == null || !IsInstanceValid(dialogBox) || !dialogBox.IsActive || dialogBox.IsRunning)
return;
var version = ConditionStateMonitor.Version;
if (version == _lastScanVersion && !_wakeup)
return;
_wakeup = false;
_lastScanVersion = version;
var dialog = FindEvent(allEvents, _lastRunEvent, _lastRunEndVersion);
var dialog = FindEvent();
if (dialog == null)
return;
if (dialog.RunDialog())
_lastRunEvent = dialog;
running = dialog;
}
/// <summary>
@@ -213,13 +231,14 @@ namespace Cthangover.Mods.Dialog.Executable
isActive = false;
}
private ExecutableEvent FindEvent(List<ExecutableEvent> list, ExecutableEvent skipEvent, long skipEndVersion)
private ExecutableEvent FindEvent()
{
if (Lists.IsEmpty(list))
if (Lists.IsEmpty(allEvents))
return null;
foreach (var nextEvent in list)
var version = ConditionStateMonitor.Version;
foreach (var nextEvent in allEvents)
{
if (nextEvent == skipEvent && ConditionStateMonitor.Version <= skipEndVersion)
if (endVersions.TryGetValue(nextEvent, out var endVersion) && version <= endVersion)
continue;
if (nextEvent.IsOneRun && !nextEvent.IsFirstRun)
continue;
@@ -1,27 +1,27 @@
namespace Cthangover.Mods.Dialog.Executable
{
using Cthangover.Core.UI.Executable;
/// <summary>
/// Simplest IEventObject implementation: carries an ID and self-destructs
/// via QueueFree when Destruct() is called. Used as a base for scene objects
/// that must be tracked by the event system for cleanup.
/// </summary>
public partial class IdentifiableEventObjectBehaviour : Godot.Node, IEventObject
{
/// <summary>
/// Identifier for this event object. Set externally to allow the event
/// tracking system to locate and manage this instance.
/// </summary>
public string ID { get; private set; }
/// <summary>
/// Signals cleanup by calling <c>QueueFree</c> to remove this node from
/// the scene tree and release resources.
/// </summary>
public void Destruct()
{
QueueFree();
}
}
}
namespace Cthangover.Mods.Dialog.Executable
{
using Cthangover.Core.UI.Executable;
/// <summary>
/// Simplest IEventObject implementation: carries an ID and self-destructs
/// via QueueFree when Destruct() is called. Used as a base for scene objects
/// that must be tracked by the event system for cleanup.
/// </summary>
public partial class IdentifiableEventObjectBehaviour : Godot.Node, IEventObject
{
/// <summary>
/// Identifier for this event object. Set externally to allow the event
/// tracking system to locate and manage this instance.
/// </summary>
public string ID { get; private set; }
/// <summary>
/// Signals cleanup by calling <c>QueueFree</c> to remove this node from
/// the scene tree and release resources.
/// </summary>
public void Destruct()
{
QueueFree();
}
}
}