This commit is contained in:
iisaev committed 2026-10-18 12:41:38 +03:00
commit 5de3fd7ad8
121 files changed
+6632

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
using Cthangover.Core.Actions;
using Cthangover.Core.Items;
using Cthangover.Core.Mods;
using Cthangover.Core.Quests;
using Cthangover.Core.Settings;
using Cthangover.Mods.Dialog;
using Cthangover.Core.Utils;
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;
using Cthangover.Mods.Map;
using Cthangover.Mods.Time;
namespace Cthangover.Mods.Dialog
{
/// <summary>
/// Concrete IActionContext wired to a DialogRuntime. Each service property
/// is instantiated inline (no DI container) — this is intentional: scenario
/// actions are short-lived commands, so creating new service instances per
/// context is cheap and avoids shared state. GetParam delegates to the
/// dialog runtime's variable store, bridging the dialog DSL's "set" variables
/// to the atomic action system. The Inventory reference is try-caught because
/// it may not exist at the moment of context creation (e.g. before game data
/// is fully loaded).
/// </summary>
internal class ScenarioActionContext : IActionContext
{
private readonly DialogRuntime runtime;
/// <summary>
/// Service accessor for quest operations. Resolved from the kernel
/// quest registry — the implementation (QuestServiceImpl) lives in
/// the quests_base engine mod and is published there during mod
/// load, so contexts created after boot always see a live service.
/// </summary>
public IQuestService Quests => QuestServiceRegistry.Provider;
/// <summary>
/// Service accessor for character party operations. Instantiated
/// per-context. Routes to CharacterData via strict enum parsing.
/// </summary>
public ICharacterService Character { get; } = new CharacterServiceImpl();
/// <summary>
/// Service accessor for lighting control. Instantiated per-context.
/// Thin wrapper around UiLightController singleton.
/// </summary>
public ILightingService Lighting { get; } = new LightingServiceImpl();
/// <summary>
/// Service accessor for runtime scene node manipulation.
/// Instantiated per-context. All operations route through
/// SceneContextNode.Instance as the scene root.
/// </summary>
public ISceneNodeService Scene { get; } = new SceneNodeServiceImpl();
/// <summary>
/// Singleton reference to the mod registry. Shared across all
/// contexts because mod registration is global and mod data
/// doesn't change during a single dialog execution.
/// </summary>
public IModRegistry ModRegistry { get; } = Cthangover.Core.Mods.ModRegistry.Instance;
/// <summary>
/// Player inventory reference, captured once at context
/// construction time. Try-caught because GameData may not be
/// fully loaded when the context is created (e.g. during early
/// dialog initialization before the game scene is ready).
/// Consumers must tolerate a null inventory.
/// </summary>
public IInventory Inventory { get; }
/// <summary>
/// Map zone control. Stateless adapter over the
/// <c>MapZoneService</c> singleton, which owns the actual state;
/// safe to instantiate per-context like the quest service, because the
/// state does not live in the adapter.
/// </summary>
public IMapZoneService Map { get; } = new MapZoneServiceImpl();
/// <summary>
/// Creates a context wired to the given DialogRuntime. The
/// runtime provides the variable store for GetParam — without
/// it, actions would have no way to receive parameters from the
/// scenario DSL. Inventory is captured eagerly (not lazily)
/// because it's fixed for the lifetime of this context.
/// </summary>
public ScenarioActionContext(DialogRuntime runtime)
{
this.runtime = runtime;
try { Inventory = GameData.Instance?.Runtime?.Inventory; }
catch { Inventory = null; }
}
/// <summary>
/// Reads a named variable from the dialog runtime's variable
/// store. This is the bridge between the scenario DSL's "set"
/// command and the atomic action system — variables set in the
/// script are consumed here. Returns null for undefined variables.
/// </summary>
public string GetParam(string name) => runtime.GetVariable(name);
/// <summary>
/// Emits a debug-level log entry via GameLogger. The category
/// groups related entries for filtering; use consistent
/// categories (e.g. "EVENT", "QUEST", "BATTLE") across actions.
/// </summary>
public void Log(string category, string message) => GameLogger.Log(category, message);
/// <summary>
/// Emits a warning-level log entry. Automatically prepends
/// "[WARN]" to the message and logs at LogLevel.Warning.
/// </summary>
public void LogWarning(string category, string message) => GameLogger.Log(category, $"[WARN] {message}", LogLevel.Warning);
}
}
+63
View File
@@ -0,0 +1,63 @@
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Events;
using Cthangover.Core.Mods;
using Cthangover.Core.Scenarios;
using Cthangover.Core.UI;
using Cthangover.Core.Utils;
using Cthangover.Mods.Dialog.Scenarios;
using Godot;
namespace Cthangover.Mods.Dialog
{
/// <summary>
/// Engine-mod initializer for the dialog subsystem. On resources-ready it
/// publishes the scenario event factory used by the core SceneManager and
/// parks the <c>/root/DialogRunner</c> node implementing the core
/// <c>IInteractiveDialogRunner</c> contract. All operations are idempotent:
/// <c>ModManager.Reload()</c> re-runs <c>OnModResourcesReady</c>, so node
/// creation must tolerate repeat invocations. Scenario command strategies
/// of every mod reach their factory through
/// <see cref="ScenarioCommandStrategyConsumer"/>, not through this initializer.
/// </summary>
public class DialogModInitializer : IModInitializer
{
private static bool initialized;
/// <summary>No per-mod setup is required; registration happens on resources-ready.</summary>
public void OnModLoaded(string modId) { }
/// <summary>
/// Publishes the scenario event factory and queues the dialog runner
/// node. Safe to call again after a mod reload.
/// </summary>
public void OnModResourcesReady()
{
if (initialized)
return;
initialized = true;
// Scenario-backed events (scene definitions and test-play) are built
// by the core SceneManager through this factory.
AppServices.Current.SceneEvents.ScenarioEventFactory = new ScenarioEventFactory();
EnsureDialogRunner();
}
private static void EnsureDialogRunner()
{
var root = ModEventBus.Instance?.GetTree()?.Root;
if (root == null)
{
GameLogger.Log("DIALOG", "DialogModInitializer: tree root not available, skipping runner setup", LogLevel.Error);
return;
}
if (root.GetNodeOrNull("DialogRunner") != null)
return;
// Deferred: OnModResourcesReady fires while the root is still
// setting up autoload children, so a direct AddChild is rejected.
root.CallDeferred("add_child", new DialogScriptRunner { Name = "DialogRunner" });
}
}
}
+131
View File
@@ -0,0 +1,131 @@
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Interactive;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Cthangover.Core.Utils;
using Cthangover.Mods.Dialog.Scenarios;
using Cthangover.Mods.Interface.Base;
namespace Cthangover.Mods.Dialog
{
/// <summary>
/// Dialog execution entry point of the dialog_base engine mod, parked at
/// /root/DialogRunner. Implements the core <c>IInteractiveDialogRunner</c>
/// contract: interactive click-dialogs (a scenario file combined with
/// inline DSL commands) and standalone inline DSL commands are parsed here
/// and executed through the mod-owned dialog runtime and the shared
/// <see cref="DialogBox"/>.
/// </summary>
public partial class DialogScriptRunner : ModNode, IInteractiveDialogRunner
{
/// <summary>Runs the click action of the interactive definition with the given id through the shared DialogBox. The definition is resolved through the interactive_base factory. Returns false when the definition is unknown, the box is missing, busy, or the definition carries no runnable commands.</summary>
public bool TryExecuteClickDialog(string definitionId)
{
var definition = InteractiveFactory.Instance.Get(definitionId);
if (definition == null)
{
GameLogger.Log("INTERACTIVE", $"TryExecuteClickDialog: definition '{definitionId}' not found", LogLevel.Error);
return false;
}
var dialogBox = SceneContextNode.FindNode<DialogBox>("DialogBox");
if (dialogBox == null)
{
GameLogger.Log("INTERACTIVE", "TryExecuteClickDialog: DialogBox not found", LogLevel.Error);
return false;
}
if (dialogBox.IsRunning)
{
GameLogger.Log("INTERACTIVE", "TryExecuteClickDialog: dialog already running, skipping");
return false;
}
var dlg = BuildClickDialog(definition);
if (dlg == null || dlg.Queue.Count == 0)
return false;
dialogBox.SetDialogQueueAndRun(dlg, null, 0, null);
return true;
}
/// <summary>Runs inline DSL commands (hover effects and similar) through a standalone dialog runtime.</summary>
public void ExecuteInlineCommands(string dsl)
{
if (string.IsNullOrWhiteSpace(dsl))
return;
try
{
var dlg = ScenarioParser.Parse(dsl);
if (dlg?.Queue == null || dlg.Queue.Count == 0)
return;
var runtime = new DialogRuntime();
runtime.SetDialogQueueAndRun(null, dlg.Queue, null, 0);
runtime.Run();
}
catch (System.Exception ex)
{
GameLogger.Log("INTERACTIVE", $"ExecuteInlineCommands failed: {ex.Message}", LogLevel.Error);
}
}
private DialogQueue BuildClickDialog(InteractiveDefinition def)
{
if (def?.Actions?.OnClick == null)
return null;
var clickAction = def.Actions.OnClick;
var combinedText = "";
if (!string.IsNullOrEmpty(clickAction.Scenario))
{
var modId = def.ModId;
if (string.IsNullOrEmpty(modId))
modId = ResolveModIdForScenario(clickAction.Scenario);
if (!string.IsNullOrEmpty(modId))
{
var scenarioText = ModManager.Instance.ReadFileText(modId, clickAction.Scenario);
if (!string.IsNullOrEmpty(scenarioText))
{
var (metadata, body) = ScenarioMetadata.ParseMetadata(scenarioText);
combinedText += body + "\n";
}
}
}
if (clickAction.Commands != null && clickAction.Commands.Length > 0)
{
combinedText += string.Join("\n", clickAction.Commands);
}
if (string.IsNullOrWhiteSpace(combinedText))
return null;
return ScenarioParser.Parse(combinedText);
}
private string ResolveModIdForScenario(string scenarioPath)
{
var defs = ScenarioDefs.CollectDefinitions();
var fileName = System.IO.Path.GetFileNameWithoutExtension(
scenarioPath.Replace('\\', '/').Split('/')[^1]);
foreach (var kv in defs)
{
foreach (var sd in kv.Value)
{
if (sd.Name == fileName)
return sd.ModId;
}
}
return null;
}
}
}
@@ -0,0 +1,78 @@
using System.Collections.Generic;
using Cthangover.Mods.Dialog.Scenarios;
using Cthangover.Core.Utils;
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.Scenarios
{
/// <summary>
/// Registry of <see cref="IScenarioCommandStrategy"/> implementations,
/// indexed by their <see cref="IScenarioCommandStrategy.Command"/> name.
/// It discovers nothing itself: <see cref="ScenarioCommandStrategyConsumer"/>
/// delivers the strategies of every mod as the kernel registers its
/// assembly and takes them back when the mod unloads, so the table never
/// holds a strategy of a dead assembly (which would pin it and keep running
/// its old code after a hot reload).
/// </summary>
public static class ScenarioCommandStrategyFactory
{
private static readonly Dictionary<string, IScenarioCommandStrategy> Strategies = new();
/// <summary>
/// Looks up the <see cref="IScenarioCommandStrategy"/> registered for <paramref name="command"/>.
/// Returns <c>null</c> if no strategy exists for the given command name.
/// </summary>
public static IScenarioCommandStrategy Get(string command)
{
Strategies.TryGetValue(command, out var strategy);
return strategy;
}
/// <summary>
/// Enumerates all registered DSL command names (lowercase).
/// </summary>
public static IEnumerable<string> GetAllCommandNames()
{
return Strategies.Keys;
}
/// <summary>
/// Returns the <see cref="ICommandReferenceMetadata"/> for a command if its
/// strategy implements that interface, or <c>null</c> otherwise. Used by the
/// build pipeline to collect asset dependencies from scenario scripts.
/// </summary>
public static ICommandReferenceMetadata GetReferenceMetadata(string command)
{
if (Strategies.TryGetValue(command, out var strategy) && strategy is ICommandReferenceMetadata meta)
return meta;
return null;
}
/// <summary>
/// Registers a strategy under its command name. The first strategy of a
/// command wins: a mod cannot silently take over a built-in command.
/// </summary>
public static void Register(IScenarioCommandStrategy strategy)
{
if (strategy != null && !Strategies.ContainsKey(strategy.Command))
Strategies[strategy.Command] = strategy;
}
/// <summary>
/// Removes <paramref name="strategy"/> (its mod is being unloaded). A
/// command owned by another strategy instance is left alone.
/// </summary>
public static void Unregister(IScenarioCommandStrategy strategy)
{
if (strategy != null && Strategies.TryGetValue(strategy.Command, out var registered) && registered == strategy)
Strategies.Remove(strategy.Command);
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using Cthangover.Core.UI.Executable;
using Cthangover.Mods.Dialog.Executable;
namespace Cthangover.Mods.Dialog
{
/// <summary>
/// Builds scenario-backed executable events for the core SceneManager.
/// The returned <see cref="ScenarioEvent"/> is parented to the requesting
/// event chain by <c>IExecutableEventChain.AddEvent</c>, so the factory
/// only needs to configure it.
/// </summary>
public class ScenarioEventFactory : IScenarioEventFactory
{
/// <summary>Builds a one-shot event that executes the inline scenario text (test-play path).</summary>
public IExecutableEvent CreateFromText(string scenarioText)
{
return new ScenarioEvent { ScenarioText = scenarioText };
}
/// <summary>Builds an event from a scene event definition's scenario fields.</summary>
public IExecutableEvent CreateFromDefinition(string scenarioText, string scenarioId, string condition, bool? lightUseTime, bool isOneRun)
{
return new ScenarioEvent
{
ScenarioText = scenarioText,
ScenarioId = scenarioId,
Condition = condition,
LightUseTime = lightUseTime,
IsOneRun = isOneRun,
};
}
}
}
@@ -0,0 +1,40 @@
namespace Cthangover.Mods.Dialog.Scenarios
{
/// <summary>
/// Classifies the type of resource that the first positional argument of a
/// scenario command refers to. Used for dependency tracking — the build tool
/// can scan commands by their <see cref="ICommandReferenceMetadata"/> to
/// discover which assets (backgrounds, scenes, music, etc.) are referenced
/// and need to be packed into the final build.
/// </summary>
public enum PositionalReferenceKind
{
/// <summary>No resource reference in the first positional argument.</summary>
None,
/// <summary>A background image resource path.</summary>
Background,
/// <summary>A scene resource path (for scene switching).</summary>
Scene,
/// <summary>A music/audio resource path.</summary>
Music,
/// <summary>A sound effect resource path.</summary>
Sound,
/// <summary>An effect/shader resource path.</summary>
Effect,
/// <summary>An interactive action definition ID.</summary>
Action
}
/// <summary>
/// Optional interface for command strategies whose first positional argument
/// references an external resource. Strategies implementing this interface
/// are inspected by the build pipeline to collect asset dependencies.
/// </summary>
public interface ICommandReferenceMetadata
{
/// <summary>
/// The kind of resource referenced by the first positional argument of this command.
/// </summary>
PositionalReferenceKind Positional0Kind { get; }
}
}
+57
View File
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
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.Scenarios
{
/// <summary>
/// Optional declarative contract of a scenario command, implemented by
/// <see cref="IScenarioCommandStrategy"/> classes that want their arguments
/// checked without the checker knowing the command name. The default members
/// mean a strategy overrides only what differs from "accepts anything".
///
/// The contract belongs to the command itself, so including a new command
/// from a mod (or Core) automatically teaches the validator its rules; a
/// command that does not implement this interface is still recognised as a
/// known command but its arguments are not constrained.
/// </summary>
public interface IScenarioCommandSpec
{
/// <summary>Minimum number of positional arguments. Default: 0.</summary>
int MinPositional => 0;
/// <summary>Maximum number of positional arguments; -1 means unlimited. Default: -1.</summary>
int MaxPositional => -1;
/// <summary>
/// Minimum number of arguments in total (positional + named). Used by
/// commands whose required input may arrive either way, e.g. text that
/// is either literal or a <c>key=</c> lookup.
/// </summary>
int MinArguments => 0;
/// <summary>Named arguments that must be present. Default: none.</summary>
IReadOnlyList<string> RequiredNamed => Array.Empty<string>();
/// <summary>
/// Named arguments the command understands. Empty/null means the command
/// accepts arbitrary named arguments (e.g. <c>action</c> forwards them as
/// dialog variables), so unknown-name checks are skipped.
/// </summary>
IReadOnlyList<string> AllowedNamed => null;
/// <summary>
/// Named argument that carries a <c>:label</c> jump target, or null.
/// Lets the checker validate that the target exists without knowing the
/// command; <c>goto</c> declares <c>"target"</c> here.
/// </summary>
string JumpTargetNamedKey => null;
}
}
+39
View File
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Strategy interface for handling a single DSL command type in scenario scripts.
/// Each command word (e.g. <c>text</c>, <c>music</c>, <c>goto</c>) maps to one
/// strategy implementation. The <see cref="ScenarioCommandStrategyFactory"/>
/// auto-discovers all implementations via reflection and dispatches commands
/// to the matching strategy during parsing.
/// </summary>
public interface IScenarioCommandStrategy
{
/// <summary>
/// The lowercase DSL command word this strategy handles (e.g. <c>"text"</c>, <c>"music"</c>).
/// </summary>
string Command { get; }
/// <summary>
/// Executes the command by appending one or more dialog actions to <paramref name="dlg"/>.
/// </summary>
/// <param name="dlg">The dialog queue to push actions onto.</param>
/// <param name="positional">Unnamed tokens following the command word in order.</param>
/// <param name="named">Key-value pairs parsed from <c>name=value</c> tokens (keys are lowercased).</param>
/// <param name="arrowTarget">The target label following a <c>-&gt;</c> arrow token, if present.</param>
/// <param name="locale">Optional localization provider for resolving <c>key=</c> lookups.</param>
void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale);
}
}
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>action</c> DSL command. Triggers a named interactive action
/// (defined in the action system) and forwards any extra named parameters as
/// context variables via <see cref="DialogQueue.Set"/>.
/// Implements <see cref="ICommandReferenceMetadata"/> to signal that the
/// first positional argument is an <see cref="PositionalReferenceKind.Action"/> reference.
/// </summary>
public class ActionCommandStrategy : IScenarioCommandStrategy, ICommandReferenceMetadata, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "action";
/// <inheritdoc/>
public PositionalReferenceKind Positional0Kind => PositionalReferenceKind.Action;
/// <inheritdoc/>
public int MinPositional => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
var actionName = positional.Count > 0 ? positional[0] : "";
foreach (var kv in named)
{
if (kv.Key is "name" or "key") continue;
dlg.Set(kv.Key, kv.Value);
}
dlg.Action(actionName);
}
}
}
@@ -0,0 +1,64 @@
using System.Collections.Generic;
using System.Globalization;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>animation</c> DSL command. Supports two modes:
/// (a) multiple sprites via <c>sprites=</c> named parameter, or
/// (b) a single sprite base with <c>start</c>, <c>count</c>, <c>speed</c>, and <c>loop</c> controls.
/// Delegates to <see cref="DialogQueue.Animation"/>.
/// </summary>
public class AnimationCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "animation";
/// <inheritdoc/>
public int MinArguments => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "sprites", "speed", "loop", "start", "count" };
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
if (named.TryGetValue("sprites", out var spritesStr))
{
var sprites = new List<string>(spritesStr.Split(','));
var speed = named.TryGetValue("speed", out var sp) ? ParseFloat(sp) : 1f;
var loop = !named.TryGetValue("loop", out var lp) || lp == "true";
dlg.Animation(sprites, speed, 1f, loop);
}
else if (positional.Count > 0)
{
var sprite = positional[0];
var start = named.TryGetValue("start", out var st) ? ParseInt(st) : 0;
var count = named.TryGetValue("count", out var cn) ? ParseInt(cn) : 1;
var speed = named.TryGetValue("speed", out var sp) ? ParseFloat(sp) : 1f;
var loop = !named.TryGetValue("loop", out var lp) || lp == "true";
dlg.Animation(sprite, start, count, speed, 1f, loop);
}
}
private static float ParseFloat(string s)
{
return float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0f;
}
private static int ParseInt(string s)
{
return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : 0;
}
}
}
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
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.Scenarios
{
/// <summary>
/// Handles the <c>background_color</c> DSL command. Sets the dialog scene's
/// background color from a named color string (e.g. <c>black</c>, <c>red</c>)
/// or an HTML hex color (e.g. <c>#FF0000</c>). Delegates to <see cref="DialogQueue.BackgroundColor"/>.
/// </summary>
public class BackgroundColorCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "background_color";
/// <inheritdoc/>
public int MinPositional => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
if (positional.Count > 0)
dlg.BackgroundColor(ColorFromString(positional[0]));
}
private static Color ColorFromString(string s)
{
if (s.StartsWith('#'))
return Color.FromHtml(s.Substring(1));
var named = s.ToLowerInvariant() switch
{
"black" => Colors.Black,
"white" => Colors.White,
"red" => Colors.Red,
"green" => Colors.Green,
"blue" => Colors.Blue,
"yellow" => Colors.Yellow,
"gray" or "grey" => Colors.Gray,
_ => Colors.Black,
};
return named;
}
}
}
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>background</c> DSL command. Sets the dialog scene background
/// image from a resource path. Implements <see cref="ICommandReferenceMetadata"/>
/// with <see cref="PositionalReferenceKind.Background"/> so the build pipeline
/// can track referenced background assets.
/// </summary>
public class BackgroundCommandStrategy : IScenarioCommandStrategy, ICommandReferenceMetadata, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "background";
/// <inheritdoc/>
public PositionalReferenceKind Positional0Kind => PositionalReferenceKind.Background;
/// <inheritdoc/>
public int MinPositional => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
dlg.Background(positional.Count > 0 ? positional[0] : "");
}
}
}
@@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.Globalization;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
using Cthangover.Mods.Dialog.Action.Impls;
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.Scenarios
{
/// <summary>
/// Handles the <c>background_show_hide</c> DSL command. Animates the background
/// in (<c>show</c>) or out (<c>hide</c>) with configurable <c>duration</c> and
/// <c>wait</c> flag. Delegates to <see cref="DialogQueue.BackgroundShowHide"/>.
/// </summary>
public class BackgroundShowHideCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "background_show_hide";
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "duration", "wait" };
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
var typeStr = positional.Count > 0 ? positional[0] : "show";
var type = typeStr.ToLowerInvariant() == "show"
? BackgroundActionType.Show
: BackgroundActionType.Hide;
var duration = named.TryGetValue("duration", out var dur) ? ParseFloat(dur) : 1f;
var wait = named.TryGetValue("wait", out var w) && w == "true";
dlg.BackgroundShowHide(type, duration, wait);
}
private static float ParseFloat(string s)
{
return float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0f;
}
}
}
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using System.Globalization;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>delay</c> DSL command. Pauses dialog execution for a
/// number of seconds, optionally displaying a text label during the pause.
/// If the <c>hidden</c> flag is set, the delay runs without visible text.
/// Supports localization via the <c>key=</c> named parameter.
/// </summary>
public class DelayCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "delay";
/// <inheritdoc/>
public int MaxPositional => 2;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "key", "hidden" };
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
var time = positional.Count > 0 ? ParseFloat(positional[0]) : 1f;
var hidden = positional.Count > 1 && positional[1] == "hidden"
|| named.TryGetValue("hidden", out var h) && h == "true";
var showText = hidden ? null
: positional.Count > 1 ? positional[1]
: null;
if (named.TryGetValue("key", out var key) && locale != null && showText != null)
{
var loc = locale.Get(key);
if (loc != null) showText = loc;
}
if (showText != null)
dlg.Delay(time, showText);
else
dlg.Delay(time, hidden);
}
private static float ParseFloat(string s)
{
return float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0f;
}
}
}
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>effect</c> DSL command. Applies a named visual effect/shader
/// to the scene. Implements <see cref="ICommandReferenceMetadata"/> with
/// <see cref="PositionalReferenceKind.Effect"/> for build-time dependency tracking.
/// </summary>
public class EffectCommandStrategy : IScenarioCommandStrategy, ICommandReferenceMetadata, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "effect";
/// <inheritdoc/>
public PositionalReferenceKind Positional0Kind => PositionalReferenceKind.Effect;
/// <inheritdoc/>
public int MinPositional => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
if (positional.Count > 0)
dlg.Effect(positional[0]);
}
}
}
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>empty</c> DSL command. Queues a no-op placeholder action
/// (<see cref="ActionEmpty"/>) that is skipped instantly — the DSL-level label
/// mechanism (<c>:label</c> via <see cref="DialogQueue.Point"/>) is built on the
/// same action type, and a bare <c>empty</c> line works as a placeholder marker.
/// </summary>
public class EmptyCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "empty";
/// <inheritdoc/>
public int MaxPositional => 0;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
dlg.Empty();
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>end</c> DSL command. Marks the end of the dialog sequence,
/// terminating execution of the current scenario. Typically used at the final
/// line of a scenario script to signal the dialog engine to close or transition.
/// </summary>
public class EndCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "end";
/// <inheritdoc/>
public int MaxPositional => 0;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
dlg.End();
}
}
}
@@ -0,0 +1,35 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>foreground</c> DSL command. Places a foreground overlay
/// image on top of the scene (above background and characters). The first
/// positional argument specifies the resource path to the image asset.
/// </summary>
public class ForegroundCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "foreground";
/// <inheritdoc/>
public int MinPositional => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
dlg.Foreground(positional.Count > 0 ? positional[0] : "");
}
}
}
@@ -0,0 +1,43 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>goto</c> DSL command. Jumps execution to a labeled anchor
/// point (<c>:labelname</c>) in the scenario script. Supports two forms:
/// <c>goto target_name</c> via <c>target=</c> named parameter, or
/// <c>goto -&gt; target_name</c> via the arrow target syntax.
/// </summary>
public class GotoCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "goto";
/// <inheritdoc/>
public int MinArguments => 1;
/// <inheritdoc/>
public int MaxPositional => 0;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "target" };
/// <inheritdoc/>
public string JumpTargetNamedKey => "target";
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
if (!string.IsNullOrEmpty(arrowTarget))
dlg.GoTo(arrowTarget);
else if (named.TryGetValue("target", out var target))
dlg.GoTo(target);
}
}
}
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>hide_dialog</c> DSL command. Hides the dialog UI panel
/// (text box, avatars, and UI chrome) from the screen. The inverse of
/// <see cref="ShowDialogCommandStrategy"/>.
/// </summary>
public class HideDialogCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "hide_dialog";
/// <inheritdoc/>
public int MaxPositional => 0;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
dlg.HideDialog();
}
}
}
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Scenario DSL command <c>interactive_add</c>: instantiates an interactive
/// object from its definition ID. Optional <c>layer</c> parameter overrides
/// the layer from the definition.
///
/// Usage: <c>interactive_add door_to_kitchen</c>
/// <c>interactive_add door_to_kitchen layer=foreground</c>
/// </summary>
public class InteractiveAddCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <summary>The lowercase DSL keyword handled by this strategy: <c>interactive_add</c>.</summary>
public string Command => "interactive_add";
/// <inheritdoc/>
public int MinPositional => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "layer" };
/// <summary>Queues an <see cref="ActionInteractiveAdd"/> for the definition id in <c>positional[0]</c>; lines without an id are skipped.</summary>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
if (positional.Count == 0)
return;
var id = positional[0];
dlg.InteractiveAdd(id);
}
}
}
@@ -0,0 +1,35 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Scenario DSL command <c>interactive_clear</c>: removes all active
/// interactive objects from the scene.
///
/// Usage: <c>interactive_clear</c>
/// </summary>
public class InteractiveClearCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <summary>The lowercase DSL keyword handled by this strategy: <c>interactive_clear</c>.</summary>
public string Command => "interactive_clear";
/// <inheritdoc/>
public int MaxPositional => 0;
/// <summary>Queues an <see cref="ActionInteractiveClear"/> that removes every active interactive object from the scene.</summary>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
dlg.InteractiveClear();
}
}
}
@@ -0,0 +1,42 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Scenario DSL command <c>interactive_remove</c>: removes and destroys
/// an interactive object by its definition ID.
///
/// Usage: <c>interactive_remove door_to_kitchen</c>
/// </summary>
public class InteractiveRemoveCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <summary>The lowercase DSL keyword handled by this strategy: <c>interactive_remove</c>.</summary>
public string Command => "interactive_remove";
/// <inheritdoc/>
public int MinPositional => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <summary>Queues an <see cref="ActionInteractiveRemove"/> for the definition id in <c>positional[0]</c>; lines without an id are skipped.</summary>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
if (positional.Count == 0)
return;
var id = positional[0];
dlg.InteractiveRemove(id);
}
}
}
@@ -0,0 +1,50 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Scenario DSL command <c>interactive_set</c>: modifies a property of an
/// active interactive object. Supported properties: <c>enabled</c>, <c>visible</c>.
///
/// Usage: <c>interactive_set door_to_kitchen enabled=false</c>
/// <c>interactive_set door_to_kitchen visible=true</c>
/// </summary>
public class InteractiveSetCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <summary>The lowercase DSL keyword handled by this strategy: <c>interactive_set</c>.</summary>
public string Command => "interactive_set";
/// <inheritdoc/>
public int MinPositional => 1;
/// <inheritdoc/>
public int MinArguments => 2;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "enabled", "visible" };
/// <summary>Queues an <see cref="ActionInteractiveSet"/> for <c>positional[0]</c>; <c>enabled=</c> wins over <c>visible=</c> when both are given, and lines without both an id and a property are skipped.</summary>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
if (positional.Count < 2)
return;
var id = positional[0];
if (named.TryGetValue("enabled", out var enabled))
dlg.InteractiveSet(id, "enabled", enabled);
else if (named.TryGetValue("visible", out var visible))
dlg.InteractiveSet(id, "visible", visible);
}
}
}
@@ -0,0 +1,37 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios.Impls
{
/// <summary>
/// Handles the <c>light_set</c> DSL command. Passes a JSON string that
/// describes lighting configuration to the dialog engine via
/// <see cref="DialogQueue.LightSet"/>. The JSON structure is forwarded
/// as-is to the rendering layer for point-light setup.
/// </summary>
public class LightSetCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "light_set";
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional,
Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
var json = positional.Count > 0 ? positional[0] : null;
dlg.LightSet(json);
}
}
}
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>light_use_time</c> DSL command. Toggles whether the
/// scene lighting should be driven by the simulated time-of-day system.
/// Passes a boolean flag to <see cref="DialogQueue.LightUseTime"/>.
/// </summary>
public class LightUseTimeCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "light_use_time";
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
var val = positional.Count > 0 ? positional[0] : "true";
dlg.LightUseTime(val == "true");
}
}
}
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>music</c> DSL command. Starts playing a background music
/// track from the given resource path. Implements <see cref="ICommandReferenceMetadata"/>
/// with <see cref="PositionalReferenceKind.Music"/> so the build pipeline
/// can track referenced audio assets.
/// </summary>
public class MusicCommandStrategy : IScenarioCommandStrategy, ICommandReferenceMetadata, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "music";
/// <inheritdoc/>
public PositionalReferenceKind Positional0Kind => PositionalReferenceKind.Music;
/// <inheritdoc/>
public int MinPositional => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
if (positional.Count > 0)
dlg.Music(positional[0]);
}
}
}
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>music_pause</c> DSL command. Pauses the currently
/// playing background music stream. Playback can be resumed later with
/// <see cref="MusicPlayCommandStrategy"/>.
/// </summary>
public class MusicPauseCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "music_pause";
/// <inheritdoc/>
public int MaxPositional => 0;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
dlg.MusicPause();
}
}
}
@@ -0,0 +1,32 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>music_play</c> DSL command. Resumes the currently
/// paused background music stream. Has no effect if music is already playing.
/// </summary>
public class MusicPlayCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "music_play";
/// <inheritdoc/>
public int MaxPositional => 0;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
dlg.MusicPlay();
}
}
}
@@ -0,0 +1,64 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
using Cthangover.Core.UI.Messages;
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.Scenarios
{
/// <summary>
/// Handles the <c>notify</c> DSL command. Queues a notification text
/// into <see cref="NotificationBuffer"/> for display in the top-left
/// notification panel. This is a fire-and-forget command — it does not
/// block dialog execution.
/// </summary>
public class NotifyCommandStrategy : IScenarioCommandStrategy
{
/// <summary>
/// The lowercase DSL command word.
/// </summary>
public string Command => "notify";
/// <inheritdoc/>
public int MinArguments => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "key" };
/// <summary>
/// Extracts the notification text from the first positional argument
/// and enqueues it into <see cref="NotificationBuffer"/>. Supports
/// localization via the <c>key=</c> named parameter.
/// </summary>
/// <param name="dlg">The dialog queue (unused — notifications bypass dialog).</param>
/// <param name="positional">Positional arguments; <c>positional[0]</c> is the notification text.</param>
/// <param name="named">Named arguments; <c>"key"</c> is a locale lookup key.</param>
/// <param name="arrowTarget">Unused by this command.</param>
/// <param name="locale">Optional localization provider for <c>key=</c> resolution.</param>
public void Execute(DialogQueue dlg, List<string> positional,
Dictionary<string, string> named, string arrowTarget,
ILocalizationProvider locale)
{
var text = positional.Count > 0 ? positional[0] : null;
if (named.TryGetValue("key", out var key) && locale != null)
{
var loc = locale.Get(key);
if (loc != null) text = loc;
}
if (text != null)
NotificationBuffer.Add(text);
}
}
}
@@ -0,0 +1,54 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
using Cthangover.Mods.Dialog.Action;
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.Scenarios
{
/// <summary>
/// Handles the <c>ptext</c> DSL command. A "plain text" variant of
/// <see cref="TextCommandStrategy"/> that skips speaker attribution
/// but still supports <c>first</c>/<c>second</c> avatar faces and
/// <c>hide_color</c> and <c>wait</c> behavior flags. Text content
/// can be provided directly or resolved via a <c>key=</c> localization key.
/// </summary>
public class PTextCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "ptext";
/// <inheritdoc/>
public int MinArguments => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait" };
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
var textVal = positional.Count > 0 ? positional[0] : "";
if (named.TryGetValue("key", out var key) && locale != null)
{
var loc = locale.Get(key);
if (loc != null) textVal = loc;
}
var first = named.TryGetValue("first", out var f) ? f : null;
var second = named.TryGetValue("second", out var s) ? s : null;
var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true";
var action = dlg.PText(textVal, first, second, hideColor);
if (named.TryGetValue("wait", out var wait) && wait == "false")
action.WaitType = WaitType.NoWait;
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>set</c> DSL command. Stores an arbitrary key-value pair
/// into the dialog session's variable state via <see cref="DialogQueue.Set"/>.
/// Values are read back later through <c>${var}</c> substitution in
/// <c>ptext</c> lines and via <see cref="Cthangover.Core.Actions.IActionContext.GetParam"/>
/// in scenario actions.
/// </summary>
public class SetCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "set";
/// <inheritdoc/>
public int MinPositional => 2;
/// <inheritdoc/>
public int MaxPositional => 2;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
var name = positional.Count > 0 ? positional[0] : "";
var value = positional.Count > 1 ? positional[1] : "";
dlg.Set(name, value);
}
}
}
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>show_dialog</c> DSL command. Makes the dialog UI panel
/// (text box, avatars, and UI chrome) visible on screen. The inverse of
/// <see cref="HideDialogCommandStrategy"/>.
/// </summary>
public class ShowDialogCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "show_dialog";
/// <inheritdoc/>
public int MaxPositional => 0;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
dlg.ShowDialog();
}
}
}
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>sound</c> DSL command. Plays a one-shot sound effect
/// from the given resource path. Implements <see cref="ICommandReferenceMetadata"/>
/// with <see cref="PositionalReferenceKind.Sound"/> so the build pipeline
/// can track referenced audio assets.
/// </summary>
public class SoundCommandStrategy : IScenarioCommandStrategy, ICommandReferenceMetadata, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "sound";
/// <inheritdoc/>
public PositionalReferenceKind Positional0Kind => PositionalReferenceKind.Sound;
/// <inheritdoc/>
public int MinPositional => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
if (positional.Count > 0)
dlg.Sound(positional[0]);
}
}
}
@@ -0,0 +1,50 @@
using System.Collections.Generic;
using System.Globalization;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>switch_scene</c> DSL command. Transitions to a new Godot
/// scene (PackedScene) with configurable transition <c>speed</c> and an
/// optional <c>hidden</c> mode that shows only the scene background during
/// the transition. Implements <see cref="ICommandReferenceMetadata"/> with
/// <see cref="PositionalReferenceKind.Scene"/> for build-time dependency tracking.
/// </summary>
public class SwitchSceneCommandStrategy : IScenarioCommandStrategy, ICommandReferenceMetadata, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "switch_scene";
/// <inheritdoc/>
public PositionalReferenceKind Positional0Kind => PositionalReferenceKind.Scene;
/// <inheritdoc/>
public int MinPositional => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "speed", "hidden" };
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
var scene = positional.Count > 0 ? positional[0] : "";
var speed = named.TryGetValue("speed", out var sp) ? ParseFloat(sp) : 4f;
var hidden = named.TryGetValue("hidden", out var h) && h == "true";
dlg.SwitchScene(scene, speed, hidden);
}
private static float ParseFloat(string s)
{
return float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0f;
}
}
}
@@ -0,0 +1,54 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
using Cthangover.Mods.Dialog.Action;
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.Scenarios
{
/// <summary>
/// Handles the <c>text</c> DSL command, the primary dialog display instruction.
/// Shows character text with optional <c>first</c>/<c>second</c> speaker
/// avatar faces, <c>hide_color</c> tint suppression, and a <c>wait</c> flag
/// that controls whether the dialog waits for user input before continuing.
/// Text can be literal or resolved from a <c>key=</c> localization entry.
/// </summary>
public class TextCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "text";
/// <inheritdoc/>
public int MinArguments => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait" };
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
var textVal = positional.Count > 0 ? positional[0] : "";
if (named.TryGetValue("key", out var key) && locale != null)
{
var loc = locale.Get(key);
if (loc != null) textVal = loc;
}
var first = named.TryGetValue("first", out var f) ? f : null;
var second = named.TryGetValue("second", out var s) ? s : null;
var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true";
var action = dlg.Text(textVal, first, second, hideColor);
if (named.TryGetValue("wait", out var wait) && wait == "false")
action.WaitType = WaitType.NoWait;
}
}
}
@@ -0,0 +1,45 @@
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
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.Scenarios
{
/// <summary>
/// Handles the <c>title</c> DSL command. Displays a centered title/caption
/// text on screen, typically used for chapter headings or location labels.
/// Text can be literal or resolved from a <c>key=</c> localization entry.
/// Silently does nothing if no text value is provided or resolved.
/// </summary>
public class TitleCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
{
/// <inheritdoc/>
public string Command => "title";
/// <inheritdoc/>
public int MinArguments => 1;
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "key" };
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
{
var textVal = positional.Count > 0 ? positional[0] : null;
if (named.TryGetValue("key", out var key) && locale != null)
{
var loc = locale.Get(key);
if (loc != null) textVal = loc;
}
if (textVal != null)
dlg.Title(textVal);
}
}
}
@@ -0,0 +1,23 @@
using Cthangover.Core.Mods.Consumers;
namespace Cthangover.Mods.Dialog.Scenarios
{
/// <summary>
/// Feeds <see cref="ScenarioCommandStrategyFactory"/> with every mod's
/// <see cref="IScenarioCommandStrategy"/> implementations as the mods load
/// (dialog_base's own built-in commands included, through the join-time
/// catch-up) and removes them when their mod unloads, so a hot-reloaded
/// mod's commands run its fresh code and the dying assembly is not pinned.
/// </summary>
public class ScenarioCommandStrategyConsumer : ModTypeConsumer<IScenarioCommandStrategy>
{
/// <inheritdoc />
public override string Id => "dialog.command_strategies";
/// <inheritdoc />
protected override void Add(IScenarioCommandStrategy instance, string modId) => ScenarioCommandStrategyFactory.Register(instance);
/// <inheritdoc />
protected override void Remove(IScenarioCommandStrategy instance, string modId) => ScenarioCommandStrategyFactory.Unregister(instance);
}
}
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Core.Mods;
using Cthangover.Mods.Dialog;
using Cthangover.Mods.Dialog.Action;
using Cthangover.Mods.Dialog.Action.Impls;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
using Cthangover.Core.UI.Event;
using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.Scenarios
{
/// <summary>
/// Converts scenario DSL text or files into <see cref="DialogQueue"/> instances.
/// Wraps <see cref="ScenarioParser"/> with a configurable <see cref="ILocalizationProvider"/>
/// that defaults to <see cref="GodotTranslationProvider.Instance"/> when none is supplied.
/// </summary>
public class ScenarioConverter
{
private readonly ILocalizationProvider locale;
/// <summary>
/// Creates a converter with an optional localization provider.
/// </summary>
/// <param name="locale">Localization provider for <c>key=</c> text lookups. Defaults to Godot translations.</param>
public ScenarioConverter(ILocalizationProvider locale = null)
{
this.locale = locale ?? GodotTranslationProvider.Instance;
}
/// <summary>
/// Parses raw scenario script text into a dialog queue using the configured locale.
/// </summary>
public DialogQueue Convert(string text)
{
return ScenarioParser.Parse(text, locale);
}
/// <summary>
/// Reads a scenario file from disk and parses it into a dialog queue.
/// </summary>
/// <param name="path">Filesystem path to the <c>.scenario</c> file.</param>
/// <returns>The parsed <see cref="DialogQueue"/>, or <c>null</c> if the file could not be opened.</returns>
public DialogQueue ConvertFile(string path)
{
foreach (var kvp in ModRegistry.Instance.Mods)
{
var text = kvp.Value.FileProvider.ReadFileText(path);
if (text != null)
return Convert(text);
}
var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
if (file == null)
{
GameLogger.Log("CONVERTER", $"Failed to open '{path}'", LogLevel.Error);
return null;
}
var fileText = file.GetAsText();
file.Close();
return Convert(fileText);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
using Cthangover.Core.Localization;
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.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.Scenarios
{
/// <summary>
/// Loads a <c>.scenario</c> file from disk, reads its text content, and
/// parses it via <see cref="ScenarioParser.Parse"/> into a <see cref="DialogQueue"/>.
/// Uses <see cref="GodotTranslationProvider.Instance"/> as the default localization source.
/// </summary>
public static class ScenarioLoader
{
/// <summary>
/// Loads and parses a scenario file using the default Godot translation provider.
/// </summary>
/// <param name="path">Filesystem path to the <c>.scenario</c> file.</param>
/// <returns>The parsed <see cref="DialogQueue"/>, or <c>null</c> if the file could not be opened.</returns>
public static DialogQueue Load(string path)
{
return Load(path, GodotTranslationProvider.Instance);
}
/// <summary>
/// Loads and parses a scenario file with a custom localization provider for text lookups.
/// </summary>
/// <param name="path">Filesystem path to the <c>.scenario</c> file.</param>
/// <param name="provider">Localization provider to resolve <c>key=</c> references during parsing.</param>
/// <returns>The parsed <see cref="DialogQueue"/>, or <c>null</c> if the file could not be opened.</returns>
public static DialogQueue Load(string path, ILocalizationProvider provider)
{
var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
if (file == null)
{
GameLogger.Log("SCENE", $"ScenarioLoader: failed to open '{path}'", LogLevel.Error);
return null;
}
var text = file.GetAsText();
file.Close();
return ScenarioParser.Parse(text, provider);
}
}
}
+267
View File
@@ -0,0 +1,267 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Localization;
using Cthangover.Mods.Dialog;
using Cthangover.Mods.Dialog.Action.Impls;
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.Scenarios
{
/// <summary>
/// Parses scenario DSL script text into a <see cref="DialogQueue"/> of dialog actions.
/// The pipeline works in two phases: first the text is split into lines, then each
/// non-empty, non-comment line is tokenized and dispatched to the matching
/// <see cref="IScenarioCommandStrategy"/> via <see cref="ScenarioCommandStrategyFactory"/>.
/// Special handling exists for <c>:label</c> anchor points, <c>select/option</c>
/// blocks (accumulated into <see cref="ActionSelect"/>), and <c>-&gt;</c> arrow targets.
/// </summary>
public static class ScenarioParser
{
/// <summary>
/// Parses a complete scenario script text and returns the resulting dialog queue.
/// </summary>
/// <param name="text">Raw scenario DSL content.</param>
/// <param name="locale">Optional localization provider for <c>key=</c> text lookups.</param>
public static DialogQueue Parse(string text, ILocalizationProvider locale = null)
{
var dlg = new DialogQueue();
var lines = text.Split('\n');
ActionSelect pendingSelect = null;
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith('#'))
{
FinalizePendingSelect(dlg, ref pendingSelect);
continue;
}
if (line.StartsWith(':'))
{
FinalizePendingSelect(dlg, ref pendingSelect);
dlg.Point(line.Substring(1));
continue;
}
var tokens = Tokenize(line);
if (tokens.Count == 0) continue;
var cmd = tokens[0].ToLowerInvariant();
tokens.RemoveAt(0);
var positional = new List<string>();
var named = new Dictionary<string, string>();
var arrowTarget = "";
for (var i = 0; i < tokens.Count; i++)
{
var t = tokens[i];
if (t == "->")
{
if (i + 1 < tokens.Count)
{
arrowTarget = tokens[i + 1].TrimStart(':');
i++;
}
continue;
}
var eqIdx = t.IndexOf('=');
if (eqIdx > 0)
{
var name = t.Substring(0, eqIdx);
if (IsValidIdent(name))
{
named[name.ToLowerInvariant()] = t.Substring(eqIdx + 1);
continue;
}
}
positional.Add(t);
}
if (cmd == "select")
{
FinalizePendingSelect(dlg, ref pendingSelect);
pendingSelect = new ActionSelect();
var promptText = positional.Count > 0 ? positional[0] : null;
if (named.TryGetValue("key", out var key) && locale != null)
{
var loc = locale.Get(key);
if (loc != null) promptText = loc;
}
if (promptText != null)
pendingSelect.Text = promptText;
if (named.TryGetValue("first", out var first))
pendingSelect.FirstAvatar = first;
if (named.TryGetValue("second", out var second))
pendingSelect.SecondAvatar = second;
continue;
}
if (cmd == "option")
{
if (pendingSelect == null) continue;
var optText = positional.Count > 0 ? positional[0] : "";
if (named.TryGetValue("key", out var key) && locale != null)
{
var loc = locale.Get(key);
if (loc != null) optText = loc;
}
var goTo = arrowTarget;
if (string.IsNullOrEmpty(goTo))
{
if (named.TryGetValue("goto", out var gt))
goTo = gt;
else if (positional.Count > 1 && positional[1].StartsWith(':'))
goTo = positional[1].TrimStart(':');
}
if (pendingSelect.Variants == null)
pendingSelect.Variants = new List<SelectVariant>();
pendingSelect.Variants.Add(SelectVariant.New(optText, goTo));
continue;
}
FinalizePendingSelect(dlg, ref pendingSelect);
var strategy = ScenarioCommandStrategyFactory.Get(cmd);
if (strategy != null)
strategy.Execute(dlg, positional, named, arrowTarget, locale);
}
FinalizePendingSelect(dlg, ref pendingSelect);
return dlg;
}
/// <summary>
/// Extracts YAML-like front-matter metadata from script text. Lines before
/// a <c>---</c> separator or the first non-key:value line are parsed as
/// <c>key: value</c> pairs. Returns both the metadata dictionary and the
/// remaining script content after the metadata block.
/// </summary>
public static (Dictionary<string, string> metadata, string remainingText) ParseMetadata(string text)
{
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var lines = text.Split('\n');
var contentStart = 0;
var inMetadata = true;
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i].Trim();
if (!inMetadata)
{
continue;
}
if (line == "---")
{
contentStart = i + 1;
break;
}
if (string.IsNullOrEmpty(line))
{
contentStart = i;
break;
}
var colonIdx = line.IndexOf(':');
if (colonIdx > 0)
{
var key = line.Substring(0, colonIdx).Trim();
var val = line.Substring(colonIdx + 1).Trim();
metadata[key] = val;
}
else
{
contentStart = i;
break;
}
}
var remaining = string.Join("\n", lines, contentStart, lines.Length - contentStart);
return (metadata, remaining);
}
private static void FinalizePendingSelect(DialogQueue dlg, ref ActionSelect pending)
{
if (pending == null) return;
if (pending.Variants == null || pending.Variants.Count == 0)
{
pending = null;
return;
}
dlg.Queue.Add(pending);
pending = null;
}
private static List<string> Tokenize(string line)
{
var result = new List<string>();
var i = 0;
while (i < line.Length)
{
if (char.IsWhiteSpace(line[i]))
{
i++;
continue;
}
if (line[i] == '"')
{
i++;
var start = i;
while (i < line.Length && line[i] != '"')
{
if (line[i] == '\\' && i + 1 < line.Length)
i++;
i++;
}
result.Add(line[start..i].Replace("\\\"", "\""));
if (i < line.Length) i++;
continue;
}
var tokenStart = i;
while (i < line.Length && !char.IsWhiteSpace(line[i]))
i++;
result.Add(line[tokenStart..i]);
}
return result;
}
private static bool IsValidIdent(string s)
{
if (string.IsNullOrEmpty(s)) return false;
for (var i = 0; i < s.Length; i++)
{
var c = s[i];
if (!char.IsLetterOrDigit(c) && c != '_')
return false;
}
return true;
}
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace Cthangover.Mods.Dialog
{
/// <summary>
/// Migration marker for the coverage pipeline
/// (<c>SubsystemsBridge</c> reflection scan): declares the dialog
/// subsystem fully moved out of the core assembly into this engine mod.
/// The id must match the <c>DeclaredTests</c> entry in
/// <c>SubsystemsCoverageTestSetup</c>.
/// </summary>
public static class SubsystemRegistration
{
public static string SubsystemId => "dialog";
}
}
+103
View File
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using Godot;
using Cthangover.Core.Actions;
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.Action
{
/// <summary>
/// Base for all dialog actions with a lifecycle of Construct → Run → Destruct.
/// Each action gets an auto-generated GUID for targeting by GoTo/If commands.
/// The three timing enums control when Construct and Destruct fire relative to
/// the action's lifecycle and queue life, enabling both immediate teardown
/// (OnEndAction) and deferred cleanup (OnEndQueue). WaitType determines whether
/// the runtime pauses here (WaitClick/Select/Time/Event) or advances immediately
/// (NoWait). DelayedDestruct is a set of action IDs whose Destruct should be
/// called after this action's own destruct — used for cross-action cleanup
/// dependencies where one action's visual state must outlast another.
/// </summary>
public abstract class ActionCommand : IActionCommand
{
/// <summary>Unique identifier for this action. Auto-generated GUID by default; can be overridden via <see cref="SetID"/> for GoTo targeting.</summary>
public string ID { get; set; }
/// <summary>Whether <see cref="Construct"/> has been called and <see cref="DoConstruct"/> has completed.</summary>
public bool IsConstructed { get; private set; }
/// <summary>Whether <see cref="Destruct"/> has been called and this action is finished.</summary>
public bool IsDestructed { get; private set; }
/// <summary>When to call <see cref="Construct"/>: OnStartQueue (preload) or OnStartAction (lazy, just before run). Default is OnStartAction.</summary>
public virtual ConstructType ConstructType { get; set; } = ConstructType.OnStartAction;
/// <summary>When to call <see cref="Destruct"/>: OnEndAction (immediate), OnDelayed (manual cleanup), or OnEndQueue (when dialog finishes). Default is OnEndAction.</summary>
public virtual DestructType DestructType { get; set; } = DestructType.OnEndAction;
/// <summary>Controls how the runtime advances past this action. Default is WaitClick (pause for user input).</summary>
public virtual WaitType WaitType { get; set; } = WaitType.WaitClick;
/// <summary>Duration in seconds for WaitTime. Used only when <see cref="WaitType"/> is WaitTime.</summary>
public virtual float WaitTime { get; set; }
/// <summary>Timestamp when <see cref="Run"/> was called. Used by WaitTime actions to measure elapsed time.</summary>
public float StartTime { get; private set; }
/// <summary>Action IDs whose <see cref="Destruct"/> will be triggered when this action destructs. Used for cross-action cleanup dependencies.</summary>
public ISet<string> DelayedDestruct { get; } = new HashSet<string>();
/// <summary>
/// Scene node lookup seam for subclasses. Replaces direct
/// SceneContextNode.FindNode calls so action behavior can be exercised
/// against a fake lookup in unit tests instead of the global scene-tree
/// singleton. Defaults to the runtime implementation
/// (<see cref="SceneNodeServiceImpl"/>) and is instantiated per action
/// instance — the same no-container style as ScenarioActionContext.
/// </summary>
protected ISceneNodeService Scene { get; set; } = new SceneNodeServiceImpl();
/// <summary>Generates a unique ID so every action is addressable by GoTo/If even without an explicit <see cref="SetID"/> call.</summary>
public ActionCommand()
{
ID = Guid.NewGuid().ToString();
}
/// <summary>Idempotent construction: calls <see cref="DoConstruct"/> once and sets <see cref="IsConstructed"/> to true.</summary>
public void Construct()
{
if(IsConstructed) return;
DoConstruct();
IsConstructed = true;
}
/// <summary>Idempotent desctruction: calls <see cref="DoDestruct"/> once, then sets <see cref="IsConstructed"/> false and <see cref="IsDestructed"/> true.</summary>
public void Destruct()
{
if(IsDestructed) return;
DoDestruct();
IsConstructed = false;
IsDestructed = true;
}
/// <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();
StartTime = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0);
DoRun(runtime);
}
/// <summary>Override in subclasses to implement action-specific behavior. Receives the runtime for text processing and dialog box access.</summary>
public abstract void DoRun(DialogRuntime runtime);
/// <summary>Override to preload resources or register listeners. Called by <see cref="Construct"/>.</summary>
public virtual void DoConstruct() { }
/// <summary>Override to release resources or disconnect listeners. Called by <see cref="Destruct"/>.</summary>
public virtual void DoDestruct() { }
/// <summary>Fluent setter for <see cref="ID"/>. Use to assign a named target for GoTo/If commands.</summary>
public ActionCommand SetID(string id)
{
ID = id;
return this;
}
}
}
+14
View File
@@ -0,0 +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
}
}
+17
View File
@@ -0,0 +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
}
}
+20
View File
@@ -0,0 +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,
}
}
+56
View File
@@ -0,0 +1,56 @@
using System.Collections.Generic;
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.Action
{
/// <summary>
/// Full dialog action contract: extends IActionDestruct with construction,
/// execution (Run), and queryable state (WaitType, timing enums, delayed-destruct
/// dependencies). The ID property enables cross-action references (GoTo, If).
/// </summary>
public interface IActionCommand : IActionDestruct
{
#region Properties
/// <summary>Unique action identifier, auto-generated GUID. Can be overridden for named GoTo targets.</summary>
string ID { get; set; }
/// <summary>Whether <see cref="Construct"/> has completed.</summary>
bool IsConstructed { get; }
/// <summary>Whether <see cref="Destruct"/> has been called and the action is finished.</summary>
bool IsDestructed { get; }
/// <summary>Controls when construction fires relative to the action's lifecycle.</summary>
ConstructType ConstructType { get; }
/// <summary>Controls when destruction fires relative to the action's lifecycle.</summary>
DestructType DestructType { get; }
/// <summary>How the runtime should handle advancement past this action.</summary>
WaitType WaitType { get; }
/// <summary>IDs of actions whose Destruct should be called when this action destructs.</summary>
ISet<string> DelayedDestruct { get; }
/// <summary>Duration in seconds when WaitType is WaitTime.</summary>
float WaitTime { get; }
/// <summary>Timestamp of the last <see cref="Run"/> call, in seconds since epoch.</summary>
float StartTime { get; }
#endregion
#region Methods
/// <summary>Called by the runtime to initialize the action before execution.</summary>
void Construct();
/// <summary>Called by the runtime to execute the action. Receives the runtime for context.</summary>
void Run(DialogRuntime runtime);
#endregion
}
}
+13
View File
@@ -0,0 +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();
}
}
@@ -0,0 +1,120 @@
using System.Collections.Generic;
using Cthangover.Mods.Effects.Animation;
using Cthangover.Mods.Dialog.Action;
using Cthangover.Core.UI.Event;
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.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Interactive;
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.
/// </summary>
public class ActionAnimatedForeground : ActionCommand, IOnUpdateEvent
{
/// <summary>Update priority. Runs after higher-priority listeners. Value 0.</summary>
public int Priority => 0;
/// <summary>List of sprite resource paths to display as animation frames. Loaded in <see cref="DoConstruct"/>.</summary>
public List<string> SpriteIDs { get; set; }
/// <summary>Initial frame rate multiplier for 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>
public float NextFrameSpeed { get; set; } = 1f;
/// <summary>Whether the animation wraps after the last frame. When false, playback stops at the final frame.</summary>
public bool IsLoop { get; set; } = true;
private int currentFrame;
private float elapsed;
private bool isAnimating;
private List<Texture2D> frames;
/// <summary>Preloads frames when the dialog queue is first loaded, so textures are ready before playback.</summary>
public override ConstructType ConstructType { get; set; } = ConstructType.OnStartQueue;
/// <summary>Animation runs in the background — the dialog queue continues immediately.</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>
public override void DoConstruct()
{
base.DoConstruct();
LoadFrames();
}
/// <summary>Stops the animation loop; the last shown frame stays on screen.</summary>
public override void DoDestruct()
{
base.DoDestruct();
isAnimating = false;
}
private void LoadFrames()
{
frames = new List<Texture2D>();
if (SpriteIDs == null)
return;
foreach (var id in SpriteIDs)
{
var tex = GD.Load<Texture2D>(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>
public override void DoRun(DialogRuntime runtime)
{
currentFrame = 0;
elapsed = 0f;
isAnimating = frames != null && frames.Count > 0;
if (isAnimating)
{
var viewBox = Scene.Find<ViewBox>("ViewBox");
viewBox?.SetForegroundTexture(frames[0]);
}
}
void IOnUpdateEvent.OnUpdate()
{
if (!isAnimating || frames == null || frames.Count == 0)
return;
var viewBox = Scene.Find<ViewBox>("ViewBox");
if (viewBox == null)
return;
elapsed += 1f / 60f;
var frameDuration = 1f / Mathf.Max(Speed, 0.01f);
if (elapsed >= frameDuration)
{
elapsed -= frameDuration;
currentFrame++;
if (currentFrame >= frames.Count)
{
if (IsLoop)
currentFrame = 0;
else
{
isAnimating = false;
return;
}
}
viewBox.SetForegroundTexture(frames[currentFrame]);
Speed = NextFrameSpeed;
}
}
}
}
@@ -0,0 +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;
}
}
}
@@ -0,0 +1,33 @@
using Cthangover.Mods.Dialog.Action;
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.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)
{
}
}
}
@@ -0,0 +1,66 @@
using Cthangover.Mods.Dialog.Action;
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.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.
/// </summary>
public class ActionBackgroundShowHide : ActionCommand
{
/// <summary>Whether to fade the background in (Show) or out (Hide).</summary>
public BackgroundActionType ActionType { get; set; } = BackgroundActionType.Show;
/// <summary>Duration of the fade tween in seconds.</summary>
public float Duration { get; set; } = 1f;
private 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;
/// <summary>Starts an alpha tween of the dialog box parent's <c>modulate:a</c> towards fully visible (Show) or fully transparent (Hide).</summary>
public override void DoRun(DialogRuntime runtime)
{
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
var targetAlpha = ActionType == BackgroundActionType.Show ? 1f : 0f;
if (dialogBox.GetParent() is Control backgroundNode)
{
StopTween();
activeTween = backgroundNode.CreateTween();
activeTween.TweenProperty(backgroundNode, "modulate:a", targetAlpha, Duration);
}
}
/// <summary>Kills the fade tween so a destructed action cannot keep animating.</summary>
public override void DoDestruct()
{
base.DoDestruct();
StopTween();
}
private void StopTween()
{
if (activeTween != null)
{
activeTween.Kill();
activeTween = null;
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
using Cthangover.Mods.Dialog.Action;
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.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
}
}
}
}
@@ -0,0 +1,67 @@
using Cthangover.Mods.Effects;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
using Cthangover.Core.UI.Event;
using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.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.
/// </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; }
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>
public override DestructType DestructType { get; set; } = DestructType.OnDelayed;
/// <summary>Resolves <see cref="EffectID"/> through <see cref="EffectFactory"/> and instantiates the effect scene.</summary>
public override void DoConstruct()
{
base.DoConstruct();
if (string.IsNullOrEmpty(EffectID))
return;
var 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>
public override void DoRun(DialogRuntime runtime)
{
}
/// <summary>Frees the spawned effect node; paired with the OnDelayed destruct so effects outlive the action's run.</summary>
public override void DoDestruct()
{
base.DoDestruct();
if (spawnedEffect != null)
{
spawnedEffect.QueueFree();
spawnedEffect = null;
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
using Cthangover.Mods.Dialog.Action;
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.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)
{
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using Cthangover.Mods.Dialog.Action;
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.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();
}
}
}
@@ -0,0 +1,49 @@
using Cthangover.Core.Factories.Impls;
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 foreground texture on the ViewBox. Resolves SpriteID through
/// BackgroundFactory on construction (OnStartQueue) so the texture is loaded
/// before the dialog starts. Sets the texture to null after applying it —
/// ActionCommand instances may be reused by the runtime, and nulling prevents
/// double-applying stale references.
/// </summary>
public class ActionForeground : ActionCommand
{
/// <summary>Sprite resource ID resolved through <see cref="BackgroundFactory"/> on construction.</summary>
public string SpriteID { get; set; }
/// <summary>Direct texture override. Bypasses factory resolution. Nulled after application to prevent stale reuse across dialog cycles.</summary>
public Texture2D Texture { get; set; }
/// <summary>Preloads the texture when the dialog queue is first created.</summary>
public override ConstructType ConstructType { get; set; } = ConstructType.OnStartQueue;
/// <summary>Foreground is set imperatively — the dialog continues immediately.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Resolves <see cref="SpriteID"/> through the background factory unless a direct texture override is set.</summary>
public override void DoConstruct()
{
base.DoConstruct();
if (Texture == null && !string.IsNullOrEmpty(SpriteID))
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>
public override void DoRun(DialogRuntime runtime)
{
var viewBox = Scene.Find<ViewBox>("ViewBox");
viewBox?.SetForegroundTexture(Texture);
Texture = null;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using Cthangover.Mods.Dialog.Action;
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.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);
}
}
}
@@ -0,0 +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;
}
}
}
+50
View File
@@ -0,0 +1,50 @@
using System;
using Cthangover.Core.Utils;
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.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.
/// </summary>
public class ActionIf : ActionCommand
{
/// <summary>Delegate evaluated at runtime. Must return true/false to select the branch target.</summary>
public Func<bool> Condition { get; set; }
/// <summary>Target action ID if <see cref="Condition"/> returns true. Empty or null causes fall-through.</summary>
public string TrueGoTo { get; set; }
/// <summary>Target action ID if <see cref="Condition"/> returns false. Empty or null causes fall-through.</summary>
public string FalseGoTo { get; set; }
/// <summary>Branch evaluation is transparent — the dialog jumps to the target without user input.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Evaluates <see cref="Condition"/> and jumps to <see cref="TrueGoTo"/> or <see cref="FalseGoTo"/>; an empty target falls through to the next action.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (Condition == null)
{
GameLogger.Log("DIALOG", "ActionIf: Condition delegate is null", LogLevel.Error);
runtime.Next();
return;
}
var result = Condition();
var goTo = result ? TrueGoTo : FalseGoTo;
if (!string.IsNullOrEmpty(goTo))
runtime.TryGoTo(goTo);
else
runtime.Next();
}
}
}
@@ -0,0 +1,44 @@
using Cthangover.Core.Interactive;
using Cthangover.Core.Utils;
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;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Dialog action that instantiates an interactive object from its definition
/// and places it on the scene. Executes immediately (NoWait).
/// </summary>
public class ActionInteractiveAdd : ActionCommand
{
/// <summary>The interactive definition ID to load and instantiate.</summary>
public string DefinitionId { get; set; }
/// <summary>Placement happens immediately — the dialog does not pause.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Instantiates the interactive object named by <see cref="DefinitionId"/> through <see cref="InteractiveManager"/>.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (string.IsNullOrEmpty(DefinitionId))
{
GameLogger.Log("INTERACTIVE", "ActionInteractiveAdd: DefinitionId is empty", LogLevel.Error);
return;
}
var mgr = InteractiveManager.Instance;
if (mgr == null)
{
GameLogger.Log("INTERACTIVE", "ActionInteractiveAdd: InteractiveManager not initialized yet", LogLevel.Error);
return;
}
mgr.Add(DefinitionId);
}
}
}
@@ -0,0 +1,29 @@
using Cthangover.Core.Interactive;
using Cthangover.Core.Utils;
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;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Dialog action that removes and destroys all active interactive objects.
/// Typically used on scene exit to ensure clean state.
/// </summary>
public class ActionInteractiveClear : ActionCommand
{
/// <summary>Clearing happens immediately — the dialog does not pause.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Destroys every active interactive object via <see cref="InteractiveManager"/>.</summary>
public override void DoRun(DialogRuntime runtime)
{
GameLogger.Log("INTERACTIVE", "ActionInteractiveClear: clearing all interactives");
InteractiveManager.Instance?.ClearAll();
}
}
}
@@ -0,0 +1,36 @@
using Cthangover.Core.Interactive;
using Cthangover.Core.Utils;
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;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Dialog action that removes and destroys an interactive object by its definition ID.
/// </summary>
public class ActionInteractiveRemove : ActionCommand
{
/// <summary>The definition ID of the interactive object to remove.</summary>
public string DefinitionId { get; set; }
/// <summary>Removal happens immediately — the dialog does not pause.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Destroys the interactive object named by <see cref="DefinitionId"/>.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (string.IsNullOrEmpty(DefinitionId))
{
GameLogger.Log("INTERACTIVE", "ActionInteractiveRemove: DefinitionId is empty", LogLevel.Error);
return;
}
InteractiveManager.Instance?.Remove(DefinitionId);
}
}
}
@@ -0,0 +1,64 @@
using Cthangover.Core.Interactive;
using Cthangover.Core.Utils;
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;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Dialog action that modifies a property of an active interactive object.
/// Supported properties: <c>enabled</c> (bool), <c>visible</c> (bool).
/// </summary>
public class ActionInteractiveSet : ActionCommand
{
/// <summary>The definition ID of the interactive object to modify.</summary>
public string DefinitionId { get; set; }
/// <summary>Property name: "enabled" or "visible".</summary>
public string Property { get; set; }
/// <summary>String value to parse: "true" or "false".</summary>
public string Value { get; set; }
/// <summary>Property changes happen immediately — the dialog does not pause.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Parses <see cref="Value"/> as a bool and applies it to the <c>enabled</c> or <c>visible</c> property of the object named by <see cref="DefinitionId"/>.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (string.IsNullOrEmpty(DefinitionId) || string.IsNullOrEmpty(Property) || string.IsNullOrEmpty(Value))
{
GameLogger.Log("INTERACTIVE", "ActionInteractiveSet: missing parameters", LogLevel.Error);
return;
}
var mgr = InteractiveManager.Instance;
if (mgr == null)
return;
if (!bool.TryParse(Value, out var boolVal))
{
GameLogger.Log("INTERACTIVE", $"ActionInteractiveSet: invalid bool value '{Value}'", LogLevel.Error);
return;
}
switch (Property.ToLowerInvariant())
{
case "enabled":
mgr.SetEnabled(DefinitionId, boolVal);
break;
case "visible":
mgr.SetVisible(DefinitionId, boolVal);
break;
default:
GameLogger.Log("INTERACTIVE", $"ActionInteractiveSet: unknown property '{Property}'", LogLevel.Error);
break;
}
}
}
}
@@ -0,0 +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);
}
}
}
@@ -0,0 +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;
}
}
}
+37
View File
@@ -0,0 +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);
}
}
}
@@ -0,0 +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);
}
}
}
@@ -0,0 +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);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using Cthangover.Core.Utils;
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.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,77 @@
using Cthangover.Core.Actions;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Utils;
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.Action.Impls
{
/// <summary>
/// Bridges the dialog system to the broader scenario action system
/// (ScenarioActionFactory). Resolves an action by name and runs it with a
/// ScenarioActionContext wrapping the dialog runtime. Wraps execution in
/// try/catch so a faulty scenario action doesn't crash the dialog queue.
/// Every error log carries the owning scenario identity (see
/// <see cref="DescribeScenario"/>): the runtime cannot report the source
/// file and line — the parser drops them — but it can name the scenario
/// definition, which together with the validator's file:line report
/// pinpoints the broken command.
/// </summary>
public class ActionScenario : ActionCommand
{
/// <summary>Scenario action name resolved through <see cref="ScenarioActionFactory"/>. Empty names are logged and skipped.</summary>
public string ActionName { get; set; }
/// <summary>The scenario action runs and the dialog advances — no synchronization.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Resolves <see cref="ActionName"/> through <see cref="ScenarioActionFactory"/> and runs it with a dialog-backed context; exceptions are logged, never propagated.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (string.IsNullOrEmpty(ActionName))
{
GameLogger.Log("DIALOG", "ActionScenario: ActionName is null or empty" + DescribeScenario(runtime), LogLevel.Error);
return;
}
var action = ScenarioActionFactory.Instance.Get(ActionName);
if (action == null)
{
GameLogger.Log("DIALOG", $"ActionScenario: scenario action '{ActionName}' is not registered — the action is skipped, check the spelling and that the defining mod is loaded" + DescribeScenario(runtime), LogLevel.Error);
return;
}
var ctx = new ScenarioActionContext(runtime);
try
{
action.Run(ctx);
}
catch (System.Exception ex)
{
GameLogger.Log("DIALOG", $"ActionScenario: {ex.Message}\n{ex.StackTrace}" + DescribeScenario(runtime), LogLevel.Error);
}
}
/// <summary>
/// Formats the scenario suffix for error logs. The locker that started
/// the dialog is the event owning the scenario; its ID is the scenario
/// definition name (ScenarioId) or, for code-driven lockers, the event
/// type name. Returns a human-readable suffix either way, so a missing
/// action in a shipped game can be traced to its scenario from
/// user://logs/cthangover.log alone.
/// </summary>
private static string DescribeScenario(DialogRuntime runtime)
{
var id = runtime?.DialogBox?.Locker?.ID;
return string.IsNullOrEmpty(id)
? " (scenario unknown)"
: $" (scenario '{id}')";
}
}
}
@@ -0,0 +1,51 @@
using System.Collections.Generic;
using Cthangover.Mods.Dialog.Action;
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.Action.Impls
{
/// <summary>
/// Presents player choices by spawning answer variants in the DialogBox.
/// WaitType is WaitSelect so the dialog pauses until the player picks an
/// option (which triggers DialogBox.SelectVariant → Runtime.TryGoTo).
/// Each variant's text is processed through variable substitution before
/// display. Also sets an accompanying text message to contextualize the
/// choice (e.g. "What will you do?").
/// </summary>
public class ActionSelect : ActionCommand
{
/// <summary>The list of selectable choice variants. Each carries display text and a GoTo target.</summary>
public List<SelectVariant> Variants { get; set; }
/// <summary>Context text shown alongside the choices (e.g. "What will you do?"). Supports variable substitution.</summary>
public string Text { get; set; }
/// <summary>Left avatar sprite ID shown during the choice. Null hides the slot.</summary>
public string FirstAvatar { get; set; }
/// <summary>Right avatar sprite ID shown during the choice. Null hides the slot.</summary>
public string SecondAvatar { get; set; }
/// <summary>Dialog pauses until the player selects a variant — then the runtime jumps to the chosen GoTo target.</summary>
public override WaitType WaitType { get; set; } = WaitType.WaitSelect;
/// <summary>Substitutes variables in the variants and context text, then shows the answer box; the runtime stays paused until a variant is picked.</summary>
public override void DoRun(DialogRuntime runtime)
{
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
foreach (var variant in Variants)
variant.Text = runtime.ProcessText(variant.Text);
dialogBox.SetVariants(Variants);
dialogBox.SetText(runtime.ProcessText(Text));
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using System;
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.Action.Impls
{
/// <summary>
/// Sets a dialog runtime variable (Name=Value) for use by subsequent actions
/// via the ${var} substitution syntax. The optional Callback Func&lt;string&gt;
/// allows lazy evaluation — if set, Value is ignored and Callback is invoked
/// at runtime. This enables dynamic values (e.g. quest state, character names)
/// that can't be known when the dialog script is authored.
/// </summary>
public class ActionSet : ActionCommand
{
/// <summary>Variable name. Referenced by subsequent actions via ${name} syntax.</summary>
public string Name { get; set; }
/// <summary>Literal value to store. Ignored if <see cref="Callback"/> is set.</summary>
public string Value { get; set; }
/// <summary>Lazy evaluation delegate. When non-null, invoked at runtime to compute the value dynamically.</summary>
public Func<string> Callback { get; set; }
/// <summary>Variable assignment is instant — the dialog continues without pause.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Stores <see cref="Value"/> — or the result of <see cref="Callback"/> when set — as the runtime variable <see cref="Name"/>.</summary>
public override void DoRun(DialogRuntime runtime)
{
var val = Callback != null ? Callback() : Value;
runtime.SetVariable(Name, val ?? string.Empty);
}
}
}
@@ -0,0 +1,23 @@
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Makes the dialog box visible. Typically paired with ActionHideDialog
/// for cutscenes that temporarily hide the dialog UI while animations play.
/// </summary>
public class ActionShowDialog : ActionCommand
{
/// <summary>Dialog visibility change is instant — the queue continues without pause.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Makes the dialog box chrome visible again after <see cref="ActionHideDialog"/>.</summary>
public override void DoRun(DialogRuntime runtime)
{
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
dialogBox.Visible = true;
}
}
}
+36
View File
@@ -0,0 +1,36 @@
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>
/// Plays a sound through the audio service using the UI sound channel (SoundType.UI).
/// NoWait so the sound plays and the dialog continues immediately — no
/// synchronization with audio completion.
/// </summary>
public class ActionSound : ActionCommand
{
/// <summary>Sound effect identifier resolved through <see cref="IAudioService"/>. Played on the UI channel.</summary>
public string Sound { get; set; }
/// <summary>Sound is fire-and-forget — the dialog continues immediately without synchronization.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Plays <see cref="Sound"/> on the UI sound channel through <see cref="IAudioService"/>; empty names are ignored.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (string.IsNullOrEmpty(Sound))
return;
var audioService = Scene.Find<Node>("AudioService") as IAudioService;
audioService?.PlaySound(Sound, SoundType.UI);
}
}
}
@@ -0,0 +1,66 @@
using Cthangover.Core.Scenes;
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.Action.Impls
{
/// <summary>
/// Deferred scene switch: calls SceneManager.SwitchScene via CallDeferred
/// to avoid mid-queue scene tree mutations. NoWait ensures the dialog ends
/// immediately — there's no return from a scene switch.
/// <para/>
/// Does NOT force the owning scenario into OneRun: re-runs are the scenario
/// author's decision (is_one_run metadata) plus per-event conditions.
/// Navigation menus (select + switch_scene) must survive into the next
/// visit, and forcing OneRun recorded them in CompletedScenarioIds — after
/// which FilterCompletedScenarios silently dropped the whole location menu
/// on re-entry.
/// </summary>
public class ActionSwitchScene : ActionCommand
{
/// <summary>Target scene name passed to <see cref="SceneManager.SwitchScene"/>.</summary>
public string SceneName { get; set; }
/// <summary>Whether to transition with the dialog hidden. Default true.</summary>
public bool HiddenMode { get; set; } = true;
/// <summary>Transition speed parameter passed to the scene manager. Default 4.0.</summary>
public float Speed { get; set; } = 4f;
/// <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>
public override void DoRun(DialogRuntime runtime)
{
if (string.IsNullOrEmpty(SceneName))
{
GameLogger.Log("DIALOG", "ActionSwitchScene: SceneName is null or empty", LogLevel.Error);
return;
}
// No forced IsOneRun here: location navigation scenarios are meant to
// re-run on every visit. Forcing it made DialogRuntime record the
// scenario as completed, and the next visit to the same scene started
// without its menu. One-time story scenes declare is_one_run: true
// (or a condition:) in their metadata instead.
GameLogger.Log("DIALOG", $"ActionSwitchScene: deferring switch to '{SceneName}'");
var sceneManager = Scene.Find<SceneManager>("SceneManager");
if (sceneManager != null)
{
Callable.From(() => sceneManager.SwitchScene(SceneName)).CallDeferred();
}
else
{
GameLogger.Log("DIALOG", $"ActionSwitchScene: SceneManager not found for '{SceneName}'", LogLevel.Error);
}
}
}
}
+49
View File
@@ -0,0 +1,49 @@
using Cthangover.Core.Factories;
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.Action.Impls
{
/// <summary>
/// The primary dialog text display action. Sets the text and both avatar slots
/// (left/right) on the DialogBox. WaitType is WaitClick so the dialog pauses
/// for player input before advancing. Avatars are resolved through
/// IAvatarProvider by string ID; a null/empty avatar hides that slot. When
/// UseProcessText is true, runtime variables (${var}) are substituted before display.
/// </summary>
public class ActionText : ActionCommand
{
/// <summary>The dialog text to display. Supports ${var} substitution when <see cref="UseProcessText"/> is true.</summary>
public string Text { get; set; }
/// <summary>Left avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary>
public string FirstAvatar { get; set; }
/// <summary>Right avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary>
public string SecondAvatar { get; set; }
/// <summary>When true, the avatar is rendered in shader-based silhouette/hidden-color mode.</summary>
public bool HideColor { get; set; }
/// <summary>When true, runtime variable substitution (${var}) is applied to <see cref="Text"/> before display.</summary>
public bool UseProcessText { get; set; }
/// <summary>Pauses for player click/key input — the dialog waits for user acknowledgment.</summary>
public override WaitType WaitType { get; set; } = WaitType.WaitClick;
/// <summary>Sets the dialog text (optionally variable-substituted) and both avatar slots, then waits for the player's click.</summary>
public override void DoRun(DialogRuntime runtime)
{
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
var processedText = UseProcessText ? runtime.ProcessText(Text) : Text;
dialogBox.SetText(processedText);
dialogBox.SetFirstAvatar(AvatarProviderRegistry.Get(FirstAvatar), HideColor);
dialogBox.SetSecondAvatar(AvatarProviderRegistry.Get(SecondAvatar), HideColor);
}
}
}
@@ -0,0 +1,92 @@
using Cthangover.Core.UI.Event;
using Godot;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Timeline-based update action: registers itself as an IOnUpdateEvent
/// 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.
/// </summary>
public class ActionTimedUpdate : ActionCommand, IOnUpdateEvent
{
/// <summary>Update priority. Value 0 — runs before most other listeners.</summary>
public int Priority => 0;
/// <summary>Fires once when <see cref="DoRun"/> is called, before the first update frame.</summary>
public System.Action OnStartCallback { get; set; }
/// <summary>Fires each frame during the active period with a normalized progress value t (0→1) over <see cref="WaitTime"/> seconds.</summary>
public System.Action<float> OnUpdateCallback { get; set; }
/// <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>
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;
private float elapsed;
private bool isRunning;
private DialogRuntime runtime;
/// <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?.AddUpdateEventListener(this);
}
/// <summary>Starts the timeline: resets elapsed time and fires <see cref="OnStartCallback"/> before the first frame.</summary>
public override void DoRun(DialogRuntime runtime)
{
this.runtime = runtime;
elapsed = 0f;
isRunning = true;
OnStartCallback?.Invoke();
}
/// <summary>Detaches from the update controller, stops ticking and fires <see cref="OnFinishCallback"/>.</summary>
public override void DoDestruct()
{
base.DoDestruct();
isRunning = false;
runtime = null;
var controller = Scene.Find<SceneEventController>("EventController");
controller?.RemoveUpdateEventListener(this);
OnFinishCallback?.Invoke();
}
void IOnUpdateEvent.OnUpdate()
{
if (!isRunning)
return;
elapsed += 1f / 60f;
var t = WaitTime > 0f ? Mathf.Min(elapsed / WaitTime, 1f) : 1f;
OnUpdateCallback?.Invoke(t);
if (elapsed >= WaitTime)
{
// 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();
}
}
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Sets the dialog box title text. A null title hides the title bar.
/// NoWait — title updates are instant visual changes.
/// </summary>
public class ActionTitle : ActionCommand
{
/// <summary>Text to display in the title bar. Null hides the title bar.</summary>
public string TitleText { get; set; }
/// <summary>Title changes are instant visual updates — the dialog continues immediately.</summary>
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <summary>Applies <see cref="TitleText"/> to the dialog title bar; null hides the bar.</summary>
public override void DoRun(DialogRuntime runtime)
{
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
dialogBox.SetTitle(TitleText);
}
}
}
@@ -0,0 +1,13 @@
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>
/// Discriminator for background show/hide transitions.
/// </summary>
public enum BackgroundActionType
{
/// <summary>Fade the dialog background visible (target alpha = 1.0).</summary>
Show,
/// <summary>Fade the dialog background transparent (target alpha = 0.0).</summary>
Hide
}
}
@@ -0,0 +1,35 @@
using System;
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.Action.Impls
{
/// <summary>
/// Data model for a dialog choice option: display Text and a GoTo target
/// action ID. The static Point property returns a fresh GUID for runtime
/// goto targets. The New factory provides concise C# construction without
/// object-initializer syntax.
/// </summary>
public class SelectVariant
{
/// <summary>The text displayed on the choice button. Supports runtime variable substitution.</summary>
public string Text { get; set; }
/// <summary>Target action ID to jump to via <see cref="DialogRuntime.TryGoTo"/> when this variant is selected.</summary>
public string GoTo { get; set; }
/// <summary>Generates a new unique action ID for use as a GoTo target (e.g. with <see cref="DialogQueue.Point"/>).</summary>
public static string Point => Guid.NewGuid().ToString();
/// <summary>Factory for concise C# construction without object-initializer syntax.</summary>
public static SelectVariant New(string text, string goTo)
{
return new SelectVariant { Text = text, GoTo = goTo };
}
}
}
+24
View File
@@ -0,0 +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,
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using Cthangover.Mods.Interface.Base.Lists.Impls;
using Cthangover.Mods.Dialog.Action.Impls;
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
{
/// <summary>
/// Answer choice container: extends VerticalListWidget but deliberately
/// overrides PutToLayout to do nothing — the prefab-based layout system
/// handles positioning internally (answer_box.tscn: Content VBoxContainer
/// lays out the items as a container), and the base ColumnCellListWidget
/// layout would conflict. Content size is computed as count * 60px height,
/// creating a fixed-height per-option layout. Self-destructs on hide.
/// </summary>
public partial class AnswerBox : VerticalListWidget<AnswerItem, SelectVariant>
{
private ICollection<SelectVariant> variants;
/// <summary>answer_box.tscn binds Content via the content export; the lookup covers instances built without the binding.</summary>
protected override void OnReady()
{
if (Content == null)
{
var found = FindChild("Content", true, false);
if (found != null)
Set("content", found);
}
}
/// <summary>Stores the variants and triggers <see cref="Widget.Show"/> to build and display the answer list.</summary>
public void CreateVariantsUI(ICollection<SelectVariant> variants)
{
this.variants = variants;
Show();
}
protected override void HideDestruct()
{
base.HideDestruct();
QueueFree();
}
/// <summary>Returns the stored variants collection. Called by the base <see cref="ListWidget{T, M}.ConstructUI"/> pipeline.</summary>
public override ICollection<SelectVariant> CreateModels()
{
return variants;
}
/// <summary>Deliberate no-op — answer items are positioned by their own layout system, not by the list widget's PutToLayout.</summary>
protected override void PutToLayout(AnswerItem item, int index, Control container, Vector2 contentSize)
{
}
/// <summary>Fixed-height layout: content height = <paramref name="count"/> * 60px. Width is inherited from the Content container.</summary>
public override Vector2 GetContentSize(int count)
{
return new Vector2(Content.Size.X, count * 60);
}
}
}
+70
View File
@@ -0,0 +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()
{ }
}
}
+351
View File
@@ -0,0 +1,351 @@
using System.Collections.Generic;
using Cthangover.Core.Audio;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Mods.Dialog.Action;
using Cthangover.Mods.Dialog.Action.Impls;
using Cthangover.Mods.Dialog.Executable;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
using Cthangover.Core.UI.Event;
using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog
{
/// <summary>
/// The visible dialog UI: dual avatars with shader-based hide-color mode,
/// answer box spawning, and click/keyboard advance. Validates that no existing
/// dialog locker is running before accepting a new queue, ensuring only one
/// dialog can occupy the box at a time. Integrates with the audio
/// system for skip sounds on mouse click. Uses the same Widget lifecycle but
/// overrides HideDestruct to reset avatar textures and release the locker.
/// The shader material for avatars is resolved through the mod system on _Ready,
/// supporting mod-provided avatar effects.
/// </summary>
public partial class DialogBox : Widget, IDialogBox
{
[Export] private PackedScene answerBoxScene;
[Export] private Label textField;
[Export] private Label titleField;
[Export] private Control titleBody;
[Export] private TextureRect firstAvatar;
[Export] private TextureRect secondAvatar;
[Export] private Texture2D emptyAvatar;
private IAudioService audioService;
private AnswerBox answerBox;
private SceneTreeTimer waitTimer;
/// <summary>Resolves the body, text and title labels, avatars and the audio service, and applies the mod-provided avatar shader.</summary>
protected override void OnReady()
{
MouseFilter = MouseFilterEnum.Ignore;
if (Body == null)
{
var found = FindChild("DialogBody", true, false);
if (found != null)
Set("body", found);
}
audioService = GetNodeOrNull<Node>("/root/AudioService") as IAudioService;
textField ??= GetNode<Label>("DialogBody/TextField");
titleField ??= GetNode<Label>("TitleBody/TitleField");
titleBody ??= GetNode<Control>("TitleBody");
firstAvatar ??= GetNode<TextureRect>("FirstAvatar");
secondAvatar ??= GetNode<TextureRect>("SecondAvatar");
var avatarShader = Shaders.Resolve("avatar");
if (avatarShader != null)
{
if (firstAvatar != null && firstAvatar.Material == null)
firstAvatar.Material = new ShaderMaterial { Shader = avatarShader };
if (secondAvatar != null && secondAvatar.Material == null)
secondAvatar.Material = new ShaderMaterial { Shader = avatarShader };
}
}
/// <summary>Mutable lock object that prevents concurrent dialogs. Set when a queue starts, cleared when the dialog ends.</summary>
public ExecutableEvent Locker { get; set; }
/// <summary>The execution engine. Created once at construction and reused for all dialog queues.</summary>
public DialogRuntime Runtime { get; } = new();
/// <summary>Whether the answer/choice box is currently visible.</summary>
public bool IsAnswerBoxShowed => answerBox != null;
/// <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>
public void SelectVariant(SelectVariant variant)
{
if (answerBox != null)
{
answerBox.Hide();
answerBox = null;
}
Runtime.TryGoTo(variant.GoTo);
Runtime.Run();
ScheduleWaitTimer();
}
/// <summary>Sets the title bar text. Null or empty hides the title bar.</summary>
public void SetTitle(string title)
{
if (title == null) title = string.Empty;
titleField.Text = title;
titleBody.Visible = title != string.Empty;
}
/// <summary>Instantiates the answer_box scene prefab (bound in dialog_box.tscn), adds it as a child, and populates it with the given choice variants.</summary>
public void SetVariants(ICollection<SelectVariant> variants)
{
if (answerBoxScene == null)
{
GameLogger.Log("DIALOG", "SetVariants: answerBoxScene is not bound in the dialog_box scene", LogLevel.Error);
return;
}
var box = answerBoxScene.Instantiate<AnswerBox>();
AddChild(box);
answerBox = box;
answerBox.CreateVariantsUI(variants);
}
/// <summary>Sets the main dialog text. Hides the Body canvas item when text is null or whitespace.</summary>
public void SetText(string text)
{
if (text == null) text = string.Empty;
textField.Text = text;
var body = Body as CanvasItem;
if (body != null)
body.Visible = !string.IsNullOrWhiteSpace(text);
}
/// <summary>Sets the left avatar texture. Null hides the slot. <paramref name="hideColor"/> enables shader silhouette mode when true.</summary>
public void SetFirstAvatar(Texture2D avatar, bool hideColor = false)
{
if (avatar == null)
{
firstAvatar.Visible = false;
}
else
{
firstAvatar.Texture = avatar;
firstAvatar.Visible = true;
if (firstAvatar.Material is ShaderMaterial mat)
mat.SetShaderParameter("hide_mode", hideColor ? 1 : 0);
}
}
/// <summary>Sets the right avatar texture. Null hides the slot. <paramref name="hideColor"/> enables shader silhouette mode when true.</summary>
public void SetSecondAvatar(Texture2D avatar, bool hideColor = false)
{
if (avatar == null)
{
secondAvatar.Visible = false;
}
else
{
secondAvatar.Texture = avatar;
secondAvatar.Visible = true;
if (secondAvatar.Material is ShaderMaterial mat)
mat.SetShaderParameter("hide_mode", hideColor ? 1 : 0);
}
}
/// <summary>Resets avatars to empty and releases the dialog locker, preparing for the next dialog session.</summary>
protected override void HideDestruct()
{
ClearWaitTimer();
firstAvatar.Texture = emptyAvatar;
firstAvatar.Visible = false;
secondAvatar.Texture = emptyAvatar;
secondAvatar.Visible = false;
Locker = null;
}
/// <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"/>.
/// </summary>
public void 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;
}
if (!IsActive)
{
GameLogger.Log("DIALOG", $"can't run '{locker?.GetType().FullName}', DialogBox is not active", LogLevel.Error);
return;
}
if (Locker != null)
{
GameLogger.Log("DIALOG", $"can't run '{locker?.GetType().FullName}', locker already executing!", LogLevel.Error);
return;
}
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;
Runtime.Run();
ScheduleWaitTimer();
}
/// <summary>
/// Advances the dialog to the next action. Skips if the runtime is waiting for an answer or has ended.
/// Respects <see cref="WaitType.WaitEvent"/> (waits for destruct) and <see cref="WaitType.WaitTime"/> (waits for elapsed duration).
/// Called by click/keyboard input, the wait timer, and self-completing WaitEvent actions.
/// </summary>
public void NextAction()
{
if (Runtime.IsWaitAnswer)
return;
if (Runtime.IsEnd)
{
GameLogger.Log("DIALOG", "NextAction: dialog ended, hiding");
Hide();
return;
}
var action = Runtime.CurrentAction;
if (action == null)
{
GameLogger.Log("DIALOG", "NextAction: no current action, hiding");
Hide();
return;
}
double now = Godot.Time.GetTicksUsec() / 1_000_000.0;
if (action.WaitType == WaitType.WaitEvent && !action.IsDestructed)
return;
if (action.WaitType == WaitType.WaitTime && now - action.StartTime < action.WaitTime)
return;
GameLogger.Log("DIALOG", $"NextAction: advancing past '{action.GetType().Name}' (idx={Runtime.CurrentAction?.GetType().Name})");
Runtime.Next();
Runtime.Run();
ScheduleWaitTimer();
}
/// <summary>Advances the dialog on <c>ui_accept</c> or on a click outside the body, unless a choice is pending or the queue has ended.</summary>
protected override void OnInput(InputEvent @event)
{
if (Runtime.IsEnd || Runtime.IsWaitAnswer)
return;
if (@event.IsActionPressed("ui_accept"))
{
GameLogger.Log("DIALOG", "input: advancing dialog");
NextAction();
}
else if (@event is InputEventMouseButton btn && btn.ButtonIndex == MouseButton.Left && btn.Pressed)
{
var action = Runtime.CurrentAction;
if (action == null || action.WaitType == WaitType.WaitTime || action.WaitType == WaitType.WaitEvent)
return;
if (Body is Control bodyControl)
{
var bodyRect = bodyControl.GetGlobalRect();
if (bodyRect.HasPoint(btn.Position))
{
GameLogger.Log("DIALOG", "input: advancing dialog");
audioService.PlaySound("ui/skip", SoundType.UI);
NextAction();
}
}
}
}
/// <summary>Whether a dialog is currently running (Locker is not null).</summary>
public bool IsRunning => Locker != null;
/// <summary>Whether the current dialog queue reached its end (also true before the first queue loads).</summary>
public bool IsDialogEnded => Runtime.IsEnd;
/// <summary>Whether the dialog is waiting for the player to pick a variant.</summary>
public bool IsWaitingAnswer => Runtime.IsWaitAnswer;
/// <summary>Number of answer variants currently shown (0 when the answer box is hidden).</summary>
public int AnswerVariantCount => answerBox?.CreateModels()?.Count ?? 0;
/// <summary>Selects the shown variant at <paramref name="index"/>. Returns false when nothing is shown or the index is out of range.</summary>
public bool TrySelectVariant(int index)
{
var models = answerBox?.CreateModels();
if (models == null || models.Count == 0 || index < 0 || index >= models.Count)
return false;
var list = new List<SelectVariant>(models);
SelectVariant(list[index]);
return true;
}
/// <summary>
/// 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.
/// </summary>
private void ScheduleWaitTimer()
{
ClearWaitTimer();
if (!Runtime.IsWaitTime)
return;
var wait = Runtime.CurrentAction?.WaitTime ?? 0f;
if (wait <= 0f)
{
NextAction();
return;
}
waitTimer = GetTree().CreateTimer(wait);
waitTimer.Timeout += OnWaitTimerTimeout;
}
/// <summary>
/// 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.
/// </summary>
private void OnWaitTimerTimeout()
{
waitTimer = null;
if (!Runtime.IsWaitTime)
return;
Runtime.Next();
Runtime.Run();
ScheduleWaitTimer();
}
/// <summary>
/// Cancels a pending delay timer. Called before scheduling a new one and when the dialog
/// hides, so a stale timer can never advance a later, unrelated dialog session.
/// </summary>
private void ClearWaitTimer()
{
if (waitTimer == null)
return;
waitTimer.Timeout -= OnWaitTimerTimeout;
waitTimer = null;
}
}
}
+52
View File
@@ -0,0 +1,52 @@
using Cthangover.Core.Scenes;
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
{
/// <summary>
/// Thin wrapper node that hosts the dialog UI. The canvas instantiates its
/// <see cref="DialogBox"/> child at runtime from the core mod through
/// <see cref="ModScenePrefab"/> and keeps the well-known node name
/// <c>DialogBox</c>. The rest of the game locates it by that name via
/// <see cref="SceneContextNode.FindNode{T}"/>, not by hardcoded paths.
/// </summary>
public partial class DialogCanvas : Control
{
private static readonly ModScenePrefab DialogBoxPrefab = new("scenes/ui/dialog_box.tscn");
private DialogBox dialogBox;
/// <summary>The active DialogBox child, resolved in _Ready. Used by the scene tree root to access dialog without path traversal.</summary>
public DialogBox DialogBox => dialogBox;
/// <summary>Instantiates the <see cref="DialogBox"/> child from the core prefab when the scene does not ship one, keeping the well-known node name for path-free lookup.</summary>
public override void _Ready()
{
dialogBox ??= GetNodeOrNull<DialogBox>("DialogBox");
if (dialogBox == null)
{
dialogBox = DialogBoxPrefab.Instantiate<DialogBox>();
if (dialogBox != null)
{
dialogBox.Name = "DialogBox";
AddChild(dialogBox);
}
else
{
GameLogger.Log("DIALOG", "DialogCanvas._Ready: failed to build 'scenes/ui/dialog_box.tscn'", LogLevel.Error);
}
}
MouseFilter = MouseFilterEnum.Ignore;
}
}
}
+346
View File
@@ -0,0 +1,346 @@
using System;
using System.Collections.Generic;
using Cthangover.Mods.Dialog.Action;
using Cthangover.Mods.Dialog.Action.Impls;
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.Mods.Effects;
using Cthangover.Core.Scenes;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog
{
/// <summary>
/// Fluent builder for dialog sequences. Each method creates an action of the
/// corresponding type, appends it to the Queue, and returns the action for
/// optional further configuration. The API vocabulary mirrors the scenario DSL
/// commands (Text, Title, Background, Music, Sound, Delay, GoTo, If, Select,
/// SwitchScene, etc.), making the C# API feel like a script. RuntimeObjectList
/// is a separate list of IActionDestruct objects that must be cleaned up when
/// the dialog ends but are not part of the sequential queue (e.g. spawned
/// resources, scheduled callbacks).
/// </summary>
public class DialogQueue
{
/// <summary>Objects that need <see cref="IActionDestruct.Destruct"/> cleanup when the dialog ends but are not part of the sequential queue (e.g. spawned resources).</summary>
public List<IActionDestruct> RuntimeObjectList { get; } = new();
/// <summary>The ordered sequence of dialog actions. Appended via fluent builder methods; consumed by <see cref="DialogRuntime"/>.</summary>
public List<IActionCommand> Queue { get; } = new();
/// <summary>Spawns a visual effect identified by <paramref name="effectID"/> through <see cref="EffectFactory"/>. Returns the action for further configuration.</summary>
public ActionEffect Effect(string effectID)
{
var action = new ActionEffect { EffectID = effectID };
Queue.Add(action);
return action;
}
/// <summary>Creates a frame-animation foreground from a base sprite path, generating frame IDs from <paramref name="start"/> to <paramref name="start"/> + <paramref name="count"/>.</summary>
public ActionAnimatedForeground Animation(string sprite, int start, int count, float speed = 1, float nextFrameSpeed = 1, bool isLoop = true)
{
var list = new List<string>();
for (int i = start; i <= start + count; i++)
list.Add(sprite + $"{i:00}");
return Animation(list, speed, nextFrameSpeed, isLoop);
}
/// <summary>Creates a frame-animation foreground from a pre-built list of sprite resource paths.</summary>
public ActionAnimatedForeground Animation(List<string> sprites, float speed = 1, float nextFrameSpeed = 1, bool isLoop = true)
{
var action = new ActionAnimatedForeground { SpriteIDs = sprites, Speed = speed, NextFrameSpeed = nextFrameSpeed, IsLoop = isLoop };
Queue.Add(action);
return action;
}
/// <summary>Sets the scene background from a sprite ID resolved through <see cref="BackgroundFactory"/>. Also loads depth and albedo maps for lighting.</summary>
public ActionBackground Background(string spriteId)
{
var action = new ActionBackground { SpriteID = spriteId };
Queue.Add(action);
return action;
}
/// <summary>Sets the scene background directly from a pre-loaded <see cref="Texture2D"/>. Bypasses factory resolution.</summary>
public ActionBackground BackgroundTexture(Texture2D texture)
{
var action = new ActionBackground { Texture = texture };
Queue.Add(action);
return action;
}
/// <summary>Sets the scene foreground from a sprite ID resolved through <see cref="BackgroundFactory"/>.</summary>
public ActionForeground Foreground(string spriteId)
{
var action = new ActionForeground { SpriteID = spriteId };
Queue.Add(action);
return action;
}
/// <summary>Sets the scene foreground directly from a pre-loaded <see cref="Texture2D"/>.</summary>
public ActionForeground ForegroundTexture(Texture2D texture)
{
var action = new ActionForeground { Texture = texture };
Queue.Add(action);
return action;
}
/// <summary>Displays dialog text with optional avatars. Pauses for click before advancing.</summary>
public ActionText Text(string text, string first = null, string second = null, bool hideColor = false)
{
var action = new ActionText { Text = text, FirstAvatar = first, SecondAvatar = second, HideColor = hideColor };
Queue.Add(action);
return action;
}
/// <summary>Like <see cref="Text"/> but enables runtime variable substitution (${var}) on the text content.</summary>
public ActionText PText(string text, string first = null, string second = null, bool hideColor = false)
{
var action = new ActionText { Text = text, FirstAvatar = first, SecondAvatar = second, HideColor = hideColor, UseProcessText = true };
Queue.Add(action);
return action;
}
/// <summary>Sets the dialog title bar. Non-null shows it; null hides.</summary>
public ActionTitle Title(string title)
{
var action = new ActionTitle { TitleText = title };
Queue.Add(action);
return action;
}
/// <summary>Explicitly hides the title bar by setting TitleText to null.</summary>
public ActionTitle HideTitle()
{
var action = new ActionTitle { TitleText = null };
Queue.Add(action);
return action;
}
/// <summary>Sets a runtime variable by name and literal value. Available to subsequent actions via ${name} syntax.</summary>
public ActionSet Set(string name, string value)
{
var action = new ActionSet { 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)
{
var action = new ActionSet { Name = name, Callback = callback };
Queue.Add(action);
return action;
}
/// <summary>Presents player choices. Each <see cref="SelectVariant"/> has display text and a GoTo target. Dialog pauses until a choice is picked.</summary>
public ActionSelect Select(List<SelectVariant> variants, string text = null, string first = null, string second = null)
{
var action = new ActionSelect { Variants = variants, Text = text, FirstAvatar = first, SecondAvatar = second };
Queue.Add(action);
return action;
}
/// <summary>Terminates the dialog queue, calling <see cref="DialogRuntime.End"/>.</summary>
public ActionEnd End()
{
var action = new ActionEnd();
Queue.Add(action);
return action;
}
/// <summary>A labeled no-op. Used as a named jump target for GoTo/If commands.</summary>
public ActionEmpty Empty()
{
var action = new ActionEmpty();
Queue.Add(action);
return action;
}
/// <summary>Creates a named empty action (a "point") identified by <paramref name="id"/> for GoTo targeting.</summary>
public ActionEmpty Point(string id)
{
return (ActionEmpty)Empty().SetID(id);
}
/// <summary>Starts background music playback via <see cref="IAudioService"/> using <see cref="MusicType.Ambient"/> channel.</summary>
public ActionMusic Music(string music)
{
var action = new ActionMusic { Music = music };
Queue.Add(action);
return action;
}
/// <summary>Pauses background music without stopping the track. Resume with <see cref="MusicPlay"/>.</summary>
public ActionMusicPause MusicPause()
{
var action = new ActionMusicPause();
Queue.Add(action);
return action;
}
/// <summary>Resumes previously paused background music.</summary>
public ActionMusicPlay MusicPlay()
{
var action = new ActionMusicPlay();
Queue.Add(action);
return action;
}
/// <summary>Waits for <paramref name="waitTime"/> seconds while optionally displaying a text message via <paramref name="showText"/>.</summary>
public ActionDelay Delay(float waitTime, string showText)
{
var action = new ActionDelay { WaitTime = waitTime, HiddenMode = false, ShowText = showText };
Queue.Add(action);
return action;
}
/// <summary>Waits for <paramref name="waitTime"/> seconds. When <paramref name="hiddenMode"/> is true, hides the dialog body for the duration (dramatic pauses, cutscene beats).</summary>
public ActionDelay Delay(float waitTime, bool hiddenMode = false)
{
var action = new ActionDelay { WaitTime = waitTime, HiddenMode = hiddenMode, ShowText = null };
Queue.Add(action);
return action;
}
/// <summary>Plays a UI sound effect through <see cref="IAudioService"/>. Non-blocking — the dialog continues immediately.</summary>
public ActionSound Sound(string sound)
{
var action = new ActionSound { Sound = sound };
Queue.Add(action);
return action;
}
/// <summary>Invokes an external scenario action by name, resolved through <see cref="ScenarioActionFactory"/>.</summary>
public ActionScenario Action(string actionName)
{
var action = new ActionScenario { ActionName = actionName };
Queue.Add(action);
return action;
}
/// <summary>Runs arbitrary C# code as a dialog action. Useful for quest state changes or UI toggles without a dedicated action class.</summary>
public ActionRun Run(System.Action executable)
{
var action = new ActionRun { Executable = executable };
Queue.Add(action);
return action;
}
/// <summary>Evaluates a condition and jumps to <paramref name="trueGoTo"/> or <paramref name="falseGoTo"/> based on the result. Falls through if the chosen target is empty.</summary>
public ActionIf IfGoTo(Func<bool> condition, string trueGoTo, string falseGoTo = null)
{
var action = new ActionIf { Condition = condition, TrueGoTo = trueGoTo, FalseGoTo = falseGoTo };
Queue.Add(action);
return action;
}
/// <summary>Unconditionally jumps to the action identified by <paramref name="goTo"/> in the queue.</summary>
public ActionGoTo GoTo(string goTo)
{
var action = new ActionGoTo { GoTo = goTo };
Queue.Add(action);
return action;
}
/// <summary>Deferred scene switch via <see cref="SceneManager"/>. Sets the locker as OneRun to prevent re-triggering after scene reload.</summary>
public ActionSwitchScene SwitchScene(string targetScene, float speed = 4f, bool hiddenMode = true)
{
var action = new ActionSwitchScene { SceneName = targetScene, HiddenMode = hiddenMode, Speed = speed };
Queue.Add(action);
return action;
}
/// <summary>Declares a background tint color to be consumed by background-rendering nodes.</summary>
public ActionBackgroundColor BackgroundColor(Color color)
{
var action = new ActionBackgroundColor { Color = color };
Queue.Add(action);
return action;
}
/// <summary>Makes the dialog box visible. Paired with <see cref="HideDialog"/> for cutscenes that temporarily hide dialog chrome.</summary>
public ActionShowDialog ShowDialog()
{
var action = new ActionShowDialog();
Queue.Add(action);
return action;
}
/// <summary>Hides the dialog box. Paired with <see cref="ShowDialog"/> for cutscene transitions.</summary>
public ActionHideDialog HideDialog()
{
var action = new ActionHideDialog();
Queue.Add(action);
return action;
}
/// <summary>Toggles whether lighting responds to in-game time of day (true) or uses static lights (false).</summary>
public ActionLightUseTime LightUseTime(bool useTime)
{
var action = new ActionLightUseTime { UseTime = useTime };
Queue.Add(action);
return action;
}
/// <summary>Applies static light definitions from a JSON string to the light controller. Empty string clears all static lights.</summary>
public ActionLightSet LightSet(string json)
{
var action = new ActionLightSet { LightsJson = json };
Queue.Add(action);
return action;
}
/// <summary>Fade the dialog background in (<see cref="BackgroundActionType.Show"/>) or out (<see cref="BackgroundActionType.Hide"/>). Optionally set <paramref name="wait"/> to pause the dialog during the transition.</summary>
public ActionBackgroundShowHide BackgroundShowHide(BackgroundActionType type, float duration, bool wait = false)
{
var action = new ActionBackgroundShowHide { ActionType = type, Duration = duration };
if (wait)
{
action.WaitType = WaitType.WaitTime;
action.WaitTime = duration;
}
Queue.Add(action);
return action;
}
/// <summary>Adds an interactive element to the scene by definition ID.</summary>
public ActionInteractiveAdd InteractiveAdd(string definitionId)
{
var action = new ActionInteractiveAdd { DefinitionId = definitionId };
Queue.Add(action);
return action;
}
/// <summary>Removes an interactive element from the scene by definition ID.</summary>
public ActionInteractiveRemove InteractiveRemove(string definitionId)
{
var action = new ActionInteractiveRemove { DefinitionId = definitionId };
Queue.Add(action);
return action;
}
/// <summary>Clears all interactive elements from the scene.</summary>
public ActionInteractiveClear InteractiveClear()
{
var action = new ActionInteractiveClear();
Queue.Add(action);
return action;
}
/// <summary>Sets a property on an interactive element identified by definition ID.</summary>
public ActionInteractiveSet InteractiveSet(string definitionId, string property, string value)
{
var action = new ActionInteractiveSet { DefinitionId = definitionId, Property = property, Value = value };
Queue.Add(action);
return action;
}
}
}
+326
View File
@@ -0,0 +1,326 @@
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Events;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Mods.Dialog.Action;
using Cthangover.Core.UI.Event;
using Cthangover.Mods.Dialog.Executable;
using Cthangover.Core.Utils;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog
{
/// <summary>
/// Dialog execution engine: manages the action queue with goto-based branching
/// and variable substitution. Run() iterates actions in a tight loop, breaking
/// only on wait points (WaitClick/Select/Time/Event). Text variables use the
/// "${name}" syntax and are resolved via GetVariable/SetVariable — values are
/// stored in a simple Dictionary, not a cascading scope, keeping lookups O(1).
/// 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.
/// Infinite loop protection caps at 100k iterations per Run call.
/// </summary>
public class DialogRuntime
{
private List<IActionCommand> dialogQueue;
private List<IActionCommand> endDialogQueue;
private int goToActionIndex;
private int index;
private bool isLastGoTo;
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>
public DialogBox DialogBox { get; private set; }
/// <summary>The currently active dialog queue. Set by <see cref="SetDialogQueueAndRun"/>.</summary>
public DialogQueue Dialog { get; set; }
/// <summary>The action at the current queue index. Automatically constructs it if <see cref="ConstructType"/> is OnStartAction and it hasn't been constructed yet.</summary>
public IActionCommand CurrentAction => GetByIndex(index);
/// <summary>True when the current action is waiting for a player choice (<see cref="WaitType.WaitSelect"/>).</summary>
public bool IsWaitAnswer => IsWaitType(WaitType.WaitSelect);
/// <summary>True when the current action is waiting for a timer (<see cref="WaitType.WaitTime"/>).</summary>
public bool IsWaitTime => IsWaitType(WaitType.WaitTime);
/// <summary>True when the current action is waiting for an external event (<see cref="WaitType.WaitEvent"/>).</summary>
public bool IsWaitEvent => IsWaitType(WaitType.WaitEvent);
/// <summary>True when the dialog queue is exhausted (index past end or queue is empty).</summary>
public bool IsEnd => Lists.IsEmpty(dialogQueue) || index >= dialogQueue.Count;
/// <summary>Retrieves a stored variable value by name. Returns null if not set.</summary>
public string GetVariable(string name)
{
variables.TryGetValue(name, out var val);
return val;
}
/// <summary>Stores a variable for later substitution via ${name} syntax. Overwrites existing values.</summary>
public void SetVariable(string name, string value)
{
variables[name] = value;
}
/// <summary>Scans <paramref name="text"/> for ${key} patterns and replaces them with stored variable values. Unresolved keys become "?".</summary>
public string ProcessText(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var vars = text.GetIncludesInQuotes("${", "}");
if (Lists.IsNotEmpty(vars))
foreach (var variable in vars)
{
if (!variables.TryGetValue(variable, out var val))
val = "?";
text = text.Replace("${" + variable + "}", val);
}
return text;
}
private bool IsWaitType(WaitType type)
{
if (Lists.IsEmpty(dialogQueue))
return false;
if (index < 0 || index >= dialogQueue.Count)
return false;
var action = dialogQueue[index];
return action != null && action.WaitType == type;
}
private IActionCommand GetByIndex(int idx)
{
if (Lists.IsEmpty(dialogQueue))
return null;
if (idx < 0 || idx >= dialogQueue.Count)
return null;
var action = dialogQueue[idx];
if (action != null && action.ConstructType == ConstructType.OnStartAction && !action.IsConstructed)
action.Construct();
return action;
}
/// <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.
/// Note: does NOT call Run() — the caller must call Run() separately.
/// </summary>
public void SetDialogQueueAndRun(DialogBox dialogBox,
IEnumerable<IActionCommand> queue,
IEnumerable<IActionCommand> endQueue,
int startIndex = 0)
{
variables = new Dictionary<string, string>();
DialogBox = dialogBox;
this.endDialogQueue = endQueue == null ? null : endQueue.ToList();
if (Lists.IsNotEmpty(this.dialogQueue))
End();
this.dialogQueue = queue.ToList();
index = startIndex;
foreach (var action in this.dialogQueue)
if (action != null && action.ConstructType == ConstructType.OnStartQueue && !action.IsConstructed)
action.Construct();
}
/// <summary>
/// Searches the queue linearly for an action with ID == <paramref name="actionID"/> and jumps the index there.
/// Destructs the source action before jumping. If not found, calls <see cref="End"/>.
/// <paramref name="lastGoTo"/> prevents the runtime from auto-advancing past the target when the GoTo is the last action in its branch.
/// </summary>
public void TryGoTo(string actionID, bool lastGoTo = false)
{
goToActionIndex = index;
if (string.IsNullOrEmpty(actionID))
return;
for (var i = 0; i < dialogQueue.Count; i++)
{
var action = dialogQueue[i];
if (action?.ID == actionID)
{
index = i;
var actionGoTo = GetByIndex(goToActionIndex);
if (actionGoTo != null && actionGoTo.IsConstructed && actionGoTo.DestructType == DestructType.OnEndAction)
actionGoTo.Destruct();
this.isLastGoTo = lastGoTo;
return;
}
}
GameLogger.Log("DIALOG", "Dialog goto action not found!", LogLevel.Error);
End();
}
/// <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
/// with a 100,000-iteration cap.
/// </summary>
public void Run()
{
var iteration = 0;
for (;;)
{
var action = CurrentAction;
if (action == null)
{
GameLogger.Log("DIALOG", "Run: action is null, ending", LogLevel.Debug);
End();
return;
}
GameLogger.Log("DIALOG", $"Run: idx={index}/{dialogQueue.Count-1} type={action.GetType().Name} wait={action.WaitType}", LogLevel.Debug);
action.Run(this);
if (iteration++ > 100000)
{
GameLogger.Log("DIALOG", "Run: infinite loop protection (100k iterations)", LogLevel.Warning);
break;
}
switch (action.WaitType)
{
case WaitType.WaitClick:
case WaitType.WaitSelect:
case WaitType.WaitTime:
case WaitType.WaitEvent:
break;
case WaitType.NoWait:
Next();
continue;
}
break;
}
}
/// <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.
/// Respects <see cref="isLastGoTo"/> — if a GoTo was the final action, the index is not incremented.
/// </summary>
public void Next()
{
if (isLastGoTo)
{
GameLogger.Log("DIALOG", "Next: last goto, returning");
isLastGoTo = false;
return;
}
var action = CurrentAction;
if (action != null && action.IsConstructed && action.DestructType == DestructType.OnEndAction)
action.Destruct();
if (action != null && Lists.IsNotEmpty(dialogQueue) && Lists.IsNotEmpty(action.DelayedDestruct))
foreach (var item in dialogQueue)
if (action.DelayedDestruct.Contains(item.ID))
item.Destruct();
if (Lists.IsEmpty(dialogQueue) || index < 0 || index >= dialogQueue.Count)
{
GameLogger.Log("DIALOG", "Next: queue exhausted, ending");
End();
}
index++;
}
/// <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"/>.
/// Guards against end-queue actions starting a new dialog mid-cleanup by checking if the queue changed.
/// </summary>
public void End()
{
GameLogger.Log("DIALOG", $"End: {dialogQueue?.Count ?? 0} actions in queue, {(endDialogQueue?.Count ?? 0)} end actions");
if (Lists.IsNotEmpty(dialogQueue))
{
foreach (var action in dialogQueue)
if (action != null && action.IsConstructed)
action.Destruct();
dialogQueue.Clear();
}
ExecutableEvent locker = null;
if (DialogBox != null)
{
locker = DialogBox.Locker;
TrackOneRunCompletion(locker);
DialogBox.Locker = null;
DialogBox.Hide();
}
if (Lists.IsNotEmpty(endDialogQueue))
{
GameLogger.Log("DIALOG", "End: processing end actions...");
var oldQueue = dialogQueue;
foreach (var action in endDialogQueue)
{
if (action == null || action.IsDestructed)
continue;
if (!action.IsConstructed) action.Construct();
action.Run(this);
action.Destruct();
}
endDialogQueue.Clear();
if (dialogQueue != oldQueue)
{
GameLogger.Log("DIALOG", "End: new dialog started during end actions, skipping cleanup", LogLevel.Warning);
return;
}
}
dialogQueue = null;
endDialogQueue = null;
index = 0;
if (DialogBox != null)
{
ModEventBus.Publish(new DialogEndedEvent { LockerId = locker?.ID });
}
}
/// <summary>
/// Records a finished one-run scenario in the per-game completion
/// history (<see cref="RuntimeData.CompletedScenarioIds"/>) so the
/// scenario runner never re-triggers it on later visits to the
/// scene or after save/load. Only <see cref="ScenarioEvent"/> with
/// an explicit ScenarioId is tracked: its ID matches the scenario
/// definition name used by the runner's filters, whereas generic
/// lockers (e.g. the ones flagged OneRun by ActionSwitchScene) carry
/// type-name IDs that would poison the history.
/// </summary>
private static void TrackOneRunCompletion(ExecutableEvent locker)
{
if (!(locker is ScenarioEvent scenario) || !scenario.IsOneRun || string.IsNullOrEmpty(scenario.ScenarioId))
return;
var runtime = GameData.Instance?.Runtime;
if (runtime != null && runtime.CompletedScenarioIds.Add(scenario.ID))
GameLogger.Log("DIALOG", $"One-run scenario '{scenario.ID}' marked as completed");
}
}
}
+172
View File
@@ -0,0 +1,172 @@
using Cthangover.Core.Scenes;
using Cthangover.Mods.Dialog;
using Cthangover.Core.UI.Event;
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.Executable;
using Cthangover.Core.Scenarios;
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.
/// </summary>
public partial class ExecutableEvent : Node, IOnUpdateEvent, IExecutableEvent
{
protected DialogBox dialogBox;
private SceneEventController eventController;
private float timestamp;
// Version at which the event last ran (MinValue forces the first run).
private long _lastRunVersion = long.MinValue;
// "Waiting for own dialog to end" flag, set after starting a run.
private bool _awaitingOwnEnd;
/// <summary>
/// <c>true</c> until <see cref="RunDialog"/> is called 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;
/// <summary>
/// If <c>true</c>, the event fires exactly once and never again, even across
/// scene revisits. After <see cref="RunDialog"/> completes, <see cref="IsFirstRun"/>
/// becomes <c>false</c>.
/// </summary>
[Export] public bool IsOneRun { get; set; } = false;
/// <summary>
/// If <c>true</c>, the event can re-trigger on subsequent scene visits.
/// When <c>false</c>, the event only fires on the first visit to the scene.
/// </summary>
[Export] public bool CanRepeat { get; set; } = true;
/// <summary>
/// Virtual guard evaluated before triggering. Defaults to <c>true</c>.
/// Override to implement conditional logic (e.g. <see cref="ScenarioEvent"/>
/// checks script conditions via <see cref="ScenarioCondition.Evaluate"/>).
/// </summary>
public virtual bool CheckConditions => true;
/// <summary>
/// Unique identifier for this event instance. Defaults to the full type name.
/// Override to provide a stable, save-friendly ID.
/// </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>
public override void _Ready()
{
if (dialogBox == null)
{
var root = GetTree()?.Root;
if (root != null)
{
dialogBox = SceneContextNode.FindNode<DialogBox>("DialogBox");
}
}
var isInChain = GetParent() is IExecutableEventChain;
if (!isInChain)
{
eventController = SceneContextNode.FindNode<SceneEventController>("EventController");
eventController?.AddUpdateEventListener(this);
}
GameLogger.Log("EVENT", $"{GetType().Name}._Ready: dialogBox={(dialogBox != null ? "found" : "null")}, chain={isInChain}, registered={!isInChain}", LogLevel.Debug);
}
/// <summary>Detaches from the event controller's update polling when the event leaves the tree.</summary>
public override void _ExitTree()
{
if (eventController != null)
eventController.RemoveUpdateEventListener(this);
}
/// <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.
/// </summary>
public void OnUpdate()
{
if (!IsFirstRun && !CanRepeat)
return;
if (dialogBox == null)
return;
if (_awaitingOwnEnd)
{
if (dialogBox.IsRunning)
return;
_awaitingOwnEnd = false;
_lastRunVersion = ConditionStateMonitor.Version;
}
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;
if (RunDialog())
_awaitingOwnEnd = true;
}
/// <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.
/// </summary>
public bool RunDialog()
{
if (!IsFirstRun && IsOneRun)
return false;
if (dialogBox == null)
return false;
if (dialogBox.Locker != null)
return false;
GameLogger.Log("EVENT", $"{GetType().Name}.RunDialog: starting dialog...");
var dlg = new DialogQueue();
CreateDialog(dlg);
dialogBox.SetDialogQueueAndRun(dlg, null, 0, this);
IsFirstRun = false;
return true;
}
protected virtual void CreateDialog(DialogQueue dlg)
{
}
}
}
@@ -0,0 +1,88 @@
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();
}
}
}
@@ -0,0 +1,90 @@
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;
}
}
}
@@ -0,0 +1,233 @@
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;
using Cthangover.Core.UI;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Core.UI.Dialog;
using Cthangover.Core.UI.Executable;
using Cthangover.Core.Scenarios;
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.
/// </summary>
public partial class ExecutableMainEventChain : Control, IExecutableEventChain, IOnUpdateEvent
{
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;
/// <summary>
/// Stores the last background ID set by action commands (e.g. "bg_home").
/// Used on scene revisit to restore the previously active background.
/// </summary>
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.
/// </summary>
public bool IsActive
{
get => isActive;
set { isActive = value; }
}
/// <summary>
/// Removes and frees all currently registered events from the chain via
/// <c>QueueFree</c> and empties the internal event list.
/// </summary>
public void ClearEvents()
{
GameLogger.Log("CHAIN", $"ClearEvents: removing {allEvents.Count} event(s)");
foreach (var evt in allEvents)
{
var id = evt is ScenarioEvent se ? se.Condition ?? "no_condition" : evt.GetType().Name;
GameLogger.Log("CHAIN", $" QueueFree: {evt.GetType().Name} [condition={id}]");
evt.QueueFree();
}
allEvents.Clear();
}
/// <summary>
/// Adds an <see cref="ExecutableEvent"/> to the chain and reparents it via
/// <c>AddChild</c>. Events are dispatched in insertion order.
/// </summary>
/// <param name="evt">The event to register and parent to this chain.</param>
public void AddEvent(IExecutableEvent evt)
{
var typed = (ExecutableEvent)evt;
var id = typed is ScenarioEvent se ? se.Condition ?? "no_condition" : typed.GetType().Name;
GameLogger.Log("CHAIN", $"AddEvent: {typed.GetType().Name} [condition={id}] — calling AddChild");
AddChild(typed);
allEvents.Add(typed);
_wakeup = true;
GameLogger.Log("CHAIN", $" AddEvent: done, allEvents.Count={allEvents.Count}");
}
/// <summary>
/// Removes an event from the chain and queues it for deletion. No-op if
/// the event is not currently registered.
/// </summary>
/// <param name="evt">The event to unregister and free.</param>
public void RemoveEvent(ExecutableEvent evt)
{
if (allEvents.Remove(evt))
{
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>
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);
foreach (var child in GetChildren())
{
if (child is ExecutableEvent evt)
{
allEvents.Add(evt);
GameLogger.Log("CHAIN", $"collected event '{evt.GetType().Name}'");
}
}
ModEventBus.Publish(new ShellReadyEvent { Root = this });
var sceneManager = GetNodeOrNull<SceneManager>("/root/SceneManager");
if (sceneManager != null && !string.IsNullOrEmpty(sceneManager.PendingSceneName))
{
var pending = sceneManager.PendingSceneName;
GameLogger.Log("CHAIN", $"_Ready: deferring SwitchScene('{pending}')");
sceneManager.PendingSceneName = null;
Callable.From(() => sceneManager.SwitchScene(pending)).CallDeferred();
}
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>
public override void _ExitTree()
{
eventController?.RemoveUpdateEventListener(this);
ModEventBus.Unsubscribe<DialogEndedEvent>(OnDialogEnded);
}
/// <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).
/// </summary>
private void OnDialogEnded(DialogEndedEvent evt)
{
_wakeup = true;
if (_lastRunEvent != null && evt.LockerId == _lastRunEvent.ID)
_lastRunEndVersion = ConditionStateMonitor.Version;
}
/// <summary>Debounced scan that starts the next runnable event when the condition state changed since the last completed run.</summary>
public void OnUpdate()
{
if (!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)
return;
var version = ConditionStateMonitor.Version;
if (version == _lastScanVersion && !_wakeup)
return;
_wakeup = false;
_lastScanVersion = version;
var dialog = FindEvent(allEvents, _lastRunEvent, _lastRunEndVersion);
if (dialog == null)
return;
if (dialog.RunDialog())
_lastRunEvent = dialog;
}
/// <summary>
/// <c>true</c> if the chain has any registered events remaining. Used to
/// check whether a scene still has pending dialog content.
/// </summary>
public bool HasEvents => allEvents.Count > 0;
/// <summary>
/// Deactivates the chain, preventing any further event dispatch until
/// reactivation.
/// </summary>
public void Stop()
{
isActive = false;
}
private ExecutableEvent FindEvent(List<ExecutableEvent> list, ExecutableEvent skipEvent, long skipEndVersion)
{
if (Lists.IsEmpty(list))
return null;
foreach (var nextEvent in list)
{
if (nextEvent == skipEvent && ConditionStateMonitor.Version <= skipEndVersion)
continue;
if (nextEvent.IsOneRun && !nextEvent.IsFirstRun)
continue;
if (nextEvent.CheckConditions)
return nextEvent;
}
return null;
}
}
}
@@ -0,0 +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();
}
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace Cthangover.Mods.Dialog.Executable
{
/// <summary>
/// Placeholder/stub node. Likely represents a future or deprecated behaviour
/// component for manually triggering ExecutableEvents.
/// </summary>
public partial class RunEventBehaviour : Godot.Node
{
}
}
+141
View File
@@ -0,0 +1,141 @@
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Mods.Dialog.Scenarios;
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>
/// ExecutableEvent backed by a scenario file (.scenario). Converts scenario
/// text using ScenarioConverter and appends the resulting actions to the
/// dialog queue. Resolves the scenario path via convention: if ScenarioPath
/// is set, uses that; otherwise searches all mods for a matching file name
/// derived from the class name (stripping "Event" suffix), with a fallback
/// to res://mods/visual_novel_core/scenarios/. CustomMappings allows code to register
/// arbitrary class-to-path bindings for mod-provided scenarios. Supports
/// inline ScenarioText for embedded scenarios that don't need a separate
/// file. Checks conditions through ScenarioCondition.Evaluate before triggering.
/// </summary>
public partial class ScenarioEvent : ExecutableEvent, IConditionedEvent
{
private static readonly Dictionary<string, string> CustomMappings = new();
/// <summary>
/// Registers a custom mapping from a class name to a scenario file path.
/// Use from mod initialization code for mod-provided scenario paths that
/// deviate from the default naming convention.
/// </summary>
/// <param name="className">The C# class name (without namespace).</param>
/// <param name="scenarioPath">The resource path to the .scenario file.</param>
public static void RegisterScenario(string className, string scenarioPath)
{
CustomMappings[className] = scenarioPath;
}
/// <summary>
/// Explicit resource path to a scenario file. When set, overrides the
/// default path resolution in <see cref="ResolveScenarioPath"/>.
/// </summary>
[Export] public string ScenarioPath { get; set; }
/// <summary>
/// Inline scenario text. If non-empty, parsed by <see cref="ScenarioConverter"/>
/// instead of loading from a file. Enables embedding small scenarios directly
/// in code or scene data.
/// </summary>
public string ScenarioText { get; set; }
/// <summary>
/// A condition expression evaluated by <see cref="ScenarioCondition.Evaluate"/>.
/// If empty, the event always passes condition checks.
/// </summary>
public string Condition { get; set; }
/// <summary>
/// Stable identifier for save/load tracking. If set, becomes the value of
/// <see cref="ExecutableEvent.ID"/> instead of the type name.
/// </summary>
public string ScenarioId { get; set; }
/// <summary>
/// Overrides the time-of-day lighting flag for this event's dialog.
/// <c>true</c> enables time blending, <c>false</c> disables it,
/// <c>null</c> leaves the current setting unchanged.
/// </summary>
public bool? LightUseTime { get; set; }
/// <summary>
/// Returns <see cref="ScenarioId"/> if set; otherwise falls back to the
/// full type name from <see cref="ExecutableEvent.ID"/>.
/// </summary>
public override string ID => ScenarioId ?? base.ID;
protected override void CreateDialog(DialogQueue dlg)
{
if (LightUseTime.HasValue)
dlg.LightUseTime(LightUseTime.Value);
var converter = new ScenarioConverter();
DialogQueue loaded;
if (!string.IsNullOrEmpty(ScenarioText))
{
loaded = converter.Convert(ScenarioText);
}
else
{
loaded = converter.Convert(ScenarioDefs.Resolve(ScenarioId));
}
if (loaded == null)
{
GameLogger.Log("SCENE", $"Failed to load scenario for {GetType().Name}", LogLevel.Error);
return;
}
foreach (var action in loaded.Queue)
dlg.Queue.Add(action);
foreach (var obj in loaded.RuntimeObjectList)
dlg.RuntimeObjectList.Add(obj);
}
private long _cachedVersion = long.MinValue;
private bool _cachedResult;
/// <summary>
/// Evaluates <see cref="Condition"/> through <see cref="ScenarioCondition.Evaluate"/>.
/// Empty conditions always pass. The result is cached and reused until
/// <see cref="ConditionStateMonitor.Version"/> changes, so unchanged world
/// state does not trigger repeated parsing/evaluation of the same condition.
/// </summary>
public override bool CheckConditions
{
get
{
if (string.IsNullOrEmpty(Condition))
return true;
var version = ConditionStateMonitor.Version;
if (_cachedVersion == version)
return _cachedResult;
_cachedResult = ScenarioCondition.Evaluate(Condition);
_cachedVersion = version;
return _cachedResult;
}
}
}
}