This commit is contained in:
cthangover committed 2026-10-19 20:21:01 +03:00
commit 7a088b25a9
58 files changed
+6053

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>An answer of the current question with its resolved outcome (<see cref="Target"/>: scene or action id).</summary>
public sealed class StudioBranch
{
public StudioCodeLine Line { get; set; }
public StudioOutcomeKind Kind { get; set; }
public string Target { get; set; }
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>
/// A scenario that targets the current scene, as the scene composition saw
/// it: owning mod, priority, header condition (DSL, null when there is none) and its result (null when
/// it was not evaluated), and the final
/// status. <see cref="Reason"/> spells out a skip or a shadowing (another
/// mod won the same scenario name) in words.
/// </summary>
public sealed class StudioCandidate
{
public string Name { get; set; }
public StudioModScope Scope { get; set; }
public int Priority { get; set; }
public string Condition { get; set; }
public bool? ConditionOk { get; set; }
public bool IsOneRun { get; set; }
public StudioCandidateStatus Status { get; set; }
public StudioText Reason { get; set; }
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>Outcome of a scene candidate as the dock shows it, the answer to "why did (not) it start".</summary>
public enum StudioCandidateStatus
{
Running,
Queued,
Skipped,
Completed,
Shadowed,
Failed,
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>
/// One line of the scenario shown in the "Now" tab. <see cref="Text"/> is the
/// DSL source for the author mode; the novice mode reads the structure
/// instead: <see cref="Say"/> is what the player sees (a line, a question,
/// an answer) and <see cref="Target"/> where the line points — the label of
/// an option or goto, the name of a label, the scene of a switch, the id of
/// an action. <see cref="StudioMoment"/> follows those links. The flags
/// mirror what the debugger reports: lines already executed, the
/// line the dialog stopped on, the lines it may be moved to, breakpoints, lines added but not yet applied
/// and a parse error message.
/// </summary>
public sealed class StudioCodeLine
{
/// <summary>1-based line number in the scenario text (header included), as in <c>SourceLine</c> of actions.</summary>
public int Number { get; set; }
/// <summary>Number of this line in the text the running dialog was built from; 0 for a line added in the studio.</summary>
public int SourceLine { get; set; }
public string Text { get; set; }
public StudioLineKind Kind { get; set; }
public StudioText Say { get; set; }
public string Target { get; set; }
public bool IsDone { get; set; }
public bool IsCurrent { get; set; }
public bool HasBreakpoint { get; set; }
/// <summary>The running dialog has an action of this line, so the ▶ marker may be dragged here.</summary>
public bool CanRunNext { get; set; }
public bool IsNew { get; set; }
public StudioText Error { get; set; }
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>
/// What a scenario line does, as <see cref="StudioLineReader"/> classifies it.
/// The novice view only shows <see cref="Line"/>, <see cref="Choice"/> and
/// <see cref="Option"/>; the rest is service lines it follows or hides.
/// </summary>
public enum StudioLineKind
{
Header,
Comment,
Light,
Background,
Line,
Choice,
Option,
Label,
Goto,
Jump,
Action,
End,
Command,
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>Category of a studio log entry, shown as its badge.</summary>
public enum StudioLogCategory
{
Scene,
Skip,
Scenario,
Stop,
End,
Edit,
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>One row of the studio event log; <see cref="Time"/> is game-session time as text.</summary>
public sealed class StudioLogEntry
{
public string Time { get; set; }
public StudioLogCategory Category { get; set; }
public StudioText Text { get; set; }
public bool IsWarning { get; set; }
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>A mod together with the way the studio may touch it; rendered as a scope badge.</summary>
public sealed class StudioModScope
{
public string ModId { get; }
public StudioScopeKind Kind { get; }
public StudioModScope(string modId, StudioScopeKind kind)
{
ModId = modId;
Kind = kind;
}
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>
/// Presentation mode of the studio dock. Both modes show the same session;
/// only the "Now" tab renders it differently: <see cref="Author"/> as script
/// text with a gutter, <see cref="Novice"/> as one card per line with plain
/// wording, so a player who never saw the DSL can still follow and extend it.
/// </summary>
public enum StudioMode
{
Author,
Novice,
}
}
+120
View File
@@ -0,0 +1,120 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
/// <summary>
/// What the novice mode shows instead of a listing: the line on screen now
/// (<see cref="Say"/>), the lines said right before it, and — for a
/// question — every answer with the place it leads to. Service lines
/// (lights, labels, <c>end</c>) never appear; an answer's
/// <c>-&gt; :label</c> is followed to the first meaningful line after the
/// label (through <c>goto</c> hops), so <c>option → :exit → switch_scene</c>
/// reads as "goes to the scene". An answer without a target or with a
/// missing label is <see cref="StudioOutcomeKind.Broken"/>. When no dialog
/// runs, the moment is the scenario's first line or question.
/// </summary>
public sealed class StudioMoment
{
public StudioCodeLine Say { get; private set; }
public List<StudioCodeLine> Before { get; } = new();
public List<StudioBranch> Branches { get; } = new();
public bool IsQuestion => Say != null && Say.Kind == StudioLineKind.Choice;
public static StudioMoment Build(StudioSession session)
{
var moment = new StudioMoment();
var lines = session.Lines;
var index = FindAnchor(lines, session.CurrentIndex);
if (index < 0)
return moment;
moment.Say = lines[index];
for (var i = index - 1; i >= 0 && lines[i].Kind == StudioLineKind.Line; i--)
moment.Before.Insert(0, lines[i]);
if (!moment.IsQuestion)
return moment;
for (var next = index + 1; next < lines.Count && lines[next].Kind == StudioLineKind.Option; next++)
moment.Branches.Add(Resolve(lines, lines[next]));
return moment;
}
private static int FindAnchor(List<StudioCodeLine> lines, int current)
{
if (current < 0)
current = 0;
if (lines.Count == 0)
return -1;
if (lines[current].Kind == StudioLineKind.Option)
{
for (var i = current; i >= 0; i--)
{
if (lines[i].Kind == StudioLineKind.Choice)
return i;
}
return -1;
}
for (var i = current; i < lines.Count; i++)
{
if (lines[i].Kind == StudioLineKind.Line || lines[i].Kind == StudioLineKind.Choice)
return i;
}
return -1;
}
private static StudioBranch Resolve(List<StudioCodeLine> lines, StudioCodeLine option)
{
var branch = new StudioBranch { Line = option, Kind = StudioOutcomeKind.Broken };
var label = string.IsNullOrEmpty(option.Target) ? -1 : FindLabel(lines, option.Target);
if (label < 0)
return branch;
var hops = 0;
for (var i = label + 1; i < lines.Count; i++)
{
var line = lines[i];
switch (line.Kind)
{
case StudioLineKind.Goto:
i = string.IsNullOrEmpty(line.Target) ? -1 : FindLabel(lines, line.Target);
if (i < 0 || ++hops > 8)
return branch;
continue;
case StudioLineKind.Jump:
branch.Kind = StudioOutcomeKind.Scene;
branch.Target = line.Target;
return branch;
case StudioLineKind.Action:
branch.Kind = StudioOutcomeKind.Action;
branch.Target = line.Target;
return branch;
case StudioLineKind.End:
branch.Kind = StudioOutcomeKind.End;
return branch;
case StudioLineKind.Line:
case StudioLineKind.Choice:
branch.Kind = StudioOutcomeKind.Continue;
return branch;
}
}
branch.Kind = StudioOutcomeKind.End;
return branch;
}
private static int FindLabel(List<StudioCodeLine> lines, string name)
{
for (var i = 0; i < lines.Count; i++)
{
if (lines[i].Kind == StudioLineKind.Label && lines[i].Target == name)
return i;
}
return -1;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>Role of a scene on the studio map relative to where the player is.</summary>
public enum StudioNodeState
{
Here,
Visited,
Next,
New,
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>Where an answer leads once its label jump is followed, as shown to a novice.</summary>
public enum StudioOutcomeKind
{
Scene,
Action,
End,
Continue,
Broken,
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>The ways the dock can restart what it is debugging (the restart menu, in menu order).</summary>
public enum StudioRestartMode
{
/// <summary>The scenario again from its first line; game state stays as it is now.</summary>
Scenario,
/// <summary>Game state rolled back to the scenario's start, then the scenario again.</summary>
Rollback,
/// <summary>The whole scene re-entered: its composition runs again.</summary>
Scene,
/// <summary>The scenario again from the label above the current line.</summary>
FromLabel,
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>State of the dialog runtime as the dock header shows it.</summary>
public enum StudioRunState
{
/// <summary>A scenario runs freely.</summary>
Running,
/// <summary>Pause is on: the next action will stop before it runs.</summary>
Paused,
/// <summary>Stopped before an action (pause or breakpoint) until continue/step.</summary>
Held,
/// <summary>No scenario dialog is running.</summary>
Idle,
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>A scene on the studio map with the mod that defines it; <see cref="Via"/> names the link (a command or a choice).</summary>
public sealed class StudioSceneNode
{
public string Name { get; set; }
public StudioNodeState State { get; set; }
public StudioModScope Scope { get; set; }
public StudioText Via { get; set; }
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>
/// How the studio may touch content of a mod, shown as the colour and caption
/// of the mod scope badge. Players mostly run zip mods, which are read-only:
/// edits of their scenarios land in the studio layer that overrides them,
/// and the badge pair "layer overrides zip" tells whose content is affected.
/// </summary>
public enum StudioScopeKind
{
/// <summary>Folder mod: edits are written into the mod itself.</summary>
Own,
/// <summary>Zip mod: read-only, edits go to the studio layer over it.</summary>
Overlay,
/// <summary>The studio layer of the current game that holds the player's edits.</summary>
Layer,
/// <summary>Compiled code of a mod: shown, never edited from the studio.</summary>
ReadOnly,
}
}
+66
View File
@@ -0,0 +1,66 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
/// <summary>
/// Everything the dock shows at one moment, built by
/// <see cref="StudioSessionBuilder"/> from the live game: where the player is
/// (scene, scenario, label), the run state, which mod owns the scenario and
/// where edits go, the scenario lines (with pending edits) and debugger
/// marks, the scene candidates, game state, the log and the scene map. A
/// plain view model: pages render it and never change the game through it.
/// </summary>
public sealed class StudioSession
{
public string SceneName { get; set; }
public string ScenarioName { get; set; }
/// <summary>Label the current line belongs to; null before the first label.</summary>
public string LabelName { get; set; }
public StudioRunState RunState { get; set; }
/// <summary>Mod (or layer) that owns the open scenario file; null without a scenario.</summary>
public StudioModScope FileScope { get; set; }
/// <summary>Where edits are written: the file's own folder mod, or the studio layer.</summary>
public StudioModScope EditScope { get; set; }
/// <summary>Unapplied edits of the open scenario.</summary>
public int PendingCount { get; set; }
public List<StudioCodeLine> Lines { get; } = new();
public List<StudioCandidate> Candidates { get; } = new();
public List<StudioStateEntry> State { get; } = new();
public List<StudioLogEntry> Log { get; } = new();
public List<StudioSceneNode> Nodes { get; } = new();
public bool HasScenario => !string.IsNullOrEmpty(ScenarioName);
/// <summary>Index of the line the dialog stands on, or -1.</summary>
public int CurrentIndex => Lines.FindIndex(IsCurrentLine);
/// <summary>
/// Line index (into the scenario text, 0-based) where "insert after the
/// current line" puts a line: right after it, or after the whole answer
/// list of a question — a line between <c>select</c> and its options would
/// cut the question short in the parser. Without a current line: the end.
/// </summary>
public int InsertionTextIndexAfterCurrent()
{
var index = CurrentIndex;
if (index < 0)
return Lines.Count == 0 ? 0 : Lines[^1].Number;
var at = index;
if (Lines[index].Kind == StudioLineKind.Choice)
{
while (at + 1 < Lines.Count && Lines[at + 1].Kind == StudioLineKind.Option)
at++;
}
return Lines[at].Number;
}
private static bool IsCurrentLine(StudioCodeLine line) => line.IsCurrent;
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>How a value of the "State" tab can be changed in place; <see cref="None"/> is read-only.</summary>
public enum StudioStateEditor
{
None,
DialogVariable,
Inventory,
Quest,
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>
/// A key/value of game state in the "State" tab, grouped by its owner (time,
/// quests, inventory…). Groups compare by <see cref="StudioText.Key"/>.
/// <see cref="Id"/> is what <see cref="Editor"/> writes to (a variable name,
/// item id, quest id).
/// </summary>
public sealed class StudioStateEntry
{
public StudioText Group { get; set; }
public StudioText Key { get; set; }
public StudioText Value { get; set; }
public StudioStateEditor Editor { get; set; }
public string Id { get; set; }
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace Cthangover.Mods.ScenarioStudio
{
/// <summary>Tabs of the studio dock body, in the order of the tab row.</summary>
public enum StudioTab
{
Now,
Scene,
State,
Log,
Map,
}
}
+60
View File
@@ -0,0 +1,60 @@
namespace Cthangover.Mods.ScenarioStudio
{
using Cthangover.Core.Utils;
using Godot;
/// <summary>
/// Player-facing text of the studio model kept as a localization key plus
/// format arguments and translated only when shown, so the dock follows a
/// language switch and no prose lives in code. Arguments may themselves be
/// <see cref="StudioText"/> (e.g. a scenario's own <c>key=</c> inside a
/// studio phrase) and are resolved recursively; plain arguments are data —
/// scene, scenario, mod and quest ids, numbers. <see cref="Raw"/> wraps such
/// data where a text is expected.
/// </summary>
public sealed class StudioText
{
private readonly string _raw;
public string Key { get; }
public object[] Args { get; }
public StudioText(string key, params object[] args)
{
Key = key;
Args = args ?? System.Array.Empty<object>();
}
private StudioText(string raw, bool _)
{
_raw = raw;
Args = System.Array.Empty<object>();
}
/// <summary>Untranslated data (an id or a number) used where a text is expected.</summary>
public static StudioText Raw(string value) => new(value, true);
public string Resolve()
{
if (Key == null)
return _raw ?? string.Empty;
var format = TranslationServer.Translate(Key).ToString();
if (Args.Length == 0)
return format;
var resolved = new object[Args.Length];
for (var i = 0; i < Args.Length; i++)
resolved[i] = Args[i] is StudioText text ? text.Resolve() : Args[i];
try
{
return string.Format(format, resolved);
}
catch (System.FormatException)
{
GameLogger.Log("UI", $"Scenario studio: '{Key}' expects other arguments than {Args.Length}", LogLevel.Warning);
return format;
}
}
}
}