init mod
This commit is contained in:
commit
7a088b25a9
58 files changed
+6053
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user