init mod
This commit is contained in:
commit
7a088b25a9
58 files changed
+6053
No files matched your search
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>-> :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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<game>/</c>,
|
||||
/// or <c>--studio-layer=<dir></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/<modId>/</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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user