diff --git a/schemas/dialog_base.json b/schemas/dialog_base.json index 3b0a698..5f837d7 100644 --- a/schemas/dialog_base.json +++ b/schemas/dialog_base.json @@ -18,6 +18,18 @@ "Key": "key", "Type": "text", "Name": "dialog/schema/field/locale_key" + }, + { + "Key": "who", + "Type": "text" + }, + { + "Key": "duration", + "Type": "number" + }, + { + "Key": "cue", + "Type": "text" } ] }, @@ -39,6 +51,18 @@ "Key": "key", "Type": "text", "Name": "dialog/schema/field/locale_key" + }, + { + "Key": "who", + "Type": "text" + }, + { + "Key": "duration", + "Type": "number" + }, + { + "Key": "cue", + "Type": "text" } ] }, @@ -109,6 +133,14 @@ "Key": "else", "Type": "label", "Name": "dialog/schema/field/else" + }, + { + "Key": "wait", + "Type": "bool" + }, + { + "Key": "timeout", + "Type": "number" } ] }, diff --git a/src/Actions/ScenarioActionContext.cs b/src/Actions/ScenarioActionContext.cs index 36c0ddd..f476052 100644 --- a/src/Actions/ScenarioActionContext.cs +++ b/src/Actions/ScenarioActionContext.cs @@ -27,10 +27,13 @@ namespace Cthangover.Mods.Dialog /// it may not exist at the moment of context creation (e.g. before game data /// is fully loaded). /// - internal class ScenarioActionContext : IActionContext + internal class ScenarioActionContext : IActionContext, IHostedScenarioContext { private readonly DialogRuntime runtime; + /// Host services belong to the runtime that invoked this action. + public IScenarioExecutionHost Host => runtime.Host; + /// /// Service accessor for quest operations. Resolved from the kernel /// quest registry — the implementation (QuestServiceImpl) lives in diff --git a/src/Debugging/BackgroundRunInfo.cs b/src/Debugging/BackgroundRunInfo.cs new file mode 100644 index 0000000..22cd316 --- /dev/null +++ b/src/Debugging/BackgroundRunInfo.cs @@ -0,0 +1,21 @@ +namespace Cthangover.Mods.Dialog +{ + using System.Collections.Generic; + /// A detached UI snapshot; no tool retains mutable actions or a gameplay session. + public sealed class BackgroundRunInfo + { + public string RunId { get; init; } + public string ScenarioId { get; init; } + public int Line { get; init; } + public string Label { get; init; } + public bool Paused { get; init; } + public string Wait { get; init; } + public double Remaining { get; init; } + public string Text { get; init; } + public string Speaker { get; init; } + public string Body { get; init; } + public string Details { get; init; } + public IReadOnlyDictionary Variables { get; init; } + public IReadOnlyDictionary Roles { get; init; } + } +} diff --git a/src/Debugging/BackgroundScenarioInfo.cs b/src/Debugging/BackgroundScenarioInfo.cs new file mode 100644 index 0000000..5bb60e0 --- /dev/null +++ b/src/Debugging/BackgroundScenarioInfo.cs @@ -0,0 +1,15 @@ +namespace Cthangover.Mods.Dialog +{ + /// Catalog source plus a current, human-inspectable admission decision. + public sealed class BackgroundScenarioInfo + { + public string Id { get; init; } + public string ModId { get; init; } + public string Path { get; init; } + public string Map { get; init; } + public string Source { get; init; } + public string Body { get; init; } + public string Status { get; init; } + public string Details { get; init; } + } +} diff --git a/src/Debugging/BackgroundScenarios.cs b/src/Debugging/BackgroundScenarios.cs new file mode 100644 index 0000000..431fbe2 --- /dev/null +++ b/src/Debugging/BackgroundScenarios.cs @@ -0,0 +1,8 @@ +namespace Cthangover.Mods.Dialog +{ + /// Optional background-host capability shared by authoring tools without a dependency on a particular gameplay mod. + public static class BackgroundScenarios + { + public static IBackgroundScenarioTools Provider { get; set; } + } +} diff --git a/src/Debugging/IBackgroundScenarioTools.cs b/src/Debugging/IBackgroundScenarioTools.cs new file mode 100644 index 0000000..f674052 --- /dev/null +++ b/src/Debugging/IBackgroundScenarioTools.cs @@ -0,0 +1,21 @@ +namespace Cthangover.Mods.Dialog +{ + using System.Collections.Generic; + using Godot; + + /// Live authoring seam. Runtime IDs select one execution, scenario IDs select source and breakpoints. + public interface IBackgroundScenarioTools + { + IReadOnlyList Catalog(); + IReadOnlyList Executions(); + IReadOnlyList Events { get; } + bool WorldPaused { get; set; } + void Observe(Node owner, bool enabled); + bool Validate(string modId, string path, string text, out string error); + bool Reload(out string error); + bool Play(string modId, string path, string text, bool company, out string error); + bool Command(string runId, string command, out string error); + bool HasBreakpoint(string scenarioId, int line); + void ToggleBreakpoint(string scenarioId, int line); + } +} diff --git a/src/IHostedScenarioContext.cs b/src/IHostedScenarioContext.cs new file mode 100644 index 0000000..bc8b1ee --- /dev/null +++ b/src/IHostedScenarioContext.cs @@ -0,0 +1,8 @@ +namespace Cthangover.Mods.Dialog +{ + /// Optional action-context capability exposing the execution owner without a global current-scenario singleton. + public interface IHostedScenarioContext + { + IScenarioExecutionHost Host { get; } + } +} diff --git a/src/IScenarioExecutionHost.cs b/src/IScenarioExecutionHost.cs new file mode 100644 index 0000000..1cddc28 --- /dev/null +++ b/src/IScenarioExecutionHost.cs @@ -0,0 +1,19 @@ +namespace Cthangover.Mods.Dialog +{ + using Cthangover.Mods.Dialog.Action.Impls; + + /// Per-execution presentation and wait services. A host never owns another runtime's mutable actions. + public interface IScenarioExecutionHost + { + /// Displays timed speech without occupying the modal DialogBox. + void ShowText(ActionText action, DialogRuntime runtime); + /// Starts a host-clock delay; completion advances only this execution. + void Delay(ActionDelay action, DialogRuntime runtime); + /// Waits for a live condition or its deadline. + void WaitCondition(ActionWaitCondition action, DialogRuntime runtime); + /// Evaluates against this execution's roles and scope. + bool EvaluateCondition(string condition); + /// Releases waits and presentation. Aborts do not count as completed scenarios. + void Finished(bool aborted); + } +} diff --git a/src/Scenarios/Impls/PTextCommandStrategy.cs b/src/Scenarios/Impls/PTextCommandStrategy.cs index fff8331..8c70be8 100644 --- a/src/Scenarios/Impls/PTextCommandStrategy.cs +++ b/src/Scenarios/Impls/PTextCommandStrategy.cs @@ -29,7 +29,7 @@ namespace Cthangover.Mods.Dialog.Scenarios /// public int MaxPositional => 1; /// - public IReadOnlyList AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait" }; + public IReadOnlyList AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait", "who", "duration", "cue" }; /// public void Execute(DialogQueue dlg, List positional, Dictionary named, string arrowTarget, ILocalizationProvider locale) @@ -46,6 +46,17 @@ namespace Cthangover.Mods.Dialog.Scenarios var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true"; var action = dlg.PText(textVal, first, second, hideColor); + action.FallbackText = positional.Count > 0 ? positional[0] : ""; + action.LocaleKey = key; + action.Speaker = named.TryGetValue("who", out var who) ? who : null; + action.Cue = named.TryGetValue("cue", out var cue) ? cue : null; + if (named.TryGetValue("duration", out var duration)) + { + if (!double.TryParse(duration, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds) + || !double.IsFinite(seconds) || seconds <= 0) + throw new System.FormatException("duration must be a positive finite number"); + action.Duration = seconds; + } if (named.TryGetValue("wait", out var wait) && wait == "false") action.WaitType = WaitType.NoWait; diff --git a/src/Scenarios/Impls/TextCommandStrategy.cs b/src/Scenarios/Impls/TextCommandStrategy.cs index 4cd6375..1a2fd63 100644 --- a/src/Scenarios/Impls/TextCommandStrategy.cs +++ b/src/Scenarios/Impls/TextCommandStrategy.cs @@ -29,7 +29,7 @@ namespace Cthangover.Mods.Dialog.Scenarios /// public int MaxPositional => 1; /// - public IReadOnlyList AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait" }; + public IReadOnlyList AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait", "who", "duration", "cue" }; /// public void Execute(DialogQueue dlg, List positional, Dictionary named, string arrowTarget, ILocalizationProvider locale) @@ -46,6 +46,17 @@ namespace Cthangover.Mods.Dialog.Scenarios var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true"; var action = dlg.Text(textVal, first, second, hideColor); + action.FallbackText = positional.Count > 0 ? positional[0] : ""; + action.LocaleKey = key; + action.Speaker = named.TryGetValue("who", out var who) ? who : null; + action.Cue = named.TryGetValue("cue", out var cue) ? cue : null; + if (named.TryGetValue("duration", out var duration)) + { + if (!double.TryParse(duration, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds) + || !double.IsFinite(seconds) || seconds <= 0) + throw new System.FormatException("duration must be a positive finite number"); + action.Duration = seconds; + } if (named.TryGetValue("wait", out var wait) && wait == "false") action.WaitType = WaitType.NoWait; diff --git a/src/Scenarios/Impls/WhenCommandStrategy.cs b/src/Scenarios/Impls/WhenCommandStrategy.cs index 5938e7b..29d4eb5 100644 --- a/src/Scenarios/Impls/WhenCommandStrategy.cs +++ b/src/Scenarios/Impls/WhenCommandStrategy.cs @@ -25,7 +25,7 @@ namespace Cthangover.Mods.Dialog.Scenarios public int MinPositional => 1; /// - public IReadOnlyList AllowedNamed => new[] { "else" }; + public IReadOnlyList AllowedNamed => new[] { "else", "wait", "timeout" }; /// public string JumpTargetNamedKey => "else"; @@ -35,8 +35,21 @@ namespace Cthangover.Mods.Dialog.Scenarios { var condition = string.Join(" ", positional); named.TryGetValue("else", out var otherwise); + if (named.TryGetValue("wait", out var wait) && wait == "true") + { + if (!named.TryGetValue("timeout", out var timeout) || !double.TryParse(timeout, + System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds) + || !double.IsFinite(seconds) || seconds <= 0) + throw new System.FormatException("when wait=true requires a positive finite timeout"); + dlg.Queue.Add(new Cthangover.Mods.Dialog.Action.Impls.ActionWaitCondition + { + ConditionText = condition, Timeout = seconds, + TrueTarget = Label(arrowTarget), TimeoutTarget = Label(otherwise), + }); + return; + } var branch = dlg.IfGoTo(null, Label(arrowTarget), Label(otherwise)); - branch.ScopedCondition = scope => ScenarioCondition.Evaluate(condition, scope); + branch.ConditionText = condition; } private static string Label(string target) => string.IsNullOrWhiteSpace(target) ? null : target.Trim().TrimStart(':'); diff --git a/src/UI/Dialog/Action/Impls/ActionDelay.cs b/src/UI/Dialog/Action/Impls/ActionDelay.cs index 5e4018c..b2375cc 100644 --- a/src/UI/Dialog/Action/Impls/ActionDelay.cs +++ b/src/UI/Dialog/Action/Impls/ActionDelay.cs @@ -32,6 +32,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls /// Shows the optional and, in , hides the dialog body for the pause duration. public override void DoRun(DialogRuntime runtime) { + if (runtime.Host != null) + { + runtime.Host.Delay(this, runtime); + return; + } var dialogBox = runtime.DialogBox; if (dialogBox == null) return; diff --git a/src/UI/Dialog/Action/Impls/ActionIf.cs b/src/UI/Dialog/Action/Impls/ActionIf.cs index c3c37fb..675e26d 100644 --- a/src/UI/Dialog/Action/Impls/ActionIf.cs +++ b/src/UI/Dialog/Action/Impls/ActionIf.cs @@ -23,6 +23,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls { /// Delegate evaluated at runtime. Must return true/false to select the branch target. public Func Condition { get; set; } + /// Source expression evaluated by the runtime's own context, not a captured global role. + public string ConditionText { get; set; } /// /// Used instead of when set: evaluated with the instance scope of the @@ -41,13 +43,14 @@ namespace Cthangover.Mods.Dialog.Action.Impls /// Evaluates and jumps to or ; an empty target falls through to the next action. public override void DoRun(DialogRuntime runtime) { - if (Condition == null && ScopedCondition == null) + if (ConditionText == null && Condition == null && ScopedCondition == null) { GameLogger.Log("DIALOG", "ActionIf: Condition delegate is null", LogLevel.Error); return; } - var result = ScopedCondition != null ? ScopedCondition(runtime?.Dialog?.Scope) : Condition(); + var result = ConditionText != null ? runtime.EvaluateCondition(ConditionText) + : ScopedCondition != null ? ScopedCondition(runtime?.Dialog?.Scope) : Condition(); var goTo = result ? TrueGoTo : FalseGoTo; if (!string.IsNullOrEmpty(goTo)) diff --git a/src/UI/Dialog/Action/Impls/ActionText.cs b/src/UI/Dialog/Action/Impls/ActionText.cs index 8f52ba6..f08374c 100644 --- a/src/UI/Dialog/Action/Impls/ActionText.cs +++ b/src/UI/Dialog/Action/Impls/ActionText.cs @@ -21,6 +21,16 @@ namespace Cthangover.Mods.Dialog.Action.Impls { /// The dialog text to display. Supports ${var} substitution when is true. public string Text { get; set; } + /// Untranslated source retained for live locale changes in hosted speech. + public string FallbackText { get; set; } + /// Stable translation key, independent of speech event cues. + public string LocaleKey { get; set; } + /// Host role naming the concrete speaker. + public string Speaker { get; set; } + /// Optional semantic event emitted when this utterance finishes normally. + public string Cue { get; set; } + /// Explicit host-clock duration; null uses translated text length. + public double? Duration { get; set; } /// Left avatar sprite ID resolved through . Null or empty hides the slot. public string FirstAvatar { get; set; } /// Right avatar sprite ID resolved through . Null or empty hides the slot. @@ -36,6 +46,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls /// Sets the dialog text (optionally variable-substituted) and both avatar slots, then waits for the player's click. public override void DoRun(DialogRuntime runtime) { + if (runtime.Host != null) + { + runtime.Host.ShowText(this, runtime); + return; + } var dialogBox = runtime.DialogBox; if (dialogBox == null) return; diff --git a/src/UI/Dialog/Action/Impls/ActionWaitCondition.cs b/src/UI/Dialog/Action/Impls/ActionWaitCondition.cs new file mode 100644 index 0000000..3468b72 --- /dev/null +++ b/src/UI/Dialog/Action/Impls/ActionWaitCondition.cs @@ -0,0 +1,22 @@ +namespace Cthangover.Mods.Dialog.Action.Impls +{ + /// An explicit asynchronous condition wait; ordinary when remains a one-shot branch. + public class ActionWaitCondition : ActionCommand + { + /// Evaluated against the execution's live context. + public string ConditionText { get; set; } + /// Maximum host-clock wait. + public double Timeout { get; set; } + /// Label selected when the condition becomes true. + public string TrueTarget { get; set; } + /// Label selected on timeout, or fall-through when absent. + public string TimeoutTarget { get; set; } + public override WaitType WaitType { get; set; } = WaitType.WaitEvent; + public override void DoRun(DialogRuntime runtime) + { + if (runtime.Host == null) + runtime.FailExpression("when wait=true requires an execution host", SourceLine); + else runtime.Host.WaitCondition(this, runtime); + } + } +} diff --git a/src/UI/Dialog/DialogRuntime.cs b/src/UI/Dialog/DialogRuntime.cs index 42855d0..cff097e 100644 --- a/src/UI/Dialog/DialogRuntime.cs +++ b/src/UI/Dialog/DialogRuntime.cs @@ -51,6 +51,13 @@ namespace Cthangover.Mods.Dialog /// The currently active dialog queue. Set by . public DialogQueue Dialog { get; set; } + /// Optional per-execution host; null preserves modal dialog behavior. + public IScenarioExecutionHost Host { get; set; } + + /// Conditions use live host roles when present, otherwise the existing instance scope. + public bool EvaluateCondition(string text) => Host != null + ? Host.EvaluateCondition(text) : ScenarioCondition.Evaluate(text, Dialog?.Scope); + /// The action at the current queue index. Automatically constructs it if is OnStartAction and it hasn't been constructed yet. public IActionCommand CurrentAction => GetByIndex(index); @@ -71,6 +78,9 @@ namespace Cthangover.Mods.Dialog /// public static IDialogDebugger Debugger { get; set; } + /// A host may debug its own execution independently of the modal studio hook. + public IDialogDebugger ExecutionDebugger { get; set; } + /// /// True while the debugger holds the runtime before : /// that action has not run yet, so advancing must wait for @@ -276,7 +286,7 @@ namespace Cthangover.Mods.Dialog GameLogger.Log("DIALOG", $"Run: idx={index}/{dialogQueue.Count-1} type={action.GetType().Name} wait={action.WaitType}", LogLevel.Debug); - if (Debugger != null && Debugger.ShouldHold(this, action)) + if ((ExecutionDebugger ?? Debugger)?.ShouldHold(this, action) == true) { IsHeld = true; return; @@ -286,6 +296,11 @@ namespace Cthangover.Mods.Dialog if (runGeneration != generation) return; + if (Host != null && iteration > 256) + { + FailExpression("Hosted scenario exceeded the immediate-action budget"); + return; + } if (iteration++ > 100000) { GameLogger.Log("DIALOG", "Run: infinite loop protection (100k iterations)", LogLevel.Warning); @@ -398,6 +413,7 @@ namespace Cthangover.Mods.Dialog dialogQueue = null; endDialogQueue = null; index = 0; + Host?.Finished(false); if (DialogBox != null) { @@ -427,6 +443,7 @@ namespace Cthangover.Mods.Dialog index = 0; isLastGoTo = false; IsHeld = false; + Host?.Finished(true); GameLogger.Log("DIALOG", "Abort: queue dropped without ending"); }