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;
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
namespace Cthangover.Mods.ScenarioStudio
{
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// "Log" tab: what happened, newest last — scene entries, skipped scenarios
/// with the reason, scenario starts and ends, stops and studio edits.
/// Skips are tinted as warnings because they are the usual answer to
/// "nothing happened".
/// </summary>
public static class StudioLogPage
{
private const int TimeWidth = 44;
public static void Build(VBoxContainer content, StudioSession session)
{
var list = UiKit.Column(0);
content.AddChild(list);
foreach (var entry in session.Log)
{
var plate = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
plate.AddThemeStyleboxOverride("panel", StudioStyles.LogRow(entry.IsWarning));
var row = UiKit.Row(UiTokens.Space3);
plate.AddChild(row);
var time = StudioStyles.Text(entry.Time, UiTokens.FontSmall, UiTokens.Faint, mono: true);
time.CustomMinimumSize = new Vector2(TimeWidth, 0);
row.AddChild(time);
row.AddChild(UiKit.Badge(TranslationServer.Translate(CategoryKey(entry.Category)), StudioStyles.LogColor(entry.Category)));
var text = UiKit.Label(entry.Text.Resolve(), UiTokens.FontBodySmall, entry.IsWarning ? UiTokens.Warning : UiTokens.Body);
text.AutowrapMode = TextServer.AutowrapMode.WordSmart;
text.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(text);
list.AddChild(plate);
}
}
private static string CategoryKey(StudioLogCategory category)
{
switch (category)
{
case StudioLogCategory.Scene:
return "studio/log_scene";
case StudioLogCategory.Skip:
return "studio/log_skip";
case StudioLogCategory.Stop:
return "studio/log_stop";
case StudioLogCategory.End:
return "studio/log_end";
case StudioLogCategory.Edit:
return "studio/log_edit";
default:
return "studio/log_scenario";
}
}
}
}
+118
View File
@@ -0,0 +1,118 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// "Map" tab: the walk between scenes around the player, laid out as a
/// vertical path because the dock is narrow — where the player came from,
/// where they are, and the exits from here with the command or choice that
/// leads there. Exits can be taken directly (a debugging teleport); new
/// scenes are created in the studio layer, the current scenario can be
/// linked to any scene, and the layer can be exported as a regular mod.
/// </summary>
public static class StudioMapPage
{
public static void Build(VBoxContainer content, StudioSession session, Action<StudioActionButton> go, Action<StudioActionButton> newScene, Action<StudioActionButton> link, Action<StudioActionButton> export)
{
var visited = false;
foreach (var node in session.Nodes)
{
if (node.State != StudioNodeState.Visited)
continue;
if (!visited)
content.AddChild(UiKit.SectionTitle(TranslationServer.Translate("studio/map_from"), UiTokens.FontSmall));
visited = true;
content.AddChild(BuildNode(node, go));
content.AddChild(Arrow());
}
if (!visited)
content.AddChild(UiKit.SectionTitle(TranslationServer.Translate("studio/map_here"), UiTokens.FontSmall));
foreach (var node in session.Nodes)
{
if (node.State == StudioNodeState.Here)
content.AddChild(BuildNode(node, null));
}
content.AddChild(UiKit.SectionTitle(TranslationServer.Translate("studio/map_to"), UiTokens.FontSmall));
var exits = 0;
foreach (var node in session.Nodes)
{
if (node.State != StudioNodeState.Next && node.State != StudioNodeState.New)
continue;
content.AddChild(BuildNode(node, go));
exits++;
}
if (exits == 0)
content.AddChild(UiKit.Hint(TranslationServer.Translate("studio/map_no_exits"), UiTokens.FontBodySmall));
var actions = new HFlowContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
actions.AddThemeConstantOverride("h_separation", UiTokens.Space2);
actions.AddThemeConstantOverride("v_separation", UiTokens.Space2);
actions.AddChild(StudioActionButton.Create("studio/map_new_scene", newScene));
if (session.HasScenario)
actions.AddChild(StudioActionButton.Create("studio/map_link", link));
actions.AddChild(StudioActionButton.Create("studio/map_export", export));
content.AddChild(actions);
}
private static Control BuildNode(StudioSceneNode node, Action<StudioActionButton> go)
{
var card = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
card.AddThemeStyleboxOverride("panel", StudioStyles.Node(node.State));
var row = UiKit.Row(UiTokens.Space3);
card.AddChild(row);
var column = UiKit.Column(UiTokens.Space1);
column.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(column);
var head = UiKit.Row(UiTokens.Space3);
var nameColor = node.State == StudioNodeState.Here ? UiTokens.AccentBright : UiTokens.Ink;
head.AddChild(StudioStyles.Text(node.Name, UiTokens.FontBodySmall, nameColor, mono: true));
head.AddChild(StudioStyles.Scope(node.Scope));
column.AddChild(head);
var sub = TranslationServer.Translate(StateKey(node.State));
if (node.Via != null)
sub += " · " + node.Via.Resolve();
column.AddChild(StudioStyles.Text(sub, UiTokens.FontSmall, UiTokens.Faint));
if (node.State != StudioNodeState.Here)
{
var button = StudioActionButton.Create("studio/map_go", go);
button.Payload = node.Name;
button.SizeFlagsVertical = Control.SizeFlags.ShrinkCenter;
row.AddChild(button);
}
return card;
}
private static Control Arrow()
{
var arrow = StudioStyles.Text("↓", UiTokens.FontBody, UiTokens.Faint);
arrow.HorizontalAlignment = HorizontalAlignment.Center;
return arrow;
}
private static string StateKey(StudioNodeState state)
{
switch (state)
{
case StudioNodeState.Here:
return "studio/node_here";
case StudioNodeState.Visited:
return "studio/node_visited";
case StudioNodeState.New:
return "studio/node_new";
default:
return "studio/node_next";
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// "Now" tab in the novice mode: the moment of the story rather than the
/// script (<see cref="StudioMoment"/>) — what is on screen, the lines said
/// right before it, every answer with where it leads, then what else the
/// scene has queued. The only insert actions are the ones that make sense
/// here: a line before the question (or after a plain line) and a new
/// answer, which starts broken until its target is chosen. Requests carry
/// 0-based indices into the scenario text, where the dock inserts. Ends
/// with the same "this change affects" plate as the author mode.
/// </summary>
public sealed class StudioNovicePage
{
/// <summary>A new line is requested at the given text index.</summary>
public event Action<int> LineRequested;
/// <summary>A new answer is requested at the given text index.</summary>
public event Action<int> OptionRequested;
/// <summary>"Edit" on a line, question or answer.</summary>
public event Action<StudioActionButton> EditRequested;
/// <summary>"Choose where" on an answer.</summary>
public event Action<StudioActionButton> TargetRequested;
private StudioMoment _moment;
public void Build(VBoxContainer content, StudioSession session)
{
_moment = StudioMoment.Build(session);
if (_moment.Say == null)
{
content.AddChild(UiKit.EmptyState(TranslationServer.Translate(session.HasScenario ? "studio/moment_empty" : "studio/no_scenario"), UiTokens.FontBody));
return;
}
content.AddChild(StudioStyles.Caption(TranslationServer.Translate("studio/moment_now")));
foreach (var line in _moment.Before)
content.AddChild(new StudioSayView(line, true, OnEdit));
content.AddChild(new StudioSayView(_moment.Say, false, OnEdit));
if (_moment.IsQuestion)
{
content.AddChild(StudioStyles.Caption(TranslationServer.Translate("studio/moment_answers")));
foreach (var branch in _moment.Branches)
content.AddChild(new StudioBranchView(branch, OnEdit, OnTarget));
}
var actions = UiKit.Row(UiTokens.Space2);
actions.AddChild(InsertButton(_moment.IsQuestion ? "studio/add_line_before" : "studio/add_line_after", OnLinePressed));
if (_moment.IsQuestion)
actions.AddChild(InsertButton("studio/add_option", OnOptionPressed));
content.AddChild(actions);
content.AddChild(StudioStyles.Caption(TranslationServer.Translate("studio/moment_next")));
content.AddChild(BuildNext(session));
content.AddChild(StudioNowPage.BuildScopeBanner(session));
}
private static Control BuildNext(StudioSession session)
{
foreach (var candidate in session.Candidates)
{
if (candidate.Status == StudioCandidateStatus.Queued)
return UiKit.Hint(string.Format(TranslationServer.Translate("studio/next_waiting"), candidate.Name), UiTokens.FontBodySmall);
}
return UiKit.Hint(TranslationServer.Translate("studio/next_none"), UiTokens.FontBodySmall);
}
private static Button InsertButton(string key, Action handler)
{
var button = StudioStyles.SmallButton(new Button(), TranslationServer.Translate(key));
button.Pressed += handler;
return button;
}
private void OnLinePressed()
{
var say = _moment.Say.Number - 1;
LineRequested?.Invoke(_moment.IsQuestion ? say : say + 1);
}
private void OnOptionPressed()
{
var last = _moment.Branches.Count > 0 ? _moment.Branches[^1].Line : _moment.Say;
OptionRequested?.Invoke(last.Number);
}
private void OnEdit(StudioActionButton button) => EditRequested?.Invoke(button);
private void OnTarget(StudioActionButton button) => TargetRequested?.Invoke(button);
}
}
+197
View File
@@ -0,0 +1,197 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// "Now" tab in the author mode: the running scenario as script with a
/// gutter (<see cref="StudioCodeLineView"/>) and the insert bar for a line
/// after the current one. It ends with the "this change affects" plate
/// naming the mod that receives the edit and the mod it overrides, so
/// nobody changes someone else's content unknowingly; the novice mode
/// (<see cref="StudioNovicePage"/>) shows the same plate.
/// <para/>
/// Dragging the ▶ marker of the current line highlights the line under the
/// mouse when the running dialog can continue from it
/// (<see cref="StudioCodeLine.CanRunNext"/>) and on release asks for it
/// with <see cref="NextLineRequested"/>; a drop anywhere else does nothing.
/// <para/>
/// One instance lives as long as the dock; <see cref="Build"/> refills the
/// body and wires the controls to this instance's handlers.
/// </summary>
public sealed class StudioNowPage
{
/// <summary>A gutter click changed a breakpoint.</summary>
public event Action<StudioCodeLine> BreakpointToggled;
/// <summary>One of the insert buttons was pressed.</summary>
public event Action<StudioLineKind> InsertRequested;
/// <summary>The ▶ marker was dropped on a line the dialog can continue from.</summary>
public event Action<StudioCodeLine> NextLineRequested;
private readonly List<StudioCodeLineView> _views = new();
private StudioCodeLineView _dropTarget;
private bool _dragging;
/// <summary>True while the ▶ marker is dragged: a redraw would drop the drag.</summary>
public bool IsDragging => _dragging;
public void Build(VBoxContainer content, StudioSession session)
{
_views.Clear();
_dropTarget = null;
_dragging = false;
if (!session.HasScenario)
{
content.AddChild(UiKit.EmptyState(TranslationServer.Translate("studio/no_scenario"), UiTokens.FontBody));
return;
}
var well = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
well.AddThemeStyleboxOverride("panel", StudioStyles.CodeWell());
content.AddChild(well);
var lines = UiKit.Column(0);
well.AddChild(lines);
foreach (var line in session.Lines)
{
var view = new StudioCodeLineView(line);
view.BreakpointToggled += OnBreakpointToggled;
view.MarkerDragged += OnMarkerDragged;
view.MarkerDropped += OnMarkerDropped;
lines.AddChild(view);
_views.Add(view);
}
var at = session.InsertionTextIndexAfterCurrent();
var caption = at <= 0 ? string.Empty : string.Format(TranslationServer.Translate("studio/after_line"), at);
content.AddChild(BuildInsertBar(caption));
content.AddChild(BuildScopeBanner(session));
}
private Control BuildInsertBar(string caption)
{
var bar = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
bar.AddThemeStyleboxOverride("panel", StudioStyles.InsertBar());
var row = new HFlowContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
row.AddThemeConstantOverride("h_separation", UiTokens.Space2);
row.AddThemeConstantOverride("v_separation", UiTokens.Space2);
bar.AddChild(row);
if (!string.IsNullOrEmpty(caption))
{
var label = StudioStyles.Text(caption, UiTokens.FontSmall, UiTokens.Faint);
label.VerticalAlignment = VerticalAlignment.Center;
label.SizeFlagsVertical = Control.SizeFlags.ShrinkCenter;
row.AddChild(label);
}
row.AddChild(InsertButton("studio/add_line", OnAddLine));
row.AddChild(InsertButton("studio/add_choice", OnAddChoice));
row.AddChild(InsertButton("studio/add_jump", OnAddJump));
return bar;
}
private static Button InsertButton(string key, Action handler)
{
var button = StudioStyles.SmallButton(new Button(), TranslationServer.Translate(key));
button.Pressed += handler;
return button;
}
/// <summary>
/// Inline editor of the whole scenario text (the footer's "Edit text"),
/// filling the body; the dock reads the text back when editing ends.
/// </summary>
public static CodeEdit BuildEditor(VBoxContainer content, string text)
{
var editor = new CodeEdit
{
Text = text ?? string.Empty,
GuttersDrawLineNumbers = true,
HighlightCurrentLine = true,
WrapMode = TextEdit.LineWrappingMode.None,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(0, 560),
SyntaxHighlighter = StudioSyntax.CreateHighlighter(),
};
editor.AddThemeFontOverride("font", StudioStyles.Mono);
editor.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
content.AddChild(editor);
return editor;
}
/// <summary>"This change affects" plate: the edit scope and, when it differs, the mod it overrides.</summary>
public static Control BuildScopeBanner(StudioSession session)
{
var banner = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
banner.AddThemeStyleboxOverride("panel", StudioStyles.ScopeBanner());
var column = UiKit.Column(UiTokens.Space2);
banner.AddChild(column);
var title = StudioStyles.Text(TranslationServer.Translate("studio/affects"), UiTokens.FontTiny, UiTokens.Warning);
title.Uppercase = true;
column.AddChild(title);
var row = new HFlowContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
row.AddThemeConstantOverride("h_separation", UiTokens.Space3);
column.AddChild(row);
if (session.EditScope == null)
return banner;
row.AddChild(StudioStyles.Scope(session.EditScope));
if (session.EditScope.ModId != session.FileScope.ModId)
{
row.AddChild(StudioStyles.Text(TranslationServer.Translate("studio/overrides"), UiTokens.FontSmall, UiTokens.Faint));
row.AddChild(StudioStyles.Scope(session.FileScope));
}
return banner;
}
private void OnBreakpointToggled(StudioCodeLine line) => BreakpointToggled?.Invoke(line);
private void OnMarkerDragged(Vector2 at)
{
_dragging = true;
var target = DropTargetAt(at);
if (target == _dropTarget)
return;
_dropTarget?.ShowDropTarget(false);
_dropTarget = target;
_dropTarget?.ShowDropTarget(true);
}
private void OnMarkerDropped(Vector2 at)
{
_dragging = false;
_dropTarget?.ShowDropTarget(false);
_dropTarget = null;
var target = DropTargetAt(at);
if (target != null)
NextLineRequested?.Invoke(target.Line);
}
/// <summary>The row under <paramref name="at"/> (canvas coordinates) the dialog can continue from, or null.</summary>
private StudioCodeLineView DropTargetAt(Vector2 at)
{
foreach (var view in _views)
{
if (view.Line.CanRunNext && view.GetGlobalRect().HasPoint(at))
return view;
}
return null;
}
private void OnAddLine() => InsertRequested?.Invoke(StudioLineKind.Line);
private void OnAddChoice() => InsertRequested?.Invoke(StudioLineKind.Choice);
private void OnAddJump() => InsertRequested?.Invoke(StudioLineKind.Jump);
}
}
+103
View File
@@ -0,0 +1,103 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// "Scene" tab: every scenario the last composition of the current scene
/// considered, in start order, with the owning mod, priority, header
/// condition and its result, and the status with the reason in words. This
/// is the answer to "why did my scenario not start": a false condition, a
/// one-run scenario already completed, or another mod winning the same
/// scenario name. Any candidate can be started now; a completed one can be
/// made fresh again; a new scenario for the scene goes to the studio layer.
/// </summary>
public static class StudioScenePage
{
public static void Build(VBoxContainer content, StudioSession session, Action<StudioActionButton> run, Action<StudioActionButton> reset, Action<StudioActionButton> create)
{
var hint = UiKit.Hint(TranslationServer.Translate("studio/cand_hint"));
hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
content.AddChild(hint);
if (session.Candidates.Count == 0)
content.AddChild(UiKit.EmptyState(TranslationServer.Translate("studio/cand_none"), UiTokens.FontBody));
foreach (var candidate in session.Candidates)
content.AddChild(BuildCard(candidate, run, reset));
var add = StudioActionButton.Create("studio/new_scenario", create);
add.Payload = session.SceneName;
add.SizeFlagsHorizontal = Control.SizeFlags.ShrinkBegin;
content.AddChild(add);
}
private static Control BuildCard(StudioCandidate candidate, Action<StudioActionButton> run, Action<StudioActionButton> reset)
{
var running = candidate.Status == StudioCandidateStatus.Running;
var card = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
card.AddThemeStyleboxOverride("panel", StudioStyles.Candidate(running));
var column = UiKit.Column(UiTokens.Space2);
card.AddChild(column);
var head = UiKit.Row(UiTokens.Space3);
head.AddChild(StudioStyles.Text(candidate.Name, UiTokens.FontBody, UiTokens.InkBright, mono: true));
head.AddChild(UiKit.Spacer());
head.AddChild(UiKit.Badge(TranslationServer.Translate(StatusKey(candidate.Status)), StudioStyles.StatusColor(candidate.Status)));
column.AddChild(head);
var meta = new HFlowContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
meta.AddThemeConstantOverride("h_separation", UiTokens.Space3);
meta.AddChild(StudioStyles.Scope(candidate.Scope));
meta.AddChild(StudioStyles.Text(string.Format(TranslationServer.Translate("studio/priority"), candidate.Priority), UiTokens.FontSmall, UiTokens.Muted, mono: true));
var condition = candidate.Condition ?? TranslationServer.Translate("studio/no_condition");
var mark = candidate.ConditionOk == null ? "" : candidate.ConditionOk.Value ? "✓ " : "✗ ";
var conditionColor = candidate.ConditionOk == false ? UiTokens.DangerText : candidate.ConditionOk == true ? UiTokens.SuccessText : UiTokens.Muted;
meta.AddChild(StudioStyles.Text(mark + condition, UiTokens.FontSmall, conditionColor, mono: true));
if (candidate.IsOneRun)
meta.AddChild(StudioStyles.Text(TranslationServer.Translate("studio/one_run"), UiTokens.FontSmall, UiTokens.Muted));
column.AddChild(meta);
var reason = UiKit.Label(candidate.Reason?.Resolve() ?? string.Empty, UiTokens.FontSmall, UiTokens.Muted);
reason.AutowrapMode = TextServer.AutowrapMode.WordSmart;
column.AddChild(reason);
if (running || candidate.Status == StudioCandidateStatus.Shadowed)
return card;
var actions = UiKit.Row(UiTokens.Space2);
var start = StudioActionButton.Create("studio/run_now", run);
start.Payload = candidate.Name;
actions.AddChild(start);
if (candidate.Status == StudioCandidateStatus.Completed)
{
var fresh = StudioActionButton.Create("studio/reset_completed", reset);
fresh.Payload = candidate.Name;
actions.AddChild(fresh);
}
column.AddChild(actions);
return card;
}
private static string StatusKey(StudioCandidateStatus status)
{
switch (status)
{
case StudioCandidateStatus.Running:
return "studio/cand_running";
case StudioCandidateStatus.Queued:
return "studio/cand_queued";
case StudioCandidateStatus.Skipped:
return "studio/cand_skipped";
case StudioCandidateStatus.Completed:
return "studio/cand_completed";
case StudioCandidateStatus.Failed:
return "studio/cand_failed";
default:
return "studio/cand_shadowed";
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// "State" tab: game state grouped by owner — time, dialog variables of the
/// running dialog, quests the current scenario refers to, inventory and the
/// one-run completion history. Variables, item counts and quest status are
/// editable (Enter writes them into the game), because changing one is the
/// quickest way to test another branch of a scenario.
/// </summary>
public static class StudioStatePage
{
public static void Build(VBoxContainer content, StudioSession session, Action<StudioStateEntry, string> submitted)
{
var hint = UiKit.Hint(TranslationServer.Translate("studio/state_hint"));
hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
content.AddChild(hint);
string group = null;
VBoxContainer rows = null;
foreach (var entry in session.State)
{
if (entry.Group.Key != group)
{
group = entry.Group.Key;
content.AddChild(UiKit.SectionTitle(entry.Group.Resolve(), UiTokens.FontSmall));
var well = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
well.AddThemeStyleboxOverride("panel", StudioStyles.Well());
content.AddChild(well);
rows = UiKit.Column(UiTokens.Space2);
well.AddChild(rows);
}
var row = new StudioStateRow(entry);
row.Submitted += submitted;
rows.AddChild(row);
}
}
}
}
+194
View File
@@ -0,0 +1,194 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using System.Collections.Generic;
using Cthangover.Mods.Dialog;
using Cthangover.Mods.Dialog.Action;
using Cthangover.Mods.Dialog.Executable;
/// <summary>
/// The studio's <see cref="IDialogDebugger"/>: pause, step and breakpoints
/// for scenario dialogs. Only dialogs shown in the <see cref="DialogBox"/>
/// with a <see cref="ScenarioEvent"/> as locker are held — standalone
/// runtimes (hover commands) and click dialogs cannot be resumed and always
/// run through. Breakpoints are keyed by scenario name, the identity that
/// survives an edit moving the file into the studio layer.
/// <para/>
/// "Pause" holds the dialog before the next action; "continue" and "step"
/// let exactly one action pass the hold (<c>allowOnce</c>), step then holds
/// again. The first action of every new scenario event raises
/// <see cref="ScenarioStarted"/> before anything runs, which is when the
/// rollback snapshot is taken.
/// </summary>
public sealed class StudioDebugger : IDialogDebugger
{
private readonly Dictionary<string, HashSet<int>> _breakpoints = new();
private readonly HashSet<int> _executed = new();
private int _allowOnce;
/// <summary>The scenario event the debugger last saw run.</summary>
public ScenarioEvent Event { get; private set; }
public bool IsPaused { get; private set; }
/// <summary>Scenario line the dialog is held at, 0 when not held.</summary>
public int HeldLine { get; private set; }
/// <summary>Raised before the first action of a new scenario event runs.</summary>
public event Action<ScenarioEvent> ScenarioStarted;
/// <summary>Raised when the dialog is held at a line (pause or breakpoint).</summary>
public event Action<int, bool> Held;
/// <summary>Raised after any change worth redrawing (an action ran, a hold, a toggle).</summary>
public event Action Changed;
public bool ShouldHold(DialogRuntime runtime, IActionCommand action)
{
if (runtime.DialogBox == null || runtime.DialogBox.Locker is not ScenarioEvent scenario)
return false;
if (scenario != Event)
{
Event = scenario;
_executed.Clear();
HeldLine = 0;
ScenarioStarted?.Invoke(scenario);
}
if (_allowOnce > 0)
{
_allowOnce--;
return Pass(action);
}
var breakpoint = action.SourceLine > 0 && HasBreakpoint(scenario.ID, action.SourceLine);
if (!IsPaused && !breakpoint)
return Pass(action);
IsPaused = true;
HeldLine = action.SourceLine;
Held?.Invoke(action.SourceLine, breakpoint);
Changed?.Invoke();
return true;
}
public bool IsExecuted(int line) => _executed.Contains(line);
public bool HasBreakpoint(string scenario, int line) =>
scenario != null && _breakpoints.TryGetValue(scenario, out var lines) && lines.Contains(line);
public void ToggleBreakpoint(string scenario, int line)
{
if (string.IsNullOrEmpty(scenario))
return;
if (!_breakpoints.TryGetValue(scenario, out var lines))
_breakpoints[scenario] = lines = new HashSet<int>();
if (!lines.Remove(line))
lines.Add(line);
Changed?.Invoke();
}
/// <summary>Turns pause on: the next action of a scenario dialog will be held.</summary>
public void Pause()
{
IsPaused = true;
Changed?.Invoke();
}
/// <summary>
/// Turns pause off and lets the held action run (<paramref name="box"/>
/// resumes it); without a hold it only clears the pause.
/// </summary>
public void Continue(DialogBox box)
{
IsPaused = false;
ReleaseHold(box);
Changed?.Invoke();
}
/// <summary>
/// Runs exactly one action and holds before the next. When the dialog is
/// not held but waits for a click, the click is made for the user.
/// </summary>
public void Step(DialogBox box)
{
IsPaused = true;
if (box == null || !box.IsRunning)
return;
if (box.Runtime.IsHeld)
{
ReleaseHold(box);
}
else if (!box.IsWaitingAnswer)
{
_allowOnce = 1;
box.NextAction();
}
Changed?.Invoke();
}
/// <summary>
/// "Set next statement" of an IDE: the action of scenario line
/// <paramref name="line"/> becomes the next one to run and the dialog is
/// held before it in pause, whether it was held, waited for a click or
/// for an answer. Skipped and re-run lines change nothing in the game
/// state on their own. False when the line has no action in the queue
/// of the running dialog (a comment, an answer, a line added in the studio).
/// </summary>
public bool SetNext(DialogBox box, int line)
{
var queue = box?.Runtime.Dialog?.Queue;
if (line <= 0 || queue == null || !box.IsRunning || box.Locker != Event)
return false;
var target = IndexOf(queue, line);
if (target < 0 || !box.MoveTo(target))
return false;
IsPaused = true;
HeldLine = line;
_allowOnce = 0;
Changed?.Invoke();
return true;
}
/// <summary>Forgets the current event, e.g. before restarting it.</summary>
public void Reset()
{
Event = null;
HeldLine = 0;
_allowOnce = 0;
_executed.Clear();
}
/// <summary>Queue index of the first action built from scenario line <paramref name="line"/>, -1 when none.</summary>
public static int IndexOf(List<IActionCommand> queue, int line)
{
for (var i = 0; i < queue.Count; i++)
{
if (queue[i]?.SourceLine == line)
return i;
}
return -1;
}
private void ReleaseHold(DialogBox box)
{
if (box == null || !box.Runtime.IsHeld)
return;
HeldLine = 0;
_allowOnce = 1;
box.Resume();
}
private bool Pass(IActionCommand action)
{
if (action.SourceLine > 0)
_executed.Add(action.SourceLine);
Changed?.Invoke();
return false;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
using Cthangover.Core.Events;
using Cthangover.Core.Scenes;
using Godot;
/// <summary>
/// What happened in the session, for the "Log", "Scene" and "Map" tabs:
/// log entries (scene entries, skipped candidates with the reason, scenario
/// starts and ends, breakpoints, studio edits), the order of visited scenes
/// and the last composition report of every scene. Fed by the mod event
/// bus and by <see cref="StudioLive"/>; times are session time (mm:ss since
/// the studio started).
/// </summary>
public sealed class StudioJournal
{
private const int MaxEntries = 300;
private readonly ulong _startMsec = Time.GetTicksMsec();
private readonly Dictionary<string, SceneComposedEvent> _composed = new();
public List<StudioLogEntry> Entries { get; } = new();
/// <summary>Scenes in the order they were entered (repeats included).</summary>
public List<string> SceneHistory { get; } = new();
public void Add(StudioLogCategory category, StudioText text, bool warning = false)
{
Entries.Add(new StudioLogEntry { Time = Now(), Category = category, Text = text, IsWarning = warning });
if (Entries.Count > MaxEntries)
Entries.RemoveAt(0);
}
/// <summary>Last composition report of <paramref name="scene"/>, or null.</summary>
public SceneComposedEvent Composed(string scene) =>
scene != null && _composed.TryGetValue(scene, out var report) ? report : null;
public void OnSceneEntered(SceneEnteredEvent e)
{
SceneHistory.Add(e.SceneName);
Add(StudioLogCategory.Scene, new StudioText("studio/log_text_enter", e.SceneName));
}
public void OnSceneComposed(SceneComposedEvent e)
{
_composed[e.SceneName] = e;
foreach (var candidate in e.Candidates)
{
switch (candidate.Outcome)
{
case SceneCandidateOutcome.ConditionFalse:
Add(StudioLogCategory.Skip, new StudioText("studio/log_text_condition", candidate.Id), true);
break;
case SceneCandidateOutcome.Completed:
Add(StudioLogCategory.Skip, new StudioText("studio/log_text_completed", candidate.Id), true);
break;
case SceneCandidateOutcome.Shadowed:
Add(StudioLogCategory.Skip, new StudioText("studio/log_text_shadowed", candidate.Id, candidate.ModId), true);
break;
case SceneCandidateOutcome.Failed:
Add(StudioLogCategory.Skip, new StudioText("studio/log_text_failed", candidate.Id), true);
break;
}
}
}
public void OnDialogEnded(DialogEndedEvent e)
{
if (!string.IsNullOrEmpty(e.LockerId))
Add(StudioLogCategory.End, new StudioText("studio/log_text_ended", e.LockerId));
}
private string Now()
{
var seconds = (Time.GetTicksMsec() - _startMsec) / 1000;
return $"{seconds / 60:00}:{seconds % 60:00}";
}
}
}
+161
View File
@@ -0,0 +1,161 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
using System.IO;
using System.Text;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Games;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Providers;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Godot;
/// <summary>
/// The studio's edit layer: a folder per game (<c>user://studio/&lt;game&gt;/</c>,
/// or <c>--studio-layer=&lt;dir&gt;</c>) laid out like a mod (<c>scenarios/</c>,
/// <c>scenes/</c>) and mounted as content overlay <see cref="Id"/> through
/// <see cref="ModOverlays"/>. Scenarios written here override same-named
/// scenarios of any mod, so a player can change content of read-only zip
/// mods without touching them; new scenes and scenarios live here too.
/// <para/>
/// Edits of folder mods go into the mod itself (the author's workflow)
/// unless <c>--studio-write=layer</c> forces the layer — tests use it to
/// keep the repository untouched. <see cref="Export"/> turns the layer into
/// a regular mod in <c>user://mods/</c>.
/// </summary>
public sealed class StudioLayer
{
public const string Id = "my_studio";
private const string LayerArg = "--studio-layer=";
private const string WriteArg = "--studio-write=layer";
/// <summary>Absolute path of the layer folder.</summary>
public string RootPath { get; }
/// <summary>True when every edit goes to the layer, folder mods included.</summary>
public bool ForceLayer { get; }
public StudioLayer(string[] args)
{
string dir = null;
foreach (var arg in args)
{
if (arg.StartsWith(LayerArg, System.StringComparison.Ordinal))
dir = arg.Substring(LayerArg.Length);
if (arg == WriteArg)
ForceLayer = true;
}
dir ??= "user://studio";
var game = string.IsNullOrEmpty(ActiveGame.Id) ? "dev" : ActiveGame.Id;
RootPath = Path.Combine(ProjectSettings.GlobalizePath(dir), game);
}
/// <summary>Creates the folder if needed, mounts it as overlay and refreshes scenario and scene tables.</summary>
public void Mount()
{
Directory.CreateDirectory(Path.Combine(RootPath, "scenarios"));
Directory.CreateDirectory(Path.Combine(RootPath, "scenes"));
ModOverlays.Mount(Id, new FolderModFileProvider(RootPath, Id));
RefreshContent();
GameLogger.Log("UI", $"Scenario studio: layer '{Id}' mounted at {RootPath}");
}
/// <summary>Writes a layer file (mod-relative path) and returns its absolute path.</summary>
public string Write(string relativePath, string text)
{
var path = Path.Combine(RootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, text);
return path;
}
/// <summary>
/// Creates scene <paramref name="name"/> with the given backgrounds and a
/// first scenario for it; returns the scenario name.
/// </summary>
public string CreateScene(string name, IReadOnlyList<string> backgrounds, string firstLine)
{
var json = new StringBuilder("{\n \"name\": \"").Append(name).Append('"');
if (backgrounds != null && backgrounds.Count > 0)
{
json.Append(",\n \"defaultBackground\": [");
for (var i = 0; i < backgrounds.Count; i++)
json.Append(i == 0 ? "" : ", ").Append('"').Append(backgrounds[i]).Append('"');
json.Append(']');
}
json.Append("\n}\n");
Write($"scenes/{name}.json", json.ToString());
return CreateScenario(name, firstLine);
}
/// <summary>Creates a new scenario for scene <paramref name="scene"/> with one line; returns its name.</summary>
public string CreateScenario(string scene, string firstLine)
{
var name = scene + "_studio";
for (var n = 2; File.Exists(ScenarioFile(name)); n++)
name = $"{scene}_studio_{n}";
Write($"scenarios/{name}.scenario", $"scene: {scene}\npriority: 50\n---\n{firstLine}\nend\n");
RefreshContent();
return name;
}
/// <summary>Scene names defined by the layer itself.</summary>
public List<string> SceneNames()
{
var result = new List<string>();
var dir = Path.Combine(RootPath, "scenes");
if (!Directory.Exists(dir))
return result;
foreach (var file in Directory.GetFiles(dir, "*.json"))
result.Add(Path.GetFileNameWithoutExtension(file));
return result;
}
/// <summary>
/// Copies the layer into <c>user://mods/&lt;modId&gt;/</c> with a manifest, so
/// the edits become a regular mod that can be added to a game's collection.
/// Returns the folder written.
/// </summary>
public string Export(string modId)
{
var target = Path.Combine(ProjectSettings.GlobalizePath("user://mods/"), modId);
CopyTree(RootPath, target);
var manifest = "{\n" +
$" \"id\": \"{modId}\",\n" +
$" \"name\": \"{modId}\",\n" +
" \"version\": \"0.1.0\",\n" +
" \"author\": \"studio\",\n" +
" \"description\": \"Exported from the scenario studio layer.\",\n" +
" \"category\": \"content\",\n" +
" \"depends\": [\"dialog_base>=1\"]\n" +
"}\n";
File.WriteAllText(Path.Combine(target, "manifest.json"), manifest);
GameLogger.Log("UI", $"Scenario studio: layer exported as mod '{modId}' to {target}");
return target;
}
/// <summary>Makes the next scene composition and the scene table see changed scenario and scene files.</summary>
public static void RefreshContent()
{
ScenarioDefs.Invalidate();
ModManager.Instance.GetResolver<SceneDefinitionResolver>()?.ClearCache();
AppServices.Current.SceneDefs.Clear();
SceneManager.RescanScenes();
}
private string ScenarioFile(string name) => Path.Combine(RootPath, "scenarios", name + ".scenario");
private static void CopyTree(string from, string to)
{
Directory.CreateDirectory(to);
foreach (var file in Directory.GetFiles(from))
File.Copy(file, Path.Combine(to, Path.GetFileName(file)), true);
foreach (var dir in Directory.GetDirectories(from))
CopyTree(dir, Path.Combine(to, Path.GetFileName(dir)));
}
}
}
+151
View File
@@ -0,0 +1,151 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
using Cthangover.Mods.Dialog.Scenarios;
/// <summary>
/// Reads scenario text into <see cref="StudioCodeLine"/>s for the dock: one
/// entry per non-empty line, numbered like the parser numbers
/// <c>SourceLine</c> (1-based, header included), classified by command
/// with the player-visible text (<see cref="StudioCodeLine.Say"/>: the
/// literal, or the line's own <c>key=</c> translated when shown) and the
/// line's target. Lines are split by <see cref="ScenarioParser.SplitLine"/>
/// so the dock never disagrees with the engine; on top of the parser it
/// reports what the parser silently skips — unknown commands, answers and
/// jumps to missing labels.
/// </summary>
public static class StudioLineReader
{
public static List<StudioCodeLine> Read(string text)
{
var result = new List<StudioCodeLine>();
if (string.IsNullOrEmpty(text))
return result;
var lines = text.Replace("\r\n", "\n").Split('\n');
var headerEnd = HeaderEnd(lines);
var labels = new HashSet<string>();
for (var i = 0; i < lines.Length; i++)
{
var trimmed = lines[i].Trim();
if (trimmed.Length == 0)
continue;
var line = new StudioCodeLine { Number = i + 1, Text = lines[i].TrimEnd() };
if (i <= headerEnd)
line.Kind = StudioLineKind.Header;
else
Classify(line, trimmed);
if (line.Kind == StudioLineKind.Label)
labels.Add(line.Target);
result.Add(line);
}
foreach (var line in result)
CheckTarget(line, labels);
return result;
}
private static int HeaderEnd(string[] lines)
{
for (var i = 0; i < lines.Length; i++)
{
if (lines[i].Trim() == "---")
return i;
}
return -1;
}
private static void Classify(StudioCodeLine line, string trimmed)
{
if (trimmed.StartsWith('#'))
{
line.Kind = StudioLineKind.Comment;
return;
}
if (trimmed.StartsWith(':'))
{
line.Kind = StudioLineKind.Label;
line.Target = trimmed.Substring(1).Trim();
return;
}
var positional = new List<string>();
var named = new Dictionary<string, string>();
var cmd = ScenarioParser.SplitLine(trimmed, positional, named, out var arrow);
switch (cmd)
{
case "text":
case "ptext":
line.Kind = StudioLineKind.Line;
line.Say = SayOf(positional, named);
return;
case "select":
line.Kind = StudioLineKind.Choice;
line.Say = SayOf(positional, named);
return;
case "option":
line.Kind = StudioLineKind.Option;
line.Say = SayOf(positional, named);
line.Target = OptionTarget(positional, named, arrow);
return;
case "goto":
line.Kind = StudioLineKind.Goto;
line.Target = !string.IsNullOrEmpty(arrow) ? arrow : named.GetValueOrDefault("target");
return;
case "switch_scene":
line.Kind = StudioLineKind.Jump;
line.Target = positional.Count > 0 ? positional[0] : null;
return;
case "action":
line.Kind = StudioLineKind.Action;
line.Target = positional.Count > 0 ? positional[0] : null;
return;
case "end":
line.Kind = StudioLineKind.End;
return;
case "light_set":
case "light_use_time":
line.Kind = StudioLineKind.Light;
return;
case "background":
case "foreground":
line.Kind = StudioLineKind.Background;
return;
}
line.Kind = StudioLineKind.Command;
if (cmd != null && ScenarioCommandStrategyFactory.Get(cmd) == null)
line.Error = new StudioText("studio/error_unknown_command", cmd);
}
private static StudioText SayOf(List<string> positional, Dictionary<string, string> named)
{
if (named.TryGetValue("key", out var key) && !string.IsNullOrEmpty(key))
return new StudioText(key);
return positional.Count > 0 ? StudioText.Raw(positional[0]) : null;
}
private static string OptionTarget(List<string> positional, Dictionary<string, string> named, string arrow)
{
if (!string.IsNullOrEmpty(arrow))
return arrow;
if (named.TryGetValue("goto", out var target))
return target;
return positional.Count > 1 && positional[1].StartsWith(':') ? positional[1].TrimStart(':') : null;
}
private static void CheckTarget(StudioCodeLine line, HashSet<string> labels)
{
if (line.Kind != StudioLineKind.Option && line.Kind != StudioLineKind.Goto)
return;
if (string.IsNullOrEmpty(line.Target))
line.Error ??= new StudioText("studio/error_no_target");
else if (!labels.Contains(line.Target))
line.Error ??= new StudioText("studio/error_label_missing", ":" + line.Target);
}
}
}
+571
View File
@@ -0,0 +1,571 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Events;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Quests;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.Utils;
using Cthangover.Mods.Dialog;
using Cthangover.Mods.Dialog.Executable;
using Godot;
/// <summary>
/// The studio's connection to the running game, parked at <c>/root</c> for
/// the whole session (the dock comes and goes, this stays). It installs
/// the <see cref="StudioDebugger"/> into the dialog runtime, keeps the
/// <see cref="StudioJournal"/> fed from the event bus, owns the edit
/// <see cref="StudioLayer"/>, the per-scenario <see cref="StudioWorkingCopy"/>s
/// and the rollback <see cref="StudioSnapshot"/> of the running scenario, and
/// performs every action of the dock: apply edits, the restart modes, run a
/// scene candidate, go to a scene, create scenes and scenarios, change
/// state values, export the layer.
/// <para/>
/// <see cref="Version"/> grows on every change the dock may want to show;
/// the dock polls it instead of subscribing, so a burst of dialog actions
/// costs one redraw.
/// </summary>
public partial class StudioLive : Node
{
private const string ChainGroup = "main_event_chain";
private static readonly Regex ModIdPattern = new("^[a-z][a-z0-9_]*$");
private readonly Dictionary<string, StudioWorkingCopy> _copies = new();
private StudioSnapshot _snapshot;
private StudioSnapshot _checkpoint;
public static StudioLive Instance { get; private set; }
public StudioDebugger Debugger { get; } = new();
public StudioJournal Journal { get; } = new();
public StudioLayer Layer { get; }
/// <summary>Grows on every change worth redrawing.</summary>
public int Version { get; private set; }
public StudioLive(StudioLayer layer)
{
Name = "ScenarioStudioLive";
Layer = layer;
}
public override void _EnterTree()
{
Instance = this;
DialogRuntime.Debugger = Debugger;
Debugger.ScenarioStarted += OnScenarioStarted;
Debugger.Held += OnHeld;
Debugger.Changed += Touch;
ModEventBus.Subscribe<SceneEnteredEvent>(OnSceneEntered, priority: 50);
ModEventBus.Subscribe<SceneComposedEvent>(OnSceneComposed);
ModEventBus.Subscribe<DialogEndedEvent>(OnDialogEnded);
}
public override void _ExitTree()
{
if (DialogRuntime.Debugger == Debugger)
DialogRuntime.Debugger = null;
ModEventBus.Unsubscribe<SceneEnteredEvent>(OnSceneEntered);
ModEventBus.Unsubscribe<SceneComposedEvent>(OnSceneComposed);
ModEventBus.Unsubscribe<DialogEndedEvent>(OnDialogEnded);
if (Instance == this)
Instance = null;
}
// ── what is running ────────────────────────────────────────
public DialogBox Box => SceneContextNode.FindNode<DialogBox>("DialogBox");
public static string SceneName => SceneManager.GetCurrentSceneName();
/// <summary>The scenario in the dialog box, or the last one the debugger saw while it still exists.</summary>
public ScenarioEvent CurrentEvent
{
get
{
var box = Box;
if (box != null && box.IsRunning && box.Locker is ScenarioEvent running)
return running;
var last = Debugger.Event;
return last != null && IsInstanceValid(last) ? last : null;
}
}
/// <summary>True while <see cref="CurrentEvent"/> is the dialog in the box.</summary>
public bool IsCurrentRunning
{
get
{
var box = Box;
return box != null && box.IsRunning && box.Locker == CurrentEvent;
}
}
/// <summary>Scenario line of the action the running dialog stands on; 0 when nothing runs.</summary>
public int CurrentLine()
{
if (!IsCurrentRunning)
return 0;
var runtime = Box.Runtime;
var queue = runtime.Dialog?.Queue;
var index = runtime.Index;
return queue != null && index >= 0 && index < queue.Count ? queue[index]?.SourceLine ?? 0 : 0;
}
/// <summary>True when the running dialog has an action built from scenario line <paramref name="line"/>, so it can run next.</summary>
public bool CanRunFrom(int line)
{
var queue = IsCurrentRunning ? Box.Runtime.Dialog?.Queue : null;
return line > 0 && queue != null && StudioDebugger.IndexOf(queue, line) >= 0;
}
/// <summary>Makes scenario line <paramref name="line"/> the next one to run (<see cref="StudioDebugger.SetNext"/>) and logs it.</summary>
public bool SetNextLine(int line)
{
if (!Debugger.SetNext(Box, line))
return false;
Journal.Add(StudioLogCategory.Stop, new StudioText("studio/log_text_next_line", line));
return true;
}
/// <summary>Current text of a scenario event's file (edits applied so far), or its built-in text.</summary>
public static string SourceText(ScenarioEvent evt)
{
if (evt == null)
return null;
if (!string.IsNullOrEmpty(evt.SourceModId))
return ScenarioDefs.ReadText(evt.SourceModId, evt.SourcePath) ?? evt.ScenarioText;
return evt.ScenarioText;
}
/// <summary>The mod (or layer) a scenario file belongs to and how the studio may touch it.</summary>
public StudioModScope ScopeOf(string modId, string path)
{
if (string.IsNullOrEmpty(modId))
return new StudioModScope("inline", StudioScopeKind.ReadOnly);
if (modId == StudioLayer.Id)
return new StudioModScope(modId, StudioScopeKind.Layer);
return string.IsNullOrEmpty(ModManager.Instance.GetFileSystemPath(modId, path))
? new StudioModScope(modId, StudioScopeKind.Overlay)
: new StudioModScope(modId, StudioScopeKind.Own);
}
/// <summary>Where an edit of a file in <paramref name="fileScope"/> is written.</summary>
public StudioModScope EditScopeFor(StudioModScope fileScope)
{
if (fileScope.Kind == StudioScopeKind.Own && !Layer.ForceLayer)
return fileScope;
return new StudioModScope(StudioLayer.Id, StudioScopeKind.Layer);
}
// ── edits ──────────────────────────────────────────────────
/// <summary>Pending edits of a scenario, or null.</summary>
public StudioWorkingCopy Copy(string scenarioName) =>
scenarioName != null && _copies.TryGetValue(scenarioName, out var copy) ? copy : null;
/// <summary>Pending edits of the current scenario, created from its file on first use; null without a scenario.</summary>
public StudioWorkingCopy EnsureCopy()
{
var evt = CurrentEvent;
if (evt == null)
return null;
if (!_copies.TryGetValue(evt.ID, out var copy))
_copies[evt.ID] = copy = new StudioWorkingCopy(evt.ID, SourceText(evt));
return copy;
}
/// <summary>Inserts lines before 0-based line <paramref name="index"/> of the current scenario.</summary>
public void Insert(int index, params string[] lines)
{
EnsureCopy()?.Insert(index, lines);
Touch();
}
public void ReplaceLine(int index, string line)
{
EnsureCopy()?.Replace(index, line);
Touch();
}
public void ReplaceText(string text)
{
EnsureCopy()?.ReplaceAll(text);
Touch();
}
/// <summary>Points the answer at 0-based line <paramref name="index"/> to a scene (a label block leading there is added).</summary>
public void TargetScene(int index, string scene)
{
var copy = EnsureCopy();
if (copy == null)
return;
var label = StudioScriptEdits.EnsureSceneExit(copy, scene);
copy.Replace(index, StudioScriptEdits.WithTarget(copy.Line(index), label));
Touch();
}
/// <summary>Points the answer at 0-based line <paramref name="index"/> to a label of the scenario.</summary>
public void TargetLabel(int index, string label)
{
var copy = EnsureCopy();
if (copy == null)
return;
copy.Replace(index, StudioScriptEdits.WithTarget(copy.Line(index), label));
Touch();
}
/// <summary>Points the answer at 0-based line <paramref name="index"/> to the end of the scenario.</summary>
public void TargetEnd(int index)
{
var copy = EnsureCopy();
if (copy == null)
return;
var label = StudioScriptEdits.EnsureEnd(copy);
copy.Replace(index, StudioScriptEdits.WithTarget(copy.Line(index), label));
Touch();
}
public void Discard()
{
var evt = CurrentEvent;
if (evt != null && _copies.Remove(evt.ID))
Touch();
}
/// <summary>
/// Writes the pending edits of the current scenario — into its folder mod,
/// or into the studio layer for zip mods, layer files and forced layer —
/// and replays the scenario from its start with the game state rolled
/// back. Returns the scope written to, or null when there was nothing.
/// </summary>
public StudioModScope Apply()
{
var evt = CurrentEvent;
var copy = Copy(evt?.ID);
if (copy == null || copy.PendingCount == 0)
return null;
if (string.IsNullOrEmpty(evt.SourceModId))
{
evt.ScenarioText = copy.Text;
_copies.Remove(evt.ID);
Restart(StudioRestartMode.Scenario);
return ScopeOf(null, null);
}
var target = EditScopeFor(ScopeOf(evt.SourceModId, evt.SourcePath));
var pending = copy.PendingCount;
if (target.Kind == StudioScopeKind.Layer)
Layer.Write(evt.SourcePath, copy.Text);
else
File.WriteAllText(ModManager.Instance.GetFileSystemPath(evt.SourceModId, evt.SourcePath), copy.Text);
_copies.Remove(evt.ID);
StudioLayer.RefreshContent();
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_applied", evt.ID, pending, target.ModId));
GameLogger.Log("UI", $"Scenario studio: applied {pending} change(s) of '{evt.ID}' to '{target.ModId}'");
Restart(StudioRestartMode.Rollback);
return target;
}
// ── restarts and navigation ────────────────────────────────
public void Restart(StudioRestartMode mode)
{
var evt = CurrentEvent;
if (evt == null)
return;
var name = evt.ID;
switch (mode)
{
case StudioRestartMode.Scene:
GoToScene(SceneName);
return;
case StudioRestartMode.Rollback when _snapshot != null && _snapshot.ScenarioName == name:
StopDialog();
if (_snapshot.Restore())
ForgetCompleted(name);
RunScenario(name, null, evt);
return;
case StudioRestartMode.FromLabel:
var label = LabelAbove(SourceText(evt), CurrentLine());
StopDialog();
ForgetCompleted(name);
RunScenario(name, label, evt);
return;
default:
StopDialog();
ForgetCompleted(name);
RunScenario(name, null, evt);
return;
}
}
/// <summary>Starts scenario <paramref name="name"/> of the current scene now, stopping the running dialog.</summary>
public bool RunNow(string name)
{
StopDialog();
ForgetCompleted(name);
return RunScenario(name, null, null);
}
/// <summary>Removes <paramref name="name"/> from the one-run completion history.</summary>
public void ForgetCompleted(string name)
{
var runtime = GameData.Instance?.Runtime;
if (runtime == null)
return;
runtime.CompletedScenarioIds.Remove(name);
runtime.LoadedCompletedScenarioIds?.Remove(name);
Touch();
}
/// <summary>Leaves the running dialog and enters <paramref name="scene"/> (a debugging teleport).</summary>
public void GoToScene(string scene)
{
if (string.IsNullOrEmpty(scene))
return;
StopDialog();
Debugger.Reset();
GetNodeOrNull<SceneManager>("/root/SceneManager")?.SwitchScene(scene);
Touch();
}
private void StopDialog()
{
Debugger.Continue(null);
Box?.Abort();
}
/// <summary>
/// Builds a fresh event for scenario <paramref name="name"/> from the
/// current definitions (edits included), puts it on the scene's chain in
/// place of <paramref name="replaced"/> and starts it at once, so other
/// queued scenarios do not run first. A scenario without a definition
/// (test play, inline) is rebuilt from the replaced event's text.
/// </summary>
private bool RunScenario(string name, string startLabel, ScenarioEvent replaced)
{
var chain = GetTree().GetFirstNodeInGroup(ChainGroup) as ExecutableMainEventChain;
var factory = AppServices.Current.SceneEvents.ScenarioEventFactory;
if (chain == null || factory == null)
{
GameLogger.Log("UI", $"Scenario studio: cannot run '{name}' — no event chain or scenario factory", LogLevel.Error);
return false;
}
var def = FindDefinition(name);
ScenarioEvent evt;
if (def != null)
{
var text = ScenarioDefs.ReadText(def.ModId, def.FilePath);
evt = factory.CreateFromDefinition(text, name, def.Condition, def.LightUseTime, def.IsOneRun, def.ModId, def.FilePath) as ScenarioEvent;
}
else if (replaced != null && IsInstanceValid(replaced))
{
evt = factory.CreateFromText(replaced.ScenarioText) as ScenarioEvent;
}
else
{
GameLogger.Log("UI", $"Scenario studio: scenario '{name}' not found", LogLevel.Error);
return false;
}
if (evt == null)
return false;
evt.StartLabel = startLabel;
if (replaced != null && IsInstanceValid(replaced) && replaced.GetParent() == chain)
chain.RemoveEvent(replaced);
chain.AddEvent(evt);
var started = evt.RunDialog();
GameLogger.Log("UI", $"Scenario studio: run '{name}'" + (startLabel != null ? $" from :{startLabel}" : "") + (started ? "" : " — dialog box busy"));
Touch();
return started;
}
public static ScenarioDefinition FindDefinition(string name)
{
var defs = ScenarioDefs.CollectDefinitions();
if (defs == null)
return null;
foreach (var list in defs.Values)
{
foreach (var def in list)
{
if (def.Name == name)
return def;
}
}
return null;
}
private static string LabelAbove(string text, int line)
{
if (text == null || line <= 0)
return null;
var lines = text.Replace("\r\n", "\n").Split('\n');
for (var i = Math.Min(line, lines.Length) - 1; i >= 0; i--)
{
var trimmed = lines[i].Trim();
if (trimmed.StartsWith(':'))
return trimmed.Substring(1).Trim();
}
return null;
}
/// <summary>True when a manual checkpoint was saved in this session.</summary>
public bool HasCheckpoint => _checkpoint != null;
/// <summary>Remembers the whole game state and the scene now, to come back to with <see cref="RestoreCheckpoint"/>.</summary>
public void SaveCheckpoint()
{
_checkpoint = StudioSnapshot.Capture(null, SceneName);
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_checkpoint_saved", SceneName));
Touch();
}
/// <summary>Brings back the saved checkpoint: game state, then its scene entered again.</summary>
public bool RestoreCheckpoint()
{
if (_checkpoint == null)
return false;
StopDialog();
Debugger.Reset();
if (!_checkpoint.Restore())
return false;
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_checkpoint_restored", _checkpoint.SceneName));
GoToScene(_checkpoint.SceneName);
return true;
}
// ── new content ────────────────────────────────────────────
/// <summary>Creates a scene in the layer (backgrounds of the current scene) with a first scenario; returns false on a bad name.</summary>
public bool CreateScene(string name)
{
name = (name ?? string.Empty).Trim().ToLowerInvariant();
if (!ModIdPattern.IsMatch(name))
return false;
var current = SceneName;
var defs = AppServices.Current.SceneDefs.CollectAll();
List<string> backgrounds = null;
if (current != null && defs != null && defs.TryGetValue(current, out var list) && list.Count > 0)
backgrounds = list[0].DefaultBackground;
var scenario = Layer.CreateScene(name, backgrounds, StudioScriptEdits.NewLine());
StudioLayer.RefreshContent();
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_scene_created", name, scenario));
Touch();
return true;
}
/// <summary>Creates a new scenario for the current scene in the layer; returns its name.</summary>
public string CreateScenario()
{
var scene = SceneName;
if (string.IsNullOrEmpty(scene))
return null;
var name = Layer.CreateScenario(scene, StudioScriptEdits.NewLine());
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_scenario_created", name, scene));
Touch();
return name;
}
/// <summary>Exports the layer as mod <paramref name="modId"/>; null on a bad id.</summary>
public string Export(string modId)
{
modId = (modId ?? string.Empty).Trim().ToLowerInvariant();
if (!ModIdPattern.IsMatch(modId))
return null;
var path = Layer.Export(modId);
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_exported", modId));
Touch();
return path;
}
// ── state ──────────────────────────────────────────────────
/// <summary>Writes a value edited in the "State" tab back into the game.</summary>
public void EditState(StudioStateEntry entry, string value)
{
switch (entry.Editor)
{
case StudioStateEditor.DialogVariable:
Box?.Runtime.SetVariable(entry.Id, value);
break;
case StudioStateEditor.Inventory:
var inventory = GameData.Instance?.Runtime.Inventory;
if (inventory == null || !int.TryParse(value, out var count) || count < 0)
return;
var delta = count - inventory.CheckCount(entry.Id);
if (delta > 0)
inventory.Add(entry.Id, delta);
else if (delta < 0)
inventory.Remove(entry.Id, -delta);
break;
case StudioStateEditor.Quest:
QuestServiceRegistry.Provider?.SetStatus(entry.Id, value);
break;
default:
return;
}
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_state", entry.Id, value));
Touch();
}
// ── bus and debugger callbacks ─────────────────────────────
private void OnScenarioStarted(ScenarioEvent evt)
{
try
{
_snapshot = StudioSnapshot.Capture(evt.ID, SceneName);
}
catch (Exception ex)
{
_snapshot = null;
GameLogger.Log("UI", $"Scenario studio: snapshot of '{evt.ID}' failed: {ex.Message}", LogLevel.Warning);
}
Journal.Add(StudioLogCategory.Scenario, new StudioText("studio/log_text_started", evt.ID));
}
private void OnHeld(int line, bool breakpoint)
{
Journal.Add(StudioLogCategory.Stop, new StudioText(breakpoint ? "studio/log_text_breakpoint" : "studio/log_text_paused", line));
}
private void OnSceneEntered(SceneEnteredEvent e)
{
Journal.OnSceneEntered(e);
Touch();
}
private void OnSceneComposed(SceneComposedEvent e)
{
Journal.OnSceneComposed(e);
Touch();
}
private void OnDialogEnded(DialogEndedEvent e)
{
Journal.OnDialogEnded(e);
Touch();
}
private void Touch() => Version++;
}
}
+106
View File
@@ -0,0 +1,106 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
using System.Text;
using Cthangover.Mods.Dialog.Scenarios;
using Godot;
/// <summary>
/// The DSL the dock writes. New lines are written in the current language
/// as literals (what an author would type; the locale atlas convention for
/// scenario text). Edits rewrite one line through
/// <see cref="ScenarioParser.SplitLine"/> and keep its other arguments, so
/// changing an answer's text does not lose its target and vice versa;
/// a literal replaces a <c>key=</c>, because the new text is no longer the
/// translated one.
/// </summary>
public static class StudioScriptEdits
{
public static string NewLine() => "text " + Quote(Tr("studio/new_line_text"));
public static string NewQuestion() => "select " + Quote(Tr("studio/new_choice_text"));
public static string NewAnswer() => "option " + Quote(Tr("studio/new_option_text"));
public static string Jump(string scene) => "switch_scene " + scene;
/// <summary><paramref name="line"/> with its player-visible text replaced by <paramref name="text"/>.</summary>
public static string WithSay(string line, string text)
{
var positional = new List<string>();
var named = new Dictionary<string, string>();
var cmd = ScenarioParser.SplitLine(line.Trim(), positional, named, out var arrow);
if (cmd == null)
return line;
named.Remove("key");
if (positional.Count > 0)
positional[0] = text;
else
positional.Insert(0, text);
return Indent(line) + Compose(cmd, positional, named, arrow);
}
/// <summary><paramref name="line"/> (an answer or goto) pointing at label <paramref name="label"/>.</summary>
public static string WithTarget(string line, string label)
{
var positional = new List<string>();
var named = new Dictionary<string, string>();
var cmd = ScenarioParser.SplitLine(line.Trim(), positional, named, out _);
if (cmd == null)
return line;
named.Remove("goto");
named.Remove("target");
if (positional.Count > 1 && positional[1].StartsWith(':'))
positional.RemoveAt(1);
return Indent(line) + Compose(cmd, positional, named, label);
}
/// <summary>
/// Makes sure the scenario has a label block that leads to scene
/// <paramref name="scene"/> (<c>:to_scene</c> → <c>switch_scene</c> → <c>end</c>),
/// appending it at the end when missing; returns the label name.
/// </summary>
public static string EnsureSceneExit(StudioWorkingCopy copy, string scene)
{
var label = "to_" + scene;
if (copy.FindLabel(label) < 0)
copy.Append("", ":" + label, Jump(scene), "end");
return label;
}
/// <summary>Makes sure the scenario has a label that just ends it; returns the label name.</summary>
public static string EnsureEnd(StudioWorkingCopy copy)
{
const string label = "finish";
if (copy.FindLabel(label) < 0)
copy.Append("", ":" + label, "end");
return label;
}
public static string Quote(string text) => "\"" + (text ?? string.Empty).Replace("\"", "\\\"") + "\"";
private static string Compose(string cmd, List<string> positional, Dictionary<string, string> named, string arrow)
{
var result = new StringBuilder(cmd);
var sayCommand = cmd == "text" || cmd == "ptext" || cmd == "select" || cmd == "option";
for (var i = 0; i < positional.Count; i++)
{
var value = positional[i];
result.Append(' ').Append((i == 0 && sayCommand) || NeedsQuotes(value) ? Quote(value) : value);
}
foreach (var pair in named)
result.Append(' ').Append(pair.Key).Append('=').Append(pair.Value);
if (!string.IsNullOrEmpty(arrow))
result.Append(" -> :").Append(arrow);
return result.ToString();
}
private static bool NeedsQuotes(string value) => value.Length == 0 || value.Contains(' ') || value.Contains('"') || value.Contains('=');
private static string Indent(string line) => line.Substring(0, line.Length - line.TrimStart().Length);
private static string Tr(string key) => TranslationServer.Translate(key).ToString();
}
}
+249
View File
@@ -0,0 +1,249 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Quests;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Mods.Dialog.Executable;
using Godot;
/// <summary>
/// Builds the <see cref="StudioSession"/> the dock renders from the live
/// game: the current scenario's lines (pending edits included) with the
/// debugger's marks, its file and edit scopes, the scene candidates of the
/// last composition merged with what is true now (running, completed), the
/// game state worth seeing while debugging, the log and the scene map
/// around the player. Read-only: nothing here changes the game.
/// </summary>
public static class StudioSessionBuilder
{
private const int VisitedOnMap = 4;
public static StudioSession Build(StudioLive live)
{
var session = new StudioSession { SceneName = StudioLive.SceneName };
var evt = live.CurrentEvent;
session.RunState = RunState(live);
if (evt != null)
FillScenario(session, live, evt);
FillCandidates(session, live, evt);
FillState(session, live);
session.Log.AddRange(live.Journal.Entries);
FillMap(session, live);
return session;
}
private static StudioRunState RunState(StudioLive live)
{
var box = live.Box;
if (box != null && box.IsRunning && box.Runtime.IsHeld)
return StudioRunState.Held;
if (live.Debugger.IsPaused)
return StudioRunState.Paused;
return live.IsCurrentRunning ? StudioRunState.Running : StudioRunState.Idle;
}
private static void FillScenario(StudioSession session, StudioLive live, ScenarioEvent evt)
{
session.ScenarioName = evt.ID;
session.FileScope = live.ScopeOf(evt.SourceModId, evt.SourcePath);
session.EditScope = live.EditScopeFor(session.FileScope);
var copy = live.Copy(evt.ID);
session.PendingCount = copy?.PendingCount ?? 0;
var lines = StudioLineReader.Read(copy != null ? copy.Text : StudioLive.SourceText(evt));
var running = live.IsCurrentRunning && live.Debugger.Event == evt;
var current = live.IsCurrentRunning ? live.CurrentLine() : 0;
string label = null;
foreach (var line in lines)
{
line.SourceLine = copy != null ? copy.OriginalNumber(line.Number) : line.Number;
line.IsNew = copy != null && copy.IsChanged(line.Number - 1);
line.HasBreakpoint = line.SourceLine > 0 && live.Debugger.HasBreakpoint(evt.ID, line.SourceLine);
line.IsCurrent = current > 0 && line.SourceLine == current;
line.IsDone = running && !line.IsCurrent && line.SourceLine > 0 && live.Debugger.IsExecuted(line.SourceLine);
line.CanRunNext = running && !line.IsCurrent && live.CanRunFrom(line.SourceLine);
if (line.Kind == StudioLineKind.Label && current > 0 && line.SourceLine > 0 && line.SourceLine < current)
label = ":" + line.Target;
}
session.LabelName = label;
session.Lines.AddRange(lines);
}
private static void FillCandidates(StudioSession session, StudioLive live, ScenarioEvent evt)
{
var report = live.Journal.Composed(session.SceneName);
if (report == null)
return;
var completed = GameData.Instance?.Runtime.CompletedScenarioIds;
foreach (var candidate in report.Candidates)
{
var item = new StudioCandidate
{
Name = candidate.Id,
Scope = live.ScopeOf(candidate.ModId, candidate.ScenarioPath),
Priority = candidate.Priority,
Condition = string.IsNullOrEmpty(candidate.Condition) ? null : candidate.Condition,
IsOneRun = candidate.IsOneRun,
};
switch (candidate.Outcome)
{
case SceneCandidateOutcome.ConditionFalse:
item.ConditionOk = false;
item.Status = StudioCandidateStatus.Skipped;
item.Reason = new StudioText("studio/reason_condition_false");
break;
case SceneCandidateOutcome.Completed:
item.Status = StudioCandidateStatus.Completed;
item.Reason = new StudioText("studio/reason_completed");
break;
case SceneCandidateOutcome.Shadowed:
item.Status = StudioCandidateStatus.Shadowed;
item.Reason = new StudioText("studio/reason_shadowed");
break;
case SceneCandidateOutcome.Failed:
item.Status = StudioCandidateStatus.Failed;
item.Reason = new StudioText("studio/reason_failed");
break;
default:
item.ConditionOk = true;
item.Status = StudioCandidateStatus.Queued;
item.Reason = new StudioText("studio/reason_queued", candidate.Priority);
break;
}
if (evt != null && live.IsCurrentRunning && evt.ID == candidate.Id)
{
item.Status = StudioCandidateStatus.Running;
item.Reason = new StudioText("studio/reason_running");
}
else if (completed != null && candidate.IsOneRun && completed.Contains(candidate.Id))
{
item.Status = StudioCandidateStatus.Completed;
item.Reason = new StudioText("studio/reason_completed");
}
session.Candidates.Add(item);
}
}
private static void FillState(StudioSession session, StudioLive live)
{
var runtime = GameData.Instance?.Runtime;
if (runtime == null)
return;
var time = runtime.Time;
Add(session, "studio/group_time", new StudioText("studio/state_day"), StudioText.Raw(time.Days.ToString()), StudioStateEditor.None, null);
Add(session, "studio/group_time", new StudioText("studio/state_hour"), StudioText.Raw($"{time.Hours:00}:{time.Minutes:00}"), StudioStateEditor.None, null);
var box = live.Box;
if (box != null && box.IsRunning)
{
foreach (var variable in box.Runtime.Variables)
Add(session, "studio/group_dialog_vars", StudioText.Raw("${" + variable.Key + "}"), StudioText.Raw(variable.Value), StudioStateEditor.DialogVariable, variable.Key);
}
var quests = QuestServiceRegistry.Provider;
if (quests != null)
{
foreach (var id in QuestIds(session.Lines))
{
var quest = quests.Get(id);
if (quest != null)
Add(session, "studio/group_quests", StudioText.Raw(id), StudioText.Raw(quest.Status.ToString()), StudioStateEditor.Quest, id);
}
}
var inventory = runtime.Inventory;
if (inventory?.Items != null)
{
foreach (var slot in inventory.Items)
{
if (slot?.Item != null)
Add(session, "studio/group_inventory", StudioText.Raw(slot.Item.ID), StudioText.Raw(slot.Count.ToString()), StudioStateEditor.Inventory, slot.Item.ID);
}
}
foreach (var id in runtime.CompletedScenarioIds)
Add(session, "studio/group_completed", StudioText.Raw(id), new StudioText("studio/value_yes"), StudioStateEditor.None, id);
}
private static IEnumerable<string> QuestIds(List<StudioCodeLine> lines)
{
var seen = new HashSet<string>();
foreach (var line in lines)
{
var at = line.Text.IndexOf("quest_id=", System.StringComparison.Ordinal);
if (at < 0)
continue;
var start = at + "quest_id=".Length;
var end = line.Text.IndexOf(' ', start);
var id = (end < 0 ? line.Text.Substring(start) : line.Text.Substring(start, end - start)).Trim('"');
if (id.Length > 0 && seen.Add(id))
yield return id;
}
}
private static void Add(StudioSession session, string group, StudioText key, StudioText value, StudioStateEditor editor, string id)
{
session.State.Add(new StudioStateEntry { Group = new StudioText(group), Key = key, Value = value, Editor = editor, Id = id });
}
private static void FillMap(StudioSession session, StudioLive live)
{
var here = session.SceneName;
var scenes = AppServices.Current.SceneDefs.CollectAll();
var visited = new List<string>();
var history = live.Journal.SceneHistory;
for (var i = history.Count - 1; i >= 0 && visited.Count < VisitedOnMap; i--)
{
if (history[i] != here && !visited.Contains(history[i]))
visited.Insert(0, history[i]);
}
foreach (var scene in visited)
session.Nodes.Add(Node(live, scenes, scene, StudioNodeState.Visited, null));
if (string.IsNullOrEmpty(here))
return;
session.Nodes.Add(Node(live, scenes, here, StudioNodeState.Here, null));
var exits = new HashSet<string>();
var defs = ScenarioDefs.CollectDefinitions();
if (defs != null && defs.TryGetValue(here, out var scenarios))
{
foreach (var def in scenarios)
{
foreach (var line in StudioLineReader.Read(ScenarioDefs.ReadText(def.ModId, def.FilePath)))
{
if (line.Kind == StudioLineKind.Jump && !string.IsNullOrEmpty(line.Target) && exits.Add(line.Target))
session.Nodes.Add(Node(live, scenes, line.Target, StudioNodeState.Next, new StudioText("studio/via_scenario", def.Name)));
}
}
}
foreach (var scene in live.Layer.SceneNames())
{
if (scene != here && exits.Add(scene))
session.Nodes.Add(Node(live, scenes, scene, StudioNodeState.New, new StudioText("studio/via_layer")));
}
}
private static StudioSceneNode Node(StudioLive live, Dictionary<string, List<SceneDefinition>> scenes, string name, StudioNodeState state, StudioText via)
{
string modId = null;
if (scenes != null && scenes.TryGetValue(name, out var list) && list.Count > 0)
modId = list[0].ModId;
var scope = modId == null ? new StudioModScope("?", StudioScopeKind.ReadOnly) : live.ScopeOf(modId, $"scenes/{name}.json");
return new StudioSceneNode { Name = name, State = state, Scope = scope, Via = via };
}
}
}
+43
View File
@@ -0,0 +1,43 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Text.Json;
using Cthangover.Core.Settings;
using Cthangover.Core.Utils;
/// <summary>
/// Game state at the moment a scenario started: a full save package
/// (<see cref="SaveService.BuildPackage"/>) kept serialized in memory, plus
/// the scenario and scene it belongs to. Restoring it is the "restart with
/// rollback": quests, inventory, time and completion history go back to
/// the scenario's start. It is kept as JSON because applying a package may
/// reuse its slots, and one snapshot is restored again and again while a
/// scenario is being polished.
/// </summary>
public sealed class StudioSnapshot
{
private readonly string _json;
public string ScenarioName { get; }
public string SceneName { get; }
private StudioSnapshot(string scenarioName, string sceneName, string json)
{
ScenarioName = scenarioName;
SceneName = sceneName;
_json = json;
}
public static StudioSnapshot Capture(string scenarioName, string sceneName)
{
var package = SaveService.BuildPackage("studio_snapshot");
return new StudioSnapshot(scenarioName, sceneName, JsonSerializer.Serialize(package, JsonOptionsProvider.WriteCompactOptions));
}
/// <summary>Brings the game state back; false when the package could not be applied.</summary>
public bool Restore()
{
var package = JsonSerializer.Deserialize<SavePackage>(_json, JsonOptionsProvider.ReadOptions);
return package != null && SaveService.ApplyPackage(package, "studio snapshot of " + ScenarioName);
}
}
}
+129
View File
@@ -0,0 +1,129 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
/// <summary>
/// Unapplied edits of one scenario: its text as lines plus which lines were
/// added or changed since the copy was taken. Every dock edit (insert, edit
/// a line's text, choose an answer's target, edit the whole text) goes here
/// first and reaches the game only on Apply, so an author can prepare
/// several changes and replay them at once. Every line remembers its number
/// in the original text (0 for added lines): the running dialog's actions
/// carry original numbers, so the debugger's marks and breakpoints are
/// mapped through it while edits shift the displayed numbering.
/// </summary>
public sealed class StudioWorkingCopy
{
private readonly List<string> _lines;
private readonly List<bool> _changed;
private readonly List<int> _origin;
public string ScenarioName { get; }
public StudioWorkingCopy(string scenarioName, string text)
{
ScenarioName = scenarioName;
_lines = new List<string>((text ?? string.Empty).Replace("\r\n", "\n").Split('\n'));
_changed = new List<bool>(new bool[_lines.Count]);
_origin = new List<int>(_lines.Count);
for (var i = 0; i < _lines.Count; i++)
_origin.Add(i + 1);
}
/// <summary>Original (1-based) number of the displayed line <paramref name="number"/>; 0 for an added line.</summary>
public int OriginalNumber(int number) => number >= 1 && number <= _origin.Count ? _origin[number - 1] : 0;
public string Text => string.Join("\n", _lines);
public int LineCount => _lines.Count;
/// <summary>Number of added or changed lines.</summary>
public int PendingCount
{
get
{
var count = 0;
foreach (var changed in _changed)
{
if (changed)
count++;
}
return count;
}
}
/// <summary>Whether the line at 0-based <paramref name="index"/> was added or changed.</summary>
public bool IsChanged(int index) => index >= 0 && index < _changed.Count && _changed[index];
public string Line(int index) => index >= 0 && index < _lines.Count ? _lines[index] : string.Empty;
/// <summary>Inserts lines before 0-based <paramref name="index"/> (clamped to the text).</summary>
public void Insert(int index, params string[] lines)
{
index = System.Math.Clamp(index, 0, _lines.Count);
_lines.InsertRange(index, lines);
_changed.InsertRange(index, NewFlags(lines.Length));
_origin.InsertRange(index, new int[lines.Length]);
}
/// <summary>Appends lines at the end of the text.</summary>
public void Append(params string[] lines) => Insert(_lines.Count, lines);
public void Replace(int index, string line)
{
if (index < 0 || index >= _lines.Count || _lines[index] == line)
return;
_lines[index] = line;
_changed[index] = true;
}
/// <summary>
/// Replaces the whole text (the inline text editor). Lines that differ from
/// the line at the same index before the edit are marked changed; this is a
/// display hint, not a diff, so an inserted line marks the shifted tail too.
/// </summary>
public void ReplaceAll(string text)
{
var lines = (text ?? string.Empty).Replace("\r\n", "\n").Split('\n');
for (var i = 0; i < lines.Length; i++)
{
var changed = i >= _lines.Count || _lines[i] != lines[i] || _changed[i];
if (i < _lines.Count)
{
_lines[i] = lines[i];
_changed[i] = changed;
}
else
{
_lines.Add(lines[i]);
_changed.Add(true);
_origin.Add(0);
}
}
if (_lines.Count > lines.Length)
{
_lines.RemoveRange(lines.Length, _lines.Count - lines.Length);
_changed.RemoveRange(lines.Length, _changed.Count - lines.Length);
_origin.RemoveRange(lines.Length, _origin.Count - lines.Length);
}
}
/// <summary>0-based index of the label line <c>:name</c>, or -1.</summary>
public int FindLabel(string name)
{
for (var i = 0; i < _lines.Count; i++)
{
if (_lines[i].Trim() == ":" + name)
return i;
}
return -1;
}
private static IEnumerable<bool> NewFlags(int count)
{
for (var i = 0; i < count; i++)
yield return true;
}
}
}
+36
View File
@@ -0,0 +1,36 @@
namespace Cthangover.Mods.ScenarioStudio
{
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Cthangover.Core.Utils;
using Godot;
/// <summary>
/// Waits until boot is done and the game shell (its <c>TopView</c> toolbar)
/// is in the tree, toggles the <c>studio</c> UI entry exactly like the
/// toolbar button, then frees itself. Started by <see cref="StudioLaunchInitializer"/>.
/// </summary>
public partial class StudioAutoOpenNode : Node
{
private const string EntryId = "studio";
private const float PollSeconds = 0.5f;
public StudioAutoOpenNode()
{
var timer = new Timer { WaitTime = PollSeconds, Autostart = true };
AddChild(timer);
timer.Timeout += OnPoll;
}
private void OnPoll()
{
if (!Boot.IsReady || UiHost.Instance == null || SceneContextNode.FindNode<Node>("TopView") == null)
return;
AppServices.Current.Ui.ToggleEntry(EntryId);
GameLogger.Log("UI", "Scenario studio: dock opened by --studio");
QueueFree();
}
}
}
+41
View File
@@ -0,0 +1,41 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
/// <summary>
/// Lifecycle entry of the studio mod. When resources are ready it mounts
/// the edit layer (<see cref="StudioLayer"/>) and parks <see cref="StudioLive"/>
/// at <c>/root</c>, so the debugger, the journal and the rollback snapshots
/// work from the first scene on, before the dock is ever opened. The launch
/// flag <c>--studio</c> additionally opens the dock with the first scene
/// (<c>debug_scenario_studio.bat</c>). Nodes are added deferred because
/// resources-ready fires before the shell scene exists.
/// </summary>
public class StudioLaunchInitializer : IModInitializer
{
private const string Flag = "--studio";
public void OnModLoaded(string modId) { }
public void OnModResourcesReady()
{
if (StudioLive.Instance != null)
return;
var args = OS.GetCmdlineUserArgs();
var layer = new StudioLayer(args);
layer.Mount();
var root = (Godot.Engine.GetMainLoop() as SceneTree)?.Root;
root?.CallDeferred("add_child", new StudioLive(layer));
if (!args.Contains(Flag))
return;
GameLogger.Log("UI", "Scenario studio: --studio given, the dock opens with the first scene");
root?.CallDeferred("add_child", new StudioAutoOpenNode());
}
}
}
+693
View File
@@ -0,0 +1,693 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// The studio dock: a non-modal panel at the right edge over the running
/// game (manifest <c>ui</c> entry <c>studio</c>, own toolbar icon
/// <c>ui/icons/studio.png</c>). The root control spans the screen but
/// ignores the mouse, so the game on the left keeps its input; only the
/// <c>Dock</c> panel catches clicks. The dock sits in a <c>Frame</c> whose
/// header and edge/corner grips move and resize it (<see cref="StudioDockDrag"/>);
/// the geometry lives as long as the dock. The skeleton — header with the mode
/// switch, transport row, "where we are", tab row, scrolling body and
/// footer — lives in <c>scenes/studio_panel.tscn</c>; this behaviour dresses
/// it with the studio styles and refills the body for the active tab and
/// mode from a <see cref="StudioSession"/> built off <see cref="StudioLive"/>.
/// <para/>
/// The dock polls <see cref="StudioLive.Version"/> and redraws only when it
/// changed and the user is not typing (a focused field, the inline text
/// editor or an open prompt), so a running dialog never steals an edit.
/// Every action goes through <see cref="StudioLive"/>; the dock keeps only
/// view state (mode, tab, footer status) and the context of an open prompt.
/// </summary>
public partial class StudioPanel : Widget
{
private const string Root = "Frame/Dock/Column/";
private const float PollSeconds = 0.4f;
private const long TargetEnd = 0;
private const long TargetLabelBase = 1000;
private const long TargetSceneBase = 2000;
private const int CheckpointSave = 100;
private const int CheckpointRestore = 101;
private readonly StudioNowPage _nowPage = new();
private readonly StudioNovicePage _novicePage = new();
private readonly List<StudioDockDrag> _drags = new();
private StudioMode _mode = StudioMode.Author;
private StudioTab _tab = StudioTab.Now;
private StudioSession _session = new();
private string _status;
private int _shownVersion = -1;
private CodeEdit _editor;
private Node _prompt;
private StudioCodeLine _promptLine;
private List<string> _pickLabels = new();
private List<string> _pickScenes = new();
private PanelContainer _runState;
private Label _runStateText;
private Button _authorButton;
private Button _noviceButton;
private Button _pauseButton;
private Button _stepButton;
private Button _continueButton;
private MenuButton _restartButton;
private HFlowContainer _crumbs;
private HFlowContainer _scope;
private Button[] _tabs;
private VBoxContainer _content;
private Label _statusLabel;
private Button _editButton;
private Button _applyButton;
/// <summary>Currently shown tab.</summary>
public StudioTab Tab => _tab;
/// <summary>Currently shown mode.</summary>
public StudioMode Mode => _mode;
/// <summary>The session the dock shows now.</summary>
public StudioSession Session => _session;
private static StudioLive Live => StudioLive.Instance;
protected override void OnceConstruct()
{
MouseFilter = MouseFilterEnum.Ignore;
var frame = GetNode<Control>("Frame");
var dock = GetNode<PanelContainer>("Frame/Dock");
dock.AddThemeStyleboxOverride("panel", StudioStyles.Dock());
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>(Root + "Header"), StudioDockEdge.Move));
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/LeftGrip"), StudioDockEdge.Left));
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/RightGrip"), StudioDockEdge.Right));
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/TopGrip"), StudioDockEdge.Top));
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/BottomGrip"), StudioDockEdge.Bottom));
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/TopLeftGrip"), StudioDockEdge.Top | StudioDockEdge.Left));
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/TopRightGrip"), StudioDockEdge.Top | StudioDockEdge.Right));
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/BottomLeftGrip"), StudioDockEdge.Bottom | StudioDockEdge.Left));
_drags.Add(new StudioDockDrag(frame, dock, GetNode<Control>("Frame/BottomRightGrip"), StudioDockEdge.Bottom | StudioDockEdge.Right));
foreach (var rule in new[] { "RuleHeader", "RuleTransport", "RuleContext", "RuleTabs", "RuleFooter" })
GetNode<ColorRect>(Root + rule).Color = UiTokens.Line;
StudioStyles.Title(GetNode<Label>(Root + "Header/Row/Title"), Tr("studio/title"));
_runState = GetNode<PanelContainer>(Root + "Header/Row/RunState");
_runStateText = GetNode<Label>(Root + "Header/Row/RunState/Text");
GetNode<PanelContainer>(Root + "Header/Row/ModeSwitch").AddThemeStyleboxOverride("panel", StudioStyles.SegmentWell());
_authorButton = GetNode<Button>(Root + "Header/Row/ModeSwitch/Items/AuthorButton");
_noviceButton = GetNode<Button>(Root + "Header/Row/ModeSwitch/Items/NoviceButton");
StudioStyles.Segment(_authorButton, Tr("studio/mode_author"));
StudioStyles.Segment(_noviceButton, Tr("studio/mode_novice"));
_authorButton.Pressed += OnAuthorPressed;
_noviceButton.Pressed += OnNovicePressed;
var close = GetNode<Button>(Root + "Header/Row/CloseButton");
CthangoverTheme.ApplyIcon(close);
close.TooltipText = Tr("studio/close");
close.Pressed += OnClosePressed;
_pauseButton = GetNode<Button>(Root + "Transport/Row/PauseButton");
StudioStyles.Transport(_pauseButton, Tr("studio/pause"));
_pauseButton.Pressed += OnPausePressed;
_stepButton = GetNode<Button>(Root + "Transport/Row/StepButton");
StudioStyles.Transport(_stepButton, Tr("studio/step"));
_stepButton.Pressed += OnStepPressed;
_continueButton = GetNode<Button>(Root + "Transport/Row/ContinueButton");
StudioStyles.Transport(_continueButton, Tr("studio/continue"));
_continueButton.Pressed += OnContinuePressed;
_restartButton = GetNode<MenuButton>(Root + "Transport/Row/RestartButton");
BuildRestartMenu(_restartButton);
_crumbs = GetNode<HFlowContainer>(Root + "Context/Column/Crumbs");
_scope = GetNode<HFlowContainer>(Root + "Context/Column/Scope");
_tabs = new[]
{
GetNode<Button>(Root + "Tabs/Row/NowTab"),
GetNode<Button>(Root + "Tabs/Row/SceneTab"),
GetNode<Button>(Root + "Tabs/Row/StateTab"),
GetNode<Button>(Root + "Tabs/Row/LogTab"),
GetNode<Button>(Root + "Tabs/Row/MapTab"),
};
StudioStyles.SmallTab(_tabs[(int)StudioTab.Now], Tr("studio/tab_now"));
StudioStyles.SmallTab(_tabs[(int)StudioTab.Scene], Tr("studio/tab_scene"));
StudioStyles.SmallTab(_tabs[(int)StudioTab.State], Tr("studio/tab_state"));
StudioStyles.SmallTab(_tabs[(int)StudioTab.Log], Tr("studio/tab_log"));
StudioStyles.SmallTab(_tabs[(int)StudioTab.Map], Tr("studio/tab_map"));
_tabs[(int)StudioTab.Now].Pressed += OnNowTabPressed;
_tabs[(int)StudioTab.Scene].Pressed += OnSceneTabPressed;
_tabs[(int)StudioTab.State].Pressed += OnStateTabPressed;
_tabs[(int)StudioTab.Log].Pressed += OnLogTabPressed;
_tabs[(int)StudioTab.Map].Pressed += OnMapTabPressed;
_content = GetNode<VBoxContainer>(Root + "Body/Scroll/Content");
_nowPage.BreakpointToggled += OnBreakpointToggled;
_nowPage.InsertRequested += OnInsertRequested;
_nowPage.NextLineRequested += OnNextLineRequested;
_novicePage.LineRequested += OnNoviceLineRequested;
_novicePage.OptionRequested += OnNoviceOptionRequested;
_novicePage.EditRequested += OnEditLineRequested;
_novicePage.TargetRequested += OnTargetRequested;
_statusLabel = GetNode<Label>(Root + "Footer/Row/Status");
_statusLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
_statusLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
_editButton = GetNode<Button>(Root + "Footer/Row/EditButton");
StudioStyles.SmallButton(_editButton, Tr("studio/edit_text"));
_editButton.Pressed += OnEditPressed;
_applyButton = GetNode<Button>(Root + "Footer/Row/ApplyButton");
StudioStyles.SmallButton(_applyButton, Tr("studio/apply"), primary: true);
_applyButton.Pressed += OnApplyPressed;
var timer = new Timer { WaitTime = PollSeconds, Autostart = true };
AddChild(timer);
timer.Timeout += OnPoll;
Refresh();
}
/// <summary>Redraws at once whenever the dock is shown.</summary>
protected override void ShowConstruct()
{
_shownVersion = -1;
if (_content != null)
Refresh();
}
private void BuildRestartMenu(MenuButton button)
{
StudioStyles.Transport(button, Tr("studio/restart"));
var popup = button.GetPopup();
CthangoverTheme.Apply(popup);
popup.AddItem(Tr("studio/restart_scenario"), (int)StudioRestartMode.Scenario);
popup.AddItem(Tr("studio/restart_rollback"), (int)StudioRestartMode.Rollback);
popup.AddItem(Tr("studio/restart_scene"), (int)StudioRestartMode.Scene);
popup.AddItem(Tr("studio/restart_from_label"), (int)StudioRestartMode.FromLabel);
popup.AddSeparator(Tr("studio/checkpoint"));
popup.AddItem(Tr("studio/checkpoint_save"), CheckpointSave);
popup.AddItem(Tr("studio/checkpoint_restore"), CheckpointRestore);
popup.IdPressed += OnRestartPicked;
}
// ── drawing ────────────────────────────────────────────────
private void OnPoll()
{
if (!Visible || Live == null || Live.Version == _shownVersion || IsBusy())
return;
Refresh();
}
/// <summary>True while the user types, answers a prompt or drags the ▶ marker: redrawing would drop it.</summary>
private bool IsBusy()
{
if (_editor != null || _nowPage.IsDragging || (_prompt != null && IsInstanceValid(_prompt)))
return true;
var focus = GetViewport()?.GuiGetFocusOwner();
return focus is LineEdit or TextEdit && _content.IsAncestorOf(focus);
}
/// <summary>Rebuilds the session from the live game and re-renders every dynamic part of the dock.</summary>
public void Refresh()
{
if (Live == null)
return;
_shownVersion = Live.Version;
_session = StudioSessionBuilder.Build(Live);
RefreshHeader();
RefreshContext();
RefreshBody();
RefreshFooter();
}
private void RefreshHeader()
{
var state = _session.RunState;
var stopped = state == StudioRunState.Paused || state == StudioRunState.Held;
StudioStyles.RunState(_runState, _runStateText, state, Tr(RunStateKey(state)));
_pauseButton.SetPressedNoSignal(Live.Debugger.IsPaused);
_stepButton.Disabled = !_session.HasScenario;
_continueButton.Disabled = !stopped;
var restartMenu = _restartButton.GetPopup();
for (var i = 0; i < restartMenu.ItemCount; i++)
{
var id = restartMenu.GetItemId(i);
if (id < CheckpointSave && !restartMenu.IsItemSeparator(i))
restartMenu.SetItemDisabled(i, !_session.HasScenario);
else if (id == CheckpointRestore)
restartMenu.SetItemDisabled(i, !Live.HasCheckpoint);
}
_authorButton.SetPressedNoSignal(_mode == StudioMode.Author);
_noviceButton.SetPressedNoSignal(_mode == StudioMode.Novice);
for (var i = 0; i < _tabs.Length; i++)
_tabs[i].SetPressedNoSignal(i == (int)_tab);
_tabs[(int)StudioTab.Scene].Text = _session.Candidates.Count > 0
? $"{Tr("studio/tab_scene")} {_session.Candidates.Count}"
: Tr("studio/tab_scene");
}
private static string RunStateKey(StudioRunState state)
{
switch (state)
{
case StudioRunState.Running:
return "studio/state_running";
case StudioRunState.Paused:
return "studio/state_paused";
case StudioRunState.Held:
return "studio/state_held";
default:
return "studio/state_idle";
}
}
private void RefreshContext()
{
Clear(_crumbs);
AddCrumb(Tr("studio/crumb_scene"), _session.SceneName ?? "—");
if (_session.HasScenario)
AddCrumb(Tr("studio/crumb_scenario"), _session.ScenarioName);
if (!string.IsNullOrEmpty(_session.LabelName))
AddCrumb(null, _session.LabelName);
Clear(_scope);
if (_session.FileScope == null)
return;
_scope.AddChild(StudioStyles.Scope(_session.FileScope));
if (_session.EditScope != null && _session.EditScope.ModId != _session.FileScope.ModId)
{
var hint = StudioStyles.Text(string.Format(Tr("studio/edits_go_to"), _session.EditScope.ModId), UiTokens.FontSmall, UiTokens.Faint);
hint.VerticalAlignment = VerticalAlignment.Center;
_scope.AddChild(hint);
}
}
private void AddCrumb(string key, string value)
{
if (_crumbs.GetChildCount() > 0)
_crumbs.AddChild(StudioStyles.Text("›", UiTokens.FontBodySmall, UiTokens.Faint));
if (key != null)
_crumbs.AddChild(StudioStyles.Text(key, UiTokens.FontSmall, UiTokens.Faint));
_crumbs.AddChild(StudioStyles.Text(value, UiTokens.FontBodySmall, UiTokens.Body, mono: true));
}
private void RefreshBody()
{
if (_editor != null)
return;
Clear(_content);
switch (_tab)
{
case StudioTab.Now when _mode == StudioMode.Author:
_nowPage.Build(_content, _session);
break;
case StudioTab.Now:
_novicePage.Build(_content, _session);
break;
case StudioTab.Scene:
StudioScenePage.Build(_content, _session, OnRunCandidate, OnResetCandidate, OnNewScenario);
break;
case StudioTab.State:
StudioStatePage.Build(_content, _session, OnStateSubmitted);
break;
case StudioTab.Log:
StudioLogPage.Build(_content, _session);
break;
case StudioTab.Map:
StudioMapPage.Build(_content, _session, OnGoToScene, OnNewScene, OnLinkScene, OnExport);
break;
}
}
private void RefreshFooter()
{
var pending = _session.PendingCount;
_statusLabel.Text = _status ?? (pending == 0 ? Tr("studio/no_pending") : string.Format(Tr("studio/pending"), pending));
_applyButton.Disabled = pending == 0 && _editor == null;
_editButton.Visible = _mode == StudioMode.Author;
_editButton.Disabled = !_session.HasScenario;
_editButton.Text = Tr(_editor != null ? "studio/edit_done" : "studio/edit_text");
}
private static void Clear(Node container)
{
foreach (var child in container.GetChildren())
{
container.RemoveChild(child);
child.QueueFree();
}
}
private void SetStatus(string text)
{
_status = text;
_statusLabel.Text = text;
}
private void Changed()
{
_status = null;
Refresh();
}
// ── header, tabs, transport ────────────────────────────────
private void ShowTab(StudioTab tab)
{
CloseEditor();
_tab = tab;
Changed();
}
private void ShowMode(StudioMode mode)
{
CloseEditor();
_mode = mode;
_tab = StudioTab.Now;
Changed();
}
private void OnAuthorPressed() => ShowMode(StudioMode.Author);
private void OnNovicePressed() => ShowMode(StudioMode.Novice);
private void OnNowTabPressed() => ShowTab(StudioTab.Now);
private void OnSceneTabPressed() => ShowTab(StudioTab.Scene);
private void OnStateTabPressed() => ShowTab(StudioTab.State);
private void OnLogTabPressed() => ShowTab(StudioTab.Log);
private void OnMapTabPressed() => ShowTab(StudioTab.Map);
private void OnClosePressed() => Hide();
private void OnPausePressed()
{
if (Live.Debugger.IsPaused)
Live.Debugger.Continue(Live.Box);
else
Live.Debugger.Pause();
Changed();
}
private void OnStepPressed()
{
var box = Live.Box;
if (box != null && box.IsWaitingAnswer && !box.Runtime.IsHeld)
{
SetStatus(Tr("studio/step_needs_answer"));
return;
}
Live.Debugger.Step(box);
Changed();
}
private void OnContinuePressed()
{
Live.Debugger.Continue(Live.Box);
Changed();
}
private void OnRestartPicked(long id)
{
CloseEditor();
switch (id)
{
case CheckpointSave:
Live.SaveCheckpoint();
_status = Tr("studio/checkpoint_saved");
Refresh();
return;
case CheckpointRestore:
_status = Live.RestoreCheckpoint() ? Tr("studio/checkpoint_restored") : Tr("studio/checkpoint_none");
Refresh();
return;
default:
Live.Restart((StudioRestartMode)id);
Changed();
return;
}
}
// ── author edits ───────────────────────────────────────────
private void OnBreakpointToggled(StudioCodeLine line)
{
if (line.SourceLine > 0)
Live.Debugger.ToggleBreakpoint(_session.ScenarioName, line.SourceLine);
Changed();
}
private void OnNextLineRequested(StudioCodeLine line)
{
if (Live.SetNextLine(line.SourceLine))
Changed();
else
SetStatus(Tr("studio/next_line_failed"));
}
private void OnInsertRequested(StudioLineKind kind)
{
var at = _session.InsertionTextIndexAfterCurrent();
switch (kind)
{
case StudioLineKind.Choice:
Live.Insert(at, StudioScriptEdits.NewQuestion(), StudioScriptEdits.NewAnswer());
Changed();
break;
case StudioLineKind.Jump:
_promptLine = new StudioCodeLine { Number = at };
OpenScenePick(OnInsertJumpPicked);
break;
default:
Live.Insert(at, StudioScriptEdits.NewLine());
Changed();
break;
}
}
private void OnInsertJumpPicked(long id)
{
var scene = PickedScene(id);
if (scene != null && _promptLine != null)
Live.Insert(_promptLine.Number, StudioScriptEdits.Jump(scene));
_prompt = null;
Changed();
}
private void OnEditPressed()
{
if (_editor != null)
{
CloseEditor();
Changed();
return;
}
var copy = Live.EnsureCopy();
if (copy == null)
return;
_tab = StudioTab.Now;
Clear(_content);
_editor = StudioNowPage.BuildEditor(_content, copy.Text);
_editor.GrabFocus();
RefreshFooter();
}
/// <summary>Takes the inline editor's text into the working copy and closes it.</summary>
private void CloseEditor()
{
if (_editor == null)
return;
var text = _editor.Text;
_editor = null;
Live.ReplaceText(text);
}
private void OnApplyPressed()
{
CloseEditor();
var scope = Live.Apply();
_status = scope == null ? Tr("studio/no_pending") : string.Format(Tr("studio/applied_to"), scope.ModId);
Refresh();
}
// ── novice edits ───────────────────────────────────────────
private void OnNoviceLineRequested(int at)
{
Live.Insert(at, StudioScriptEdits.NewLine());
Changed();
}
private void OnNoviceOptionRequested(int at)
{
Live.Insert(at, StudioScriptEdits.NewAnswer());
Changed();
}
private void OnEditLineRequested(StudioActionButton button)
{
_promptLine = button.Line;
_prompt = StudioPrompt.AskText(this, Tr("studio/prompt_edit_title"), button.Line.Say?.Resolve(), OnLineTextEntered);
}
private void OnLineTextEntered(string text)
{
if (_promptLine != null && !string.IsNullOrWhiteSpace(text))
{
var index = _promptLine.Number - 1;
var copy = Live.EnsureCopy();
if (copy != null)
Live.ReplaceLine(index, StudioScriptEdits.WithSay(copy.Line(index), text.Trim()));
}
_prompt = null;
Changed();
}
/// <summary>"Choose where" for an answer: the end, the scenario's labels or any scene.</summary>
private void OnTargetRequested(StudioActionButton button)
{
_promptLine = button.Line;
var menu = StudioPrompt.Pick(this, OnTargetPicked);
menu.AddItem(Tr("studio/target_end"), (int)TargetEnd);
_pickLabels = new List<string>();
foreach (var line in _session.Lines)
{
if (line.Kind == StudioLineKind.Label)
_pickLabels.Add(line.Target);
}
if (_pickLabels.Count > 0)
menu.AddSeparator(Tr("studio/target_labels"));
for (var i = 0; i < _pickLabels.Count; i++)
menu.AddItem(":" + _pickLabels[i], (int)(TargetLabelBase + i));
AddScenes(menu);
_prompt = menu;
StudioPrompt.Open(menu);
}
private void OnTargetPicked(long id)
{
if (_promptLine != null)
{
var index = _promptLine.Number - 1;
if (id == TargetEnd)
Live.TargetEnd(index);
else if (id >= TargetSceneBase)
Live.TargetScene(index, PickedScene(id));
else if (id >= TargetLabelBase && id - TargetLabelBase < _pickLabels.Count)
Live.TargetLabel(index, _pickLabels[(int)(id - TargetLabelBase)]);
}
_prompt = null;
Changed();
}
private void OpenScenePick(PopupMenu.IdPressedEventHandler picked)
{
var menu = StudioPrompt.Pick(this, picked);
AddScenes(menu);
_prompt = menu;
StudioPrompt.Open(menu);
}
private void AddScenes(PopupMenu menu)
{
_pickScenes = new List<string>();
var scenes = AppServices.Current.SceneDefs.CollectAll();
if (scenes != null)
_pickScenes.AddRange(scenes.Keys);
_pickScenes.Sort(System.StringComparer.Ordinal);
menu.AddSeparator(Tr("studio/target_scenes"));
for (var i = 0; i < _pickScenes.Count; i++)
menu.AddItem(string.Format(Tr("studio/target_scene"), _pickScenes[i]), (int)(TargetSceneBase + i));
}
private string PickedScene(long id)
{
var index = (int)(id - TargetSceneBase);
return index >= 0 && index < _pickScenes.Count ? _pickScenes[index] : null;
}
// ── scene, state, map ──────────────────────────────────────
private void OnRunCandidate(StudioActionButton button)
{
var started = Live.RunNow(button.Payload);
_tab = StudioTab.Now;
_status = started ? null : Tr("studio/run_failed");
Refresh();
}
private void OnResetCandidate(StudioActionButton button)
{
Live.ForgetCompleted(button.Payload);
Changed();
}
private void OnNewScenario(StudioActionButton button)
{
var name = Live.CreateScenario();
_status = name == null ? null : string.Format(Tr("studio/scenario_created"), name);
Refresh();
}
private void OnStateSubmitted(StudioStateEntry entry, string value)
{
GetViewport()?.GuiReleaseFocus();
Live.EditState(entry, value);
Changed();
}
private void OnGoToScene(StudioActionButton button)
{
Live.GoToScene(button.Payload);
Changed();
}
private void OnNewScene(StudioActionButton button)
{
_prompt = StudioPrompt.AskText(this, Tr("studio/prompt_scene_title"), "", OnSceneNameEntered);
}
private void OnSceneNameEntered(string name)
{
_prompt = null;
_status = Live.CreateScene(name) ? string.Format(Tr("studio/scene_created"), name.Trim().ToLowerInvariant()) : Tr("studio/bad_name");
Refresh();
}
private void OnLinkScene(StudioActionButton button)
{
_promptLine = new StudioCodeLine { Number = _session.InsertionTextIndexAfterCurrent() };
OpenScenePick(OnInsertJumpPicked);
}
private void OnExport(StudioActionButton button)
{
_prompt = StudioPrompt.AskText(this, Tr("studio/prompt_export_title"), "my_story", OnExportEntered);
}
private void OnExportEntered(string modId)
{
_prompt = null;
var path = Live.Export(modId);
_status = path == null ? Tr("studio/bad_name") : string.Format(Tr("studio/exported"), modId.Trim().ToLowerInvariant());
Refresh();
}
}
}
+38
View File
@@ -0,0 +1,38 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Godot;
/// <summary>
/// Small studio button that carries what it acts on — a scenario line, a
/// scenario or scene name — and reports itself when pressed, so one dock
/// handler serves every row without a closure per button.
/// </summary>
public partial class StudioActionButton : Button
{
/// <summary>Name the action applies to (scenario, scene), if any.</summary>
public string Payload { get; set; }
/// <summary>Scenario line the action applies to, if any.</summary>
public StudioCodeLine Line { get; set; }
/// <summary>Raised with the button itself when pressed.</summary>
public event Action<StudioActionButton> Chosen;
public StudioActionButton()
{
Pressed += OnPressed;
}
/// <summary>Small studio-styled button with a localized caption.</summary>
public static StudioActionButton Create(string key, Action<StudioActionButton> chosen, bool primary = false)
{
var button = new StudioActionButton();
StudioStyles.SmallButton(button, TranslationServer.Translate(key), primary);
button.Chosen += chosen;
return button;
}
private void OnPressed() => Chosen?.Invoke(this);
}
}
+83
View File
@@ -0,0 +1,83 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// One answer of the current question for the novice (<c>.ct-branch</c>):
/// the answer text and, under it, where it leads — a scene, an action, the
/// end of the scenario — with the label hop already followed. Every answer
/// can be redirected ("Choose where"); a broken one says so in words and
/// offers only that, as the gold action.
/// </summary>
public partial class StudioBranchView : PanelContainer
{
public bool IsBroken { get; }
public StudioBranchView(StudioBranch branch, Action<StudioActionButton> edit, Action<StudioActionButton> chooseTarget)
{
IsBroken = branch.Kind == StudioOutcomeKind.Broken;
var isNew = branch.Line.IsNew;
MouseFilter = MouseFilterEnum.Ignore;
AddThemeStyleboxOverride("panel", StudioStyles.Branch(IsBroken, isNew));
var row = UiKit.Row(StudioStyles.BarGap);
AddChild(row);
if (isNew)
row.AddChild(StudioStyles.Bar(UiTokens.Success));
var body = UiKit.Column(UiTokens.Space1);
body.SizeFlagsHorizontal = SizeFlags.ExpandFill;
row.AddChild(body);
var text = UiKit.Label(StudioSayView.Quote(branch.Line), UiTokens.FontBodyLarge, UiTokens.Ink);
text.AutowrapMode = TextServer.AutowrapMode.WordSmart;
body.AddChild(text);
body.AddChild(BuildOutcome(branch));
var actions = UiKit.Column(UiTokens.Space1);
actions.SizeFlagsVertical = SizeFlags.ShrinkCenter;
row.AddChild(actions);
var target = StudioActionButton.Create("studio/choose_target", chooseTarget, primary: IsBroken);
target.Line = branch.Line;
actions.AddChild(target);
if (IsBroken)
return;
var editText = StudioActionButton.Create("studio/edit", edit);
editText.Line = branch.Line;
actions.AddChild(editText);
}
private static Control BuildOutcome(StudioBranch branch)
{
var color = StudioStyles.OutcomeColor(branch.Kind);
var row = UiKit.Row(UiTokens.Space2);
row.AddChild(StudioStyles.Text("→", UiTokens.FontBodySmall, UiTokens.Faint));
row.AddChild(StudioStyles.Text(TranslationServer.Translate(OutcomeKey(branch.Kind)), UiTokens.FontBodySmall, color));
if (!string.IsNullOrEmpty(branch.Target))
row.AddChild(StudioStyles.Text(branch.Target, UiTokens.FontBodySmall, color, mono: true));
return row;
}
private static string OutcomeKey(StudioOutcomeKind kind)
{
switch (kind)
{
case StudioOutcomeKind.Scene:
return "studio/outcome_scene";
case StudioOutcomeKind.Action:
return "studio/outcome_action";
case StudioOutcomeKind.End:
return "studio/outcome_end";
case StudioOutcomeKind.Continue:
return "studio/outcome_continue";
default:
return "studio/outcome_broken";
}
}
}
}
+152
View File
@@ -0,0 +1,152 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Godot;
/// <summary>
/// One script line of the author mode (<c>.ct-code__line</c>): gutter with
/// the breakpoint dot, line number and the ▶ marker of the current line,
/// then the coloured source. A click on the gutter toggles the breakpoint,
/// like in an IDE. The ▶ marker of the current line is a handle of its own:
/// dragging it onto another line is the IDE's "set next statement" — the
/// view only reports where the mouse is (<see cref="MarkerDragged"/>,
/// <see cref="MarkerDropped"/> in canvas coordinates); the page decides
/// which line is under it. Godot keeps sending motion and release to the
/// control the press started on, so the marker sees the whole drag even
/// over other lines. A parse error is shown as a faint comment after the
/// source so the line keeps its place. The source label has a fixed line
/// height instead of fit-content: a fitted label without wrapping demands its
/// full text width and would widen the whole dock for one long line.
/// </summary>
public partial class StudioCodeLineView : PanelContainer
{
private const int GutterWidth = 58;
private const int LineHeight = 20;
private readonly StudioCodeLine _line;
private readonly Control _gutter;
private readonly Label _marker;
private bool _dragging;
/// <summary>The line this row shows.</summary>
public StudioCodeLine Line => _line;
/// <summary>The clickable gutter (breakpoint dot, number, marker).</summary>
public Control Gutter => _gutter;
/// <summary>The ▶ marker of the current line, the handle dragged to choose the next line.</summary>
public Control Marker => _marker;
/// <summary>Raised when the gutter is clicked.</summary>
public event Action<StudioCodeLine> BreakpointToggled;
/// <summary>Raised while the marker is dragged, with the mouse position in canvas coordinates.</summary>
public event Action<Vector2> MarkerDragged;
/// <summary>Raised when the dragged marker is released, with the mouse position in canvas coordinates.</summary>
public event Action<Vector2> MarkerDropped;
public StudioCodeLineView(StudioCodeLine line)
{
_line = line;
MouseFilter = MouseFilterEnum.Pass;
AddThemeStyleboxOverride("panel", StudioStyles.CodeLine(line));
var row = new HBoxContainer { MouseFilter = MouseFilterEnum.Ignore };
row.AddThemeConstantOverride("separation", 0);
AddChild(row);
_gutter = new HBoxContainer { CustomMinimumSize = new Vector2(GutterWidth, 0), MouseFilter = MouseFilterEnum.Stop };
_gutter.AddThemeConstantOverride("separation", 0);
_gutter.MouseDefaultCursorShape = CursorShape.PointingHand;
_gutter.GuiInput += OnGutterInput;
row.AddChild(_gutter);
var dotSlot = new CenterContainer { CustomMinimumSize = new Vector2(14, 0), MouseFilter = MouseFilterEnum.Ignore };
var dot = new Panel { CustomMinimumSize = new Vector2(8, 8), MouseFilter = MouseFilterEnum.Ignore };
dot.AddThemeStyleboxOverride("panel", StudioStyles.Breakpoint(line.HasBreakpoint));
dotSlot.AddChild(dot);
_gutter.AddChild(dotSlot);
var current = line.IsCurrent;
var number = StudioStyles.Text(line.Number.ToString(), UiTokens.FontBodySmall, current ? UiTokens.AccentBright : UiTokens.Faint, mono: true);
number.CustomMinimumSize = new Vector2(28, 0);
number.HorizontalAlignment = HorizontalAlignment.Right;
_gutter.AddChild(number);
_marker = StudioStyles.Text(current ? " ▶" : "", UiTokens.FontTiny, UiTokens.AccentBright);
_marker.CustomMinimumSize = new Vector2(16, 0);
_marker.VerticalAlignment = VerticalAlignment.Center;
if (current)
{
_marker.MouseFilter = MouseFilterEnum.Stop;
_marker.MouseDefaultCursorShape = CursorShape.Drag;
_marker.TooltipText = TranslationServer.Translate("studio/drag_next_line");
_marker.GuiInput += OnMarkerInput;
}
_gutter.AddChild(_marker);
var source = new RichTextLabel
{
BbcodeEnabled = true,
FitContent = false,
CustomMinimumSize = new Vector2(0, LineHeight),
ScrollActive = false,
AutowrapMode = TextServer.AutowrapMode.Off,
ClipContents = true,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
MouseFilter = MouseFilterEnum.Ignore,
Text = StudioSyntax.ToBbCode(line.Text) + ErrorSuffix(line),
};
source.AddThemeFontOverride("normal_font", StudioStyles.Mono);
source.AddThemeFontSizeOverride("normal_font_size", UiTokens.FontBodySmall);
source.AddThemeColorOverride("default_color", line.IsDone ? UiTokens.Muted : UiTokens.Body);
if (line.IsDone)
source.Modulate = new Color(1f, 1f, 1f, 0.6f);
row.AddChild(source);
}
private static string ErrorSuffix(StudioCodeLine line)
{
if (line.Error == null)
return string.Empty;
return $" [color=#{UiTokens.DangerText.ToHtml(false)}]# {line.Error.Resolve().Replace("[", "[lb]")}[/color]";
}
/// <summary>Marks the row as the place the dragged marker would land (or clears the mark).</summary>
public void ShowDropTarget(bool shown)
{
AddThemeStyleboxOverride("panel", shown ? StudioStyles.CodeLineDropTarget() : StudioStyles.CodeLine(_line));
}
private void OnMarkerInput(InputEvent @event)
{
switch (@event)
{
case InputEventMouseButton { ButtonIndex: MouseButton.Left } mouse when mouse.Pressed:
_dragging = true;
_marker.AcceptEvent();
break;
case InputEventMouseButton { ButtonIndex: MouseButton.Left } mouse when _dragging:
_dragging = false;
_marker.AcceptEvent();
MarkerDropped?.Invoke(_marker.GetGlobalTransform() * mouse.Position);
break;
case InputEventMouseMotion motion when _dragging:
_marker.AcceptEvent();
MarkerDragged?.Invoke(_marker.GetGlobalTransform() * motion.Position);
break;
}
}
private void OnGutterInput(InputEvent @event)
{
if (@event is not InputEventMouseButton mouse || !mouse.Pressed || mouse.ButtonIndex != MouseButton.Left)
return;
_gutter.AcceptEvent();
BreakpointToggled?.Invoke(_line);
}
}
}
+94
View File
@@ -0,0 +1,94 @@
namespace Cthangover.Mods.ScenarioStudio
{
using Godot;
/// <summary>
/// Mouse drag on one handle of the dock (<c>Frame</c> in
/// <c>scenes/studio_panel.tscn</c>): the header moves the frame, the edge and
/// corner grips resize it. The handles and their cursors live in the scene;
/// this only listens to the handle's <c>GuiInput</c>.
/// <para/>
/// Each motion is measured from the press, not from the previous motion, so
/// a drag that hits a limit (screen border, smallest size) does not leave
/// the frame lagging behind the mouse once it comes back. The frame keeps
/// its anchors (right edge, full height): <c>Position</c>/<c>Size</c> rewrite
/// only the offsets, so a moved dock still follows the right edge when the
/// window is resized. The smallest size is <see cref="StudioStyles.DockMinSize"/>
/// or what the dock's content demands, whichever is larger — a narrower
/// frame would let the panel overflow it.
/// </summary>
public sealed class StudioDockDrag
{
private readonly Control _frame;
private readonly Control _dock;
private readonly Control _handle;
private readonly StudioDockEdge _edges;
private bool _dragging;
private Vector2 _pressedAt;
private Rect2 _startRect;
public StudioDockDrag(Control frame, Control dock, Control handle, StudioDockEdge edges)
{
_frame = frame;
_dock = dock;
_handle = handle;
_edges = edges;
handle.GuiInput += OnInput;
}
private void OnInput(InputEvent @event)
{
switch (@event)
{
case InputEventMouseButton { ButtonIndex: MouseButton.Left } mouse when mouse.Pressed:
_dragging = true;
_pressedAt = _handle.GetGlobalTransform() * mouse.Position;
_startRect = new Rect2(_frame.Position, _frame.Size);
_handle.AcceptEvent();
break;
case InputEventMouseButton { ButtonIndex: MouseButton.Left } when _dragging:
_dragging = false;
_handle.AcceptEvent();
break;
case InputEventMouseMotion motion when _dragging:
Apply(_handle.GetGlobalTransform() * motion.Position - _pressedAt);
_handle.AcceptEvent();
break;
}
}
private void Apply(Vector2 delta)
{
var bounds = _frame.GetParentAreaSize();
var min = StudioStyles.DockMinSize.Max(_dock.GetCombinedMinimumSize());
var left = _startRect.Position.X;
var top = _startRect.Position.Y;
var right = _startRect.End.X;
var bottom = _startRect.End.Y;
if (_edges == StudioDockEdge.Move)
{
var dx = Mathf.Clamp(delta.X, -left, bounds.X - right);
var dy = Mathf.Clamp(delta.Y, -top, bounds.Y - bottom);
left += dx;
right += dx;
top += dy;
bottom += dy;
}
else
{
if (_edges.HasFlag(StudioDockEdge.Left))
left = Mathf.Clamp(left + delta.X, 0, right - min.X);
if (_edges.HasFlag(StudioDockEdge.Right))
right = Mathf.Clamp(right + delta.X, left + min.X, bounds.X);
if (_edges.HasFlag(StudioDockEdge.Top))
top = Mathf.Clamp(top + delta.Y, 0, bottom - min.Y);
if (_edges.HasFlag(StudioDockEdge.Bottom))
bottom = Mathf.Clamp(bottom + delta.Y, top + min.Y, bounds.Y);
}
_frame.Position = new Vector2(left, top);
_frame.Size = new Vector2(right - left, bottom - top);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
/// <summary>
/// Edges of the dock a <see cref="StudioDockDrag"/> handle moves: one edge
/// or a corner resizes, all four at once (<see cref="Move"/>) shift the
/// dock without changing its size.
/// </summary>
[Flags]
public enum StudioDockEdge
{
None = 0,
Left = 1,
Top = 2,
Right = 4,
Bottom = 8,
Move = Left | Top | Right | Bottom,
}
}
+58
View File
@@ -0,0 +1,58 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Godot;
/// <summary>
/// The dock's small modal questions: a one-line text input (edit a line,
/// name a scene or an exported mod) and a pick list (where an answer
/// leads, which scene to link). They are plain Godot popups themed like the
/// game, added under the dock and freed when closed.
/// </summary>
public static class StudioPrompt
{
/// <summary>Asks for a line of text; <paramref name="done"/> gets it on OK.</summary>
public static ConfirmationDialog AskText(Node parent, string title, string initial, Action<string> done)
{
var dialog = new ConfirmationDialog { Title = title, MinSize = new Vector2I(460, 0) };
CthangoverTheme.Apply(dialog);
dialog.OkButtonText = TranslationServer.Translate("studio/ok");
dialog.CancelButtonText = TranslationServer.Translate("studio/cancel");
var field = new LineEdit { Text = initial ?? string.Empty, SelectAllOnFocus = true };
dialog.AddChild(field);
dialog.RegisterTextEnter(field);
var relay = new StudioPromptRelay(field, done);
dialog.Confirmed += relay.OnConfirmed;
dialog.Confirmed += dialog.QueueFree;
dialog.Canceled += dialog.QueueFree;
dialog.AddChild(relay);
parent.AddChild(dialog);
dialog.PopupCentered();
field.GrabFocus();
return dialog;
}
/// <summary>Shows a pick list under the mouse; <paramref name="picked"/> gets the item id.</summary>
public static PopupMenu Pick(Node parent, PopupMenu.IdPressedEventHandler picked)
{
var menu = new PopupMenu();
CthangoverTheme.Apply(menu);
menu.IdPressed += picked;
menu.PopupHide += menu.QueueFree;
parent.AddChild(menu);
return menu;
}
/// <summary>Opens a pick list built with <see cref="Pick"/> at the mouse position.</summary>
public static void Open(PopupMenu menu)
{
menu.ResetSize();
menu.Position = (Vector2I)menu.GetViewport().GetMousePosition();
menu.Popup();
}
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Godot;
/// <summary>
/// Hands the text field of a <see cref="StudioPrompt.AskText"/> dialog to its
/// callback on OK. A node child of the dialog, so it lives and dies with it
/// and the dialog needs no closure over the field.
/// </summary>
public partial class StudioPromptRelay : Node
{
private readonly LineEdit _field;
private readonly Action<string> _done;
public StudioPromptRelay(LineEdit field, Action<string> done)
{
_field = field;
_done = done;
}
public void OnConfirmed() => _done?.Invoke(_field.Text);
}
}
+53
View File
@@ -0,0 +1,53 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// Novice card of a line the player reads (<c>.ct-say</c>): what kind of line
/// it is (a question or a line, "new" while unapplied) and its text, with
/// "Edit" on the right. The line on screen is the gold card; lines said
/// right before it are plain cards with the green bar.
/// </summary>
public partial class StudioSayView : PanelContainer
{
public StudioSayView(StudioCodeLine line, bool before, Action<StudioActionButton> edit)
{
MouseFilter = MouseFilterEnum.Ignore;
AddThemeStyleboxOverride("panel", StudioStyles.Say(before));
var row = UiKit.Row(StudioStyles.BarGap);
AddChild(row);
row.AddChild(StudioStyles.Bar(before ? UiTokens.Success : UiTokens.Accent));
var body = UiKit.Column(UiTokens.Space1);
body.SizeFlagsHorizontal = SizeFlags.ExpandFill;
row.AddChild(body);
var kind = TranslationServer.Translate(line.Kind == StudioLineKind.Choice ? "studio/say_question" : "studio/say_line").ToString();
if (line.IsNew)
kind = string.Format(TranslationServer.Translate("studio/say_new"), kind);
var caption = StudioStyles.Text(kind, UiTokens.FontTiny, before ? UiTokens.Faint : UiTokens.AccentDim);
caption.Uppercase = true;
body.AddChild(caption);
var text = UiKit.Label(Quote(line), before ? UiTokens.FontBodyLarge : UiTokens.FontLead, before ? UiTokens.Ink : UiTokens.InkBright);
text.AutowrapMode = TextServer.AutowrapMode.WordSmart;
body.AddChild(text);
var button = StudioActionButton.Create("studio/edit", edit);
button.Line = line;
button.SizeFlagsVertical = SizeFlags.ShrinkBegin;
row.AddChild(button);
}
/// <summary>The player-visible text in quotes, or an empty placeholder for a line that has none.</summary>
public static string Quote(StudioCodeLine line)
{
var text = line.Say?.Resolve();
return string.Format(TranslationServer.Translate("studio/quote"), string.IsNullOrEmpty(text) ? "…" : text);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using Cthangover.Core.UI.Theming;
using Godot;
/// <summary>
/// Key/value row of the "State" tab. Editable values (dialog variables,
/// item counts, quest status) are written back when the field is submitted
/// with Enter; the rest is shown read-only.
/// </summary>
public partial class StudioStateRow : HBoxContainer
{
private const int KeyWidth = 170;
private readonly StudioStateEntry _entry;
/// <summary>Raised with the entry and the submitted text.</summary>
public event Action<StudioStateEntry, string> Submitted;
public StudioStateRow(StudioStateEntry entry)
{
_entry = entry;
MouseFilter = MouseFilterEnum.Ignore;
AddThemeConstantOverride("separation", UiTokens.Space3);
var key = StudioStyles.Text(entry.Key.Resolve(), UiTokens.FontBodySmall, UiTokens.ChipKey, mono: true);
key.CustomMinimumSize = new Vector2(KeyWidth, 0);
key.ClipText = true;
key.VerticalAlignment = VerticalAlignment.Center;
AddChild(key);
var value = new LineEdit
{
Text = entry.Value.Resolve(),
Editable = entry.Editor != StudioStateEditor.None,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
value.AddThemeFontOverride("font", StudioStyles.Mono);
value.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
value.TextSubmitted += OnSubmitted;
AddChild(value);
}
private void OnSubmitted(string text) => Submitted?.Invoke(_entry, text);
}
}
+466
View File
@@ -0,0 +1,466 @@
namespace Cthangover.Mods.ScenarioStudio
{
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Godot;
/// <summary>
/// Godot counterparts of the studio components of the style guide
/// (<c>mockups/style_guide</c>, section <c>#studio</c>: <c>.ct-dock</c>,
/// <c>.ct-scope</c>, <c>.ct-code</c>, <c>.ct-block</c>, <c>.ct-cand</c>…).
/// Every colour comes from <see cref="UiTokens"/>; mod scenes ignore theme
/// overrides, so the dock skeleton from the scene is dressed here and the
/// data rows are built with the same helpers.
/// <para/>
/// Two deliberate deviations from the HTML guide, both recorded there:
/// <c>StyleBoxFlat</c> has no dashed border, so the insert bar and new map
/// nodes use a solid low-alpha border instead of a dashed one.
/// </summary>
public static class StudioStyles
{
/// <summary>Width of the dock; the scene offsets use the same value plus the 8px edge gap.</summary>
public const int DockWidth = 480;
/// <summary>Smallest size the dock can be dragged to; its content may still demand more.</summary>
public static readonly Vector2 DockMinSize = new(360, 320);
public static Font Mono => ToolWindow.GetMonospaceFont();
/// <summary>Dock surface: window surface, line border, large radius and the window shadow, without any backdrop.</summary>
public static StyleBoxFlat Dock()
{
var style = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Surface0, UiTokens.Line);
style.ShadowColor = UiTokens.Alpha(Colors.Black, 0.6f);
style.ShadowSize = 24;
style.ShadowOffset = new Vector2(0, 12);
return style;
}
public static Label Title(Label label, string text)
{
label.Text = text.ToUpperInvariant();
label.AddThemeFontSizeOverride("font_size", UiTokens.FontLead);
label.AddThemeColorOverride("font_color", UiTokens.InkBright);
return label;
}
/// <summary>Monospace state plate: running green, paused and held warning yellow, idle muted.</summary>
public static void RunState(PanelContainer plate, Label text, StudioRunState state, string caption)
{
var color = state switch
{
StudioRunState.Running => UiTokens.Success,
StudioRunState.Idle => UiTokens.Muted,
_ => UiTokens.Warning,
};
var style = state == StudioRunState.Idle
? Chip(Colors.Transparent, UiTokens.Line)
: Chip(UiTokens.BadgeFill(color), UiTokens.BadgeLine(color));
plate.AddThemeStyleboxOverride("panel", style);
text.Text = caption.ToUpperInvariant();
text.AddThemeFontOverride("font", Mono);
text.AddThemeFontSizeOverride("font_size", UiTokens.FontTiny);
text.AddThemeColorOverride("font_color", color);
}
/// <summary>Well behind the segmented mode switch.</summary>
public static StyleBoxFlat SegmentWell()
{
var style = UiKit.Frame(UiTokens.RadiusSm, UiTokens.Well, UiTokens.Line);
Pad(style, 2, 2);
return style;
}
/// <summary>Segment of the mode switch: transparent, the pressed one looks like the active tab.</summary>
public static void Segment(Button button, string text)
{
button.Text = text;
var normal = UiKit.Frame(UiTokens.RadiusXs, Colors.Transparent);
Pad(normal, 10, 3);
var hover = UiKit.Frame(UiTokens.RadiusXs, UiTokens.WhiteTrace);
Pad(hover, 10, 3);
var active = UiKit.Frame(UiTokens.RadiusXs, UiTokens.AccentSoft, UiTokens.AccentLine);
Pad(active, 10, 3);
ApplyStates(button, normal, hover, active);
button.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
button.AddThemeColorOverride("font_color", UiTokens.Muted);
button.AddThemeColorOverride("font_hover_color", UiTokens.Ink);
button.AddThemeColorOverride("font_pressed_color", UiTokens.AccentBright);
button.AddThemeColorOverride("font_hover_pressed_color", UiTokens.AccentBright);
}
/// <summary>Transport button (pause/step/continue/restart): a compact secondary button; pressed means "on".</summary>
public static void Transport(Button button, string text)
{
button.Text = text;
var normal = UiKit.Frame(UiTokens.RadiusSm, UiTokens.Surface3, UiTokens.Line);
Pad(normal, 10, 5);
var hover = UiKit.Frame(UiTokens.RadiusSm, UiTokens.BtnHover, UiTokens.AccentLine);
Pad(hover, 10, 5);
var on = UiKit.Frame(UiTokens.RadiusSm, UiTokens.BtnPrimary, UiTokens.AccentLine);
Pad(on, 10, 5);
ApplyStates(button, normal, hover, on);
button.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
button.AddThemeColorOverride("font_color", UiTokens.Body);
button.AddThemeColorOverride("font_hover_color", UiTokens.InkBright);
button.AddThemeColorOverride("font_pressed_color", UiTokens.AccentBright);
button.AddThemeColorOverride("font_hover_pressed_color", UiTokens.AccentBright);
}
/// <summary>Small tab (<c>.ct-tab--sm</c>): the tab variation with the compact paddings.</summary>
public static void SmallTab(Button button, string text)
{
button.Text = text;
var normal = UiKit.Frame(UiTokens.RadiusXs, Colors.Transparent);
Pad(normal, 9, 4);
var hover = UiKit.Frame(UiTokens.RadiusXs, UiTokens.WhiteTrace);
Pad(hover, 9, 4);
var active = UiKit.Frame(UiTokens.RadiusXs, UiTokens.AccentSoft, UiTokens.AccentLine);
Pad(active, 9, 4);
ApplyStates(button, normal, hover, active);
button.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
button.AddThemeColorOverride("font_color", UiTokens.Muted);
button.AddThemeColorOverride("font_hover_color", UiTokens.Ink);
button.AddThemeColorOverride("font_pressed_color", UiTokens.AccentBright);
button.AddThemeColorOverride("font_hover_pressed_color", UiTokens.AccentBright);
}
/// <summary>Small action button inside the body (<c>.ct-btn--sm</c>); primary turns it gold.</summary>
public static Button SmallButton(Button button, string text, bool primary = false)
{
button.Text = text;
var background = primary ? UiTokens.BtnPrimary : UiTokens.Surface3;
var border = primary ? UiTokens.AccentLine : UiTokens.Line;
var normal = UiKit.Frame(UiTokens.RadiusSm, background, border);
Pad(normal, 12, 6);
var hover = UiKit.Frame(UiTokens.RadiusSm, primary ? UiTokens.BtnPrimaryHover : UiTokens.BtnHover, primary ? UiTokens.AccentLineStrong : UiTokens.AccentLine);
Pad(hover, 12, 6);
ApplyStates(button, normal, hover, hover);
button.FocusMode = Control.FocusModeEnum.None;
button.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
button.AddThemeColorOverride("font_color", primary ? UiTokens.AccentBright : UiTokens.Body);
button.AddThemeColorOverride("font_hover_color", primary ? UiTokens.AccentHover : UiTokens.InkBright);
button.AddThemeColorOverride("font_pressed_color", primary ? UiTokens.AccentHover : UiTokens.InkBright);
return button;
}
public static Label Text(string text, int size, Color color, bool mono = false)
{
var label = UiKit.Label(text, size, color);
if (mono)
label.AddThemeFontOverride("font", Mono);
return label;
}
/// <summary>
/// Mod scope badge (<c>.ct-scope</c>): caps kind caption plus the mono mod id,
/// coloured by how the studio may touch the mod.
/// </summary>
public static Control Scope(StudioModScope scope)
{
var color = ScopeColor(scope.Kind);
var style = scope.Kind == StudioScopeKind.ReadOnly
? Chip(Colors.Transparent, UiTokens.Line)
: Chip(UiTokens.BadgeFill(color), UiTokens.BadgeLine(color));
var badge = new PanelContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
badge.AddThemeStyleboxOverride("panel", style);
var row = UiKit.Row(UiTokens.Space2);
var kind = UiKit.Label(TranslationServer.Translate(ScopeKey(scope.Kind)), UiTokens.FontMicro, color);
kind.Uppercase = true;
kind.VerticalAlignment = VerticalAlignment.Center;
row.AddChild(kind);
row.AddChild(Text(scope.ModId, UiTokens.FontSmall, color, mono: true));
badge.AddChild(row);
return badge;
}
public static Color ScopeColor(StudioScopeKind kind)
{
switch (kind)
{
case StudioScopeKind.Own:
return UiTokens.SuccessText;
case StudioScopeKind.Overlay:
return UiTokens.Info;
case StudioScopeKind.Layer:
return UiTokens.TypeTarget;
default:
return UiTokens.Faint;
}
}
private static string ScopeKey(StudioScopeKind kind)
{
switch (kind)
{
case StudioScopeKind.Own:
return "studio/scope_own";
case StudioScopeKind.Overlay:
return "studio/scope_overlay";
case StudioScopeKind.Layer:
return "studio/scope_layer";
default:
return "studio/scope_readonly";
}
}
/// <summary>"This change affects" plate: warning-tinted, lists every mod the action writes to or overrides.</summary>
public static StyleBoxFlat ScopeBanner()
{
var style = UiKit.Frame(UiTokens.RadiusSm, UiTokens.BadgeFill(UiTokens.Warning), UiTokens.BadgeLine(UiTokens.Warning));
Pad(style, 12, 8);
return style;
}
/// <summary>Well behind the script lines (<c>.ct-code</c>).</summary>
public static StyleBoxFlat CodeWell()
{
var style = UiKit.Frame(UiTokens.RadiusSm, UiTokens.Well, UiTokens.Line);
Pad(style, 0, 4);
return style;
}
/// <summary>Row of one script line: a 2px left bar and a tint carry current / new / error.</summary>
public static StyleBoxFlat CodeLine(StudioCodeLine line)
{
Color background = Colors.Transparent;
Color bar = Colors.Transparent;
if (line.IsCurrent)
{
background = UiTokens.AccentSoft;
bar = UiTokens.Accent;
}
else if (line.Error != null)
{
background = UiTokens.DangerSoft;
bar = UiTokens.Danger;
}
else if (line.IsNew)
{
background = UiTokens.SuccessSoft;
bar = UiTokens.Success;
}
var style = new StyleBoxFlat { BgColor = background, BorderColor = bar, BorderWidthLeft = 2 };
Pad(style, 0, 1);
style.ContentMarginRight = UiTokens.Space4;
return style;
}
/// <summary>Row the dragged ▶ marker would land on: the current-line tint with a full accent outline.</summary>
public static StyleBoxFlat CodeLineDropTarget()
{
var style = new StyleBoxFlat { BgColor = UiTokens.AccentSoft, BorderColor = UiTokens.AccentBright };
style.SetBorderWidthAll(1);
style.BorderWidthLeft = 2;
Pad(style, 0, 1);
style.ContentMarginRight = UiTokens.Space4;
return style;
}
/// <summary>Breakpoint dot of the gutter; transparent when the line has none.</summary>
public static StyleBoxFlat Breakpoint(bool on)
{
var style = UiKit.Frame(4, on ? UiTokens.Danger : Colors.Transparent);
if (on)
{
style.ShadowColor = UiTokens.DangerSoft;
style.ShadowSize = 2;
}
return style;
}
/// <summary>Section caption of the novice moment (<c>.ct-moment__caption</c>).</summary>
public static Label Caption(string text)
{
var label = Text(text, UiTokens.FontTiny, UiTokens.Faint);
label.Uppercase = true;
return label;
}
/// <summary>
/// Card of what is on screen (<c>.ct-say</c>): selected surface with the gold
/// bar; a line added before it (<c>.ct-say--before</c>) is a plain card
/// with the success bar.
/// </summary>
public static StyleBoxFlat Say(bool before)
{
var style = before
? UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line)
: UiKit.Frame(UiTokens.RadiusMd, UiTokens.SurfaceSelected, UiTokens.AccentLine);
Pad(style, 12, 10);
style.ContentMarginLeft = 0;
return style;
}
/// <summary>
/// Coloured 3px left bar of a card (gold for the line on screen, green for
/// new lines). StyleBoxFlat has a single border colour, so the bar is its
/// own strip at the start of the card row; the card style then has no left
/// content margin and the bar is followed by <see cref="BarGap"/>.
/// </summary>
public static ColorRect Bar(Color color) => new()
{
Color = color,
CustomMinimumSize = new Vector2(3, 0),
MouseFilter = Control.MouseFilterEnum.Ignore,
};
/// <summary>Space between a <see cref="Bar"/> and the card content.</summary>
public const int BarGap = 9;
/// <summary>Answer row (<c>.ct-branch</c>): broken answers are red-tinted, new ones get the success bar.</summary>
public static StyleBoxFlat Branch(bool broken, bool isNew)
{
var style = broken
? UiKit.Frame(UiTokens.RadiusMd, UiTokens.DangerSoft, UiTokens.BadgeLine(UiTokens.TypeEnemy))
: UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line);
Pad(style, 12, 8);
if (isNew)
style.ContentMarginLeft = 0;
return style;
}
public static Color OutcomeColor(StudioOutcomeKind kind)
{
switch (kind)
{
case StudioOutcomeKind.Scene:
return UiTokens.TypeTarget;
case StudioOutcomeKind.Action:
return UiTokens.TypeUsed;
case StudioOutcomeKind.Broken:
return UiTokens.DangerText;
default:
return UiTokens.Muted;
}
}
/// <summary>Insert bar (<c>.ct-insert</c>); solid accent 20% border because StyleBoxFlat cannot dash.</summary>
public static StyleBoxFlat InsertBar()
{
var style = UiKit.Frame(UiTokens.RadiusSm, Colors.Transparent, UiTokens.AccentLineWeak);
Pad(style, 8, 6);
return style;
}
/// <summary>Scene candidate card (<c>.ct-cand</c>); the running one gets the accent border.</summary>
public static StyleBoxFlat Candidate(bool running)
{
var style = UiKit.Frame(UiTokens.RadiusMd, running ? UiTokens.SurfaceSelected : UiTokens.Surface2, running ? UiTokens.AccentLine : UiTokens.Line);
Pad(style, 12, 8);
return style;
}
public static Color StatusColor(StudioCandidateStatus status)
{
switch (status)
{
case StudioCandidateStatus.Running:
return UiTokens.Success;
case StudioCandidateStatus.Queued:
return UiTokens.Warning;
case StudioCandidateStatus.Failed:
return UiTokens.Danger;
case StudioCandidateStatus.Skipped:
return UiTokens.TypeEnemy;
case StudioCandidateStatus.Completed:
return UiTokens.TypeResource;
default:
return UiTokens.Muted;
}
}
public static Color LogColor(StudioLogCategory category)
{
switch (category)
{
case StudioLogCategory.Scene:
return UiTokens.TypeTarget;
case StudioLogCategory.Skip:
return UiTokens.TypeUsed;
case StudioLogCategory.Stop:
return UiTokens.TypeEnemy;
case StudioLogCategory.End:
return UiTokens.Muted;
case StudioLogCategory.Edit:
return UiTokens.Success;
default:
return UiTokens.Accent;
}
}
/// <summary>Map node card (<c>.ct-node</c>): here glows gold, visited is strong-lined, new is teal.</summary>
public static StyleBoxFlat Node(StudioNodeState state)
{
StyleBoxFlat style;
switch (state)
{
case StudioNodeState.Here:
style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.SurfaceSelected, UiTokens.AccentLineStrong);
style.ShadowColor = UiTokens.Alpha(UiTokens.Accent, 0.35f);
style.ShadowSize = 8;
break;
case StudioNodeState.Visited:
style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.LineStrong);
break;
case StudioNodeState.New:
style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.BadgeLine(UiTokens.TypeTarget));
break;
default:
style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line);
break;
}
Pad(style, 12, 8);
return style;
}
/// <summary>Rows of the state tab: inset well like the inventory quantity block.</summary>
public static StyleBoxFlat Well()
{
var style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line);
Pad(style, 12, 10);
return style;
}
/// <summary>Bottom hairline between log rows.</summary>
public static StyleBoxFlat LogRow(bool warning)
{
var style = new StyleBoxFlat
{
BgColor = warning ? UiTokens.BadgeFill(UiTokens.Warning) : Colors.Transparent,
BorderColor = UiTokens.LineSoft,
BorderWidthBottom = 1,
};
Pad(style, 8, 5);
return style;
}
private static StyleBoxFlat Chip(Color fill, Color border)
{
var style = UiKit.Frame(UiTokens.RadiusXs, fill, border);
Pad(style, 7, 2);
return style;
}
private static void ApplyStates(Button button, StyleBox normal, StyleBox hover, StyleBox pressed)
{
button.AddThemeStyleboxOverride("normal", normal);
button.AddThemeStyleboxOverride("hover", hover);
button.AddThemeStyleboxOverride("pressed", pressed);
button.AddThemeStyleboxOverride("hover_pressed", pressed);
button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty());
}
private static void Pad(StyleBoxFlat style, int horizontal, int vertical)
{
style.ContentMarginLeft = horizontal;
style.ContentMarginRight = horizontal;
style.ContentMarginTop = vertical;
style.ContentMarginBottom = vertical;
}
}
}
+115
View File
@@ -0,0 +1,115 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Text;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Dialog.Scenarios;
using Godot;
/// <summary>
/// Colours one scenario DSL line as BBCode with the studio palette of the
/// style guide: command word gold, quoted text ink, <c>:labels</c> teal,
/// <c>name=</c> keys chip-key, comments faint. A light single-pass scanner
/// is enough for display: it never fails on malformed lines, it just
/// colours less of them, and the real parser stays the only judge of
/// validity.
/// </summary>
public static class StudioSyntax
{
/// <summary>
/// Highlighter for the inline text editor with the same palette: every
/// registered command word (plus <c>select</c>/<c>option</c>) gold, quoted
/// text ink, comments faint, <c>:labels</c> teal.
/// </summary>
public static CodeHighlighter CreateHighlighter()
{
var highlighter = new CodeHighlighter
{
NumberColor = UiTokens.Body,
SymbolColor = UiTokens.Muted,
FunctionColor = UiTokens.Body,
MemberVariableColor = UiTokens.Body,
};
foreach (var command in ScenarioCommandStrategyFactory.GetAllCommandNames())
highlighter.AddKeywordColor(command, UiTokens.AccentBright);
highlighter.AddKeywordColor("select", UiTokens.AccentBright);
highlighter.AddKeywordColor("option", UiTokens.AccentBright);
highlighter.AddColorRegion("\"", "\"", UiTokens.Ink);
highlighter.AddColorRegion("#", "", UiTokens.Faint, true);
highlighter.AddColorRegion(":", " ", UiTokens.TypeTarget, true);
return highlighter;
}
public static string ToBbCode(string line)
{
var text = line ?? string.Empty;
var trimmed = text.TrimStart();
var indent = text.Substring(0, text.Length - trimmed.Length);
var result = new StringBuilder(Escape(indent));
if (trimmed.StartsWith('#'))
return result.Append(Paint(trimmed, UiTokens.Faint)).ToString();
if (trimmed.StartsWith(':'))
return result.Append(Paint(trimmed, UiTokens.TypeTarget)).ToString();
var i = 0;
var first = true;
while (i < trimmed.Length)
{
var c = trimmed[i];
if (c == ' ')
{
result.Append(' ');
i++;
continue;
}
if (c == '#')
{
result.Append(Paint(trimmed.Substring(i), UiTokens.Faint));
break;
}
var end = c == '"' ? CloseQuote(trimmed, i) : NextSpace(trimmed, i);
var token = trimmed.Substring(i, end - i);
result.Append(PaintToken(token, first));
first = false;
i = end;
}
return result.ToString();
}
private static string PaintToken(string token, bool first)
{
if (first)
return Paint(token, UiTokens.AccentBright);
if (token.StartsWith('"'))
return Paint(token, UiTokens.Ink);
if (token.StartsWith(':'))
return Paint(token, UiTokens.TypeTarget);
if (token == "->")
return Paint(token, UiTokens.Muted);
var eq = token.IndexOf('=');
if (eq > 0)
return Paint(token.Substring(0, eq + 1), UiTokens.ChipKey) + Paint(token.Substring(eq + 1), UiTokens.Body);
return Paint(token, UiTokens.Body);
}
private static int CloseQuote(string text, int start)
{
var close = text.IndexOf('"', start + 1);
return close < 0 ? text.Length : close + 1;
}
private static int NextSpace(string text, int start)
{
var space = text.IndexOf(' ', start);
return space < 0 ? text.Length : space;
}
private static string Paint(string text, Color color) => $"[color=#{color.ToHtml(false)}]{Escape(text)}[/color]";
private static string Escape(string text) => text.Replace("[", "[lb]");
}
}