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

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using System.Collections.Generic;
using Cthangover.Mods.Dialog;
using Cthangover.Mods.Dialog.Action;
using Cthangover.Mods.Dialog.Executable;
/// <summary>
/// The studio's <see cref="IDialogDebugger"/>: pause, step and breakpoints
/// for scenario dialogs. Only dialogs shown in the <see cref="DialogBox"/>
/// with a <see cref="ScenarioEvent"/> as locker are held — standalone
/// runtimes (hover commands) and click dialogs cannot be resumed and always
/// run through. Breakpoints are keyed by scenario name, the identity that
/// survives an edit moving the file into the studio layer.
/// <para/>
/// "Pause" holds the dialog before the next action; "continue" and "step"
/// let exactly one action pass the hold (<c>allowOnce</c>), step then holds
/// again. The first action of every new scenario event raises
/// <see cref="ScenarioStarted"/> before anything runs, which is when the
/// rollback snapshot is taken.
/// </summary>
public sealed class StudioDebugger : IDialogDebugger
{
private readonly Dictionary<string, HashSet<int>> _breakpoints = new();
private readonly HashSet<int> _executed = new();
private int _allowOnce;
/// <summary>The scenario event the debugger last saw run.</summary>
public ScenarioEvent Event { get; private set; }
public bool IsPaused { get; private set; }
/// <summary>Scenario line the dialog is held at, 0 when not held.</summary>
public int HeldLine { get; private set; }
/// <summary>Raised before the first action of a new scenario event runs.</summary>
public event Action<ScenarioEvent> ScenarioStarted;
/// <summary>Raised when the dialog is held at a line (pause or breakpoint).</summary>
public event Action<int, bool> Held;
/// <summary>Raised after any change worth redrawing (an action ran, a hold, a toggle).</summary>
public event Action Changed;
public bool ShouldHold(DialogRuntime runtime, IActionCommand action)
{
if (runtime.DialogBox == null || runtime.DialogBox.Locker is not ScenarioEvent scenario)
return false;
if (scenario != Event)
{
Event = scenario;
_executed.Clear();
HeldLine = 0;
ScenarioStarted?.Invoke(scenario);
}
if (_allowOnce > 0)
{
_allowOnce--;
return Pass(action);
}
var breakpoint = action.SourceLine > 0 && HasBreakpoint(scenario.ID, action.SourceLine);
if (!IsPaused && !breakpoint)
return Pass(action);
IsPaused = true;
HeldLine = action.SourceLine;
Held?.Invoke(action.SourceLine, breakpoint);
Changed?.Invoke();
return true;
}
public bool IsExecuted(int line) => _executed.Contains(line);
public bool HasBreakpoint(string scenario, int line) =>
scenario != null && _breakpoints.TryGetValue(scenario, out var lines) && lines.Contains(line);
public void ToggleBreakpoint(string scenario, int line)
{
if (string.IsNullOrEmpty(scenario))
return;
if (!_breakpoints.TryGetValue(scenario, out var lines))
_breakpoints[scenario] = lines = new HashSet<int>();
if (!lines.Remove(line))
lines.Add(line);
Changed?.Invoke();
}
/// <summary>Turns pause on: the next action of a scenario dialog will be held.</summary>
public void Pause()
{
IsPaused = true;
Changed?.Invoke();
}
/// <summary>
/// Turns pause off and lets the held action run (<paramref name="box"/>
/// resumes it); without a hold it only clears the pause.
/// </summary>
public void Continue(DialogBox box)
{
IsPaused = false;
ReleaseHold(box);
Changed?.Invoke();
}
/// <summary>
/// Runs exactly one action and holds before the next. When the dialog is
/// not held but waits for a click, the click is made for the user.
/// </summary>
public void Step(DialogBox box)
{
IsPaused = true;
if (box == null || !box.IsRunning)
return;
if (box.Runtime.IsHeld)
{
ReleaseHold(box);
}
else if (!box.IsWaitingAnswer)
{
_allowOnce = 1;
box.NextAction();
}
Changed?.Invoke();
}
/// <summary>
/// "Set next statement" of an IDE: the action of scenario line
/// <paramref name="line"/> becomes the next one to run and the dialog is
/// held before it in pause, whether it was held, waited for a click or
/// for an answer. Skipped and re-run lines change nothing in the game
/// state on their own. False when the line has no action in the queue
/// of the running dialog (a comment, an answer, a line added in the studio).
/// </summary>
public bool SetNext(DialogBox box, int line)
{
var queue = box?.Runtime.Dialog?.Queue;
if (line <= 0 || queue == null || !box.IsRunning || box.Locker != Event)
return false;
var target = IndexOf(queue, line);
if (target < 0 || !box.MoveTo(target))
return false;
IsPaused = true;
HeldLine = line;
_allowOnce = 0;
Changed?.Invoke();
return true;
}
/// <summary>Forgets the current event, e.g. before restarting it.</summary>
public void Reset()
{
Event = null;
HeldLine = 0;
_allowOnce = 0;
_executed.Clear();
}
/// <summary>Queue index of the first action built from scenario line <paramref name="line"/>, -1 when none.</summary>
public static int IndexOf(List<IActionCommand> queue, int line)
{
for (var i = 0; i < queue.Count; i++)
{
if (queue[i]?.SourceLine == line)
return i;
}
return -1;
}
private void ReleaseHold(DialogBox box)
{
if (box == null || !box.Runtime.IsHeld)
return;
HeldLine = 0;
_allowOnce = 1;
box.Resume();
}
private bool Pass(IActionCommand action)
{
if (action.SourceLine > 0)
_executed.Add(action.SourceLine);
Changed?.Invoke();
return false;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
using Cthangover.Core.Events;
using Cthangover.Core.Scenes;
using Godot;
/// <summary>
/// What happened in the session, for the "Log", "Scene" and "Map" tabs:
/// log entries (scene entries, skipped candidates with the reason, scenario
/// starts and ends, breakpoints, studio edits), the order of visited scenes
/// and the last composition report of every scene. Fed by the mod event
/// bus and by <see cref="StudioLive"/>; times are session time (mm:ss since
/// the studio started).
/// </summary>
public sealed class StudioJournal
{
private const int MaxEntries = 300;
private readonly ulong _startMsec = Time.GetTicksMsec();
private readonly Dictionary<string, SceneComposedEvent> _composed = new();
public List<StudioLogEntry> Entries { get; } = new();
/// <summary>Scenes in the order they were entered (repeats included).</summary>
public List<string> SceneHistory { get; } = new();
public void Add(StudioLogCategory category, StudioText text, bool warning = false)
{
Entries.Add(new StudioLogEntry { Time = Now(), Category = category, Text = text, IsWarning = warning });
if (Entries.Count > MaxEntries)
Entries.RemoveAt(0);
}
/// <summary>Last composition report of <paramref name="scene"/>, or null.</summary>
public SceneComposedEvent Composed(string scene) =>
scene != null && _composed.TryGetValue(scene, out var report) ? report : null;
public void OnSceneEntered(SceneEnteredEvent e)
{
SceneHistory.Add(e.SceneName);
Add(StudioLogCategory.Scene, new StudioText("studio/log_text_enter", e.SceneName));
}
public void OnSceneComposed(SceneComposedEvent e)
{
_composed[e.SceneName] = e;
foreach (var candidate in e.Candidates)
{
switch (candidate.Outcome)
{
case SceneCandidateOutcome.ConditionFalse:
Add(StudioLogCategory.Skip, new StudioText("studio/log_text_condition", candidate.Id), true);
break;
case SceneCandidateOutcome.Completed:
Add(StudioLogCategory.Skip, new StudioText("studio/log_text_completed", candidate.Id), true);
break;
case SceneCandidateOutcome.Shadowed:
Add(StudioLogCategory.Skip, new StudioText("studio/log_text_shadowed", candidate.Id, candidate.ModId), true);
break;
case SceneCandidateOutcome.Failed:
Add(StudioLogCategory.Skip, new StudioText("studio/log_text_failed", candidate.Id), true);
break;
}
}
}
public void OnDialogEnded(DialogEndedEvent e)
{
if (!string.IsNullOrEmpty(e.LockerId))
Add(StudioLogCategory.End, new StudioText("studio/log_text_ended", e.LockerId));
}
private string Now()
{
var seconds = (Time.GetTicksMsec() - _startMsec) / 1000;
return $"{seconds / 60:00}:{seconds % 60:00}";
}
}
}
+161
View File
@@ -0,0 +1,161 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
using System.IO;
using System.Text;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Games;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Providers;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Godot;
/// <summary>
/// The studio's edit layer: a folder per game (<c>user://studio/&lt;game&gt;/</c>,
/// or <c>--studio-layer=&lt;dir&gt;</c>) laid out like a mod (<c>scenarios/</c>,
/// <c>scenes/</c>) and mounted as content overlay <see cref="Id"/> through
/// <see cref="ModOverlays"/>. Scenarios written here override same-named
/// scenarios of any mod, so a player can change content of read-only zip
/// mods without touching them; new scenes and scenarios live here too.
/// <para/>
/// Edits of folder mods go into the mod itself (the author's workflow)
/// unless <c>--studio-write=layer</c> forces the layer — tests use it to
/// keep the repository untouched. <see cref="Export"/> turns the layer into
/// a regular mod in <c>user://mods/</c>.
/// </summary>
public sealed class StudioLayer
{
public const string Id = "my_studio";
private const string LayerArg = "--studio-layer=";
private const string WriteArg = "--studio-write=layer";
/// <summary>Absolute path of the layer folder.</summary>
public string RootPath { get; }
/// <summary>True when every edit goes to the layer, folder mods included.</summary>
public bool ForceLayer { get; }
public StudioLayer(string[] args)
{
string dir = null;
foreach (var arg in args)
{
if (arg.StartsWith(LayerArg, System.StringComparison.Ordinal))
dir = arg.Substring(LayerArg.Length);
if (arg == WriteArg)
ForceLayer = true;
}
dir ??= "user://studio";
var game = string.IsNullOrEmpty(ActiveGame.Id) ? "dev" : ActiveGame.Id;
RootPath = Path.Combine(ProjectSettings.GlobalizePath(dir), game);
}
/// <summary>Creates the folder if needed, mounts it as overlay and refreshes scenario and scene tables.</summary>
public void Mount()
{
Directory.CreateDirectory(Path.Combine(RootPath, "scenarios"));
Directory.CreateDirectory(Path.Combine(RootPath, "scenes"));
ModOverlays.Mount(Id, new FolderModFileProvider(RootPath, Id));
RefreshContent();
GameLogger.Log("UI", $"Scenario studio: layer '{Id}' mounted at {RootPath}");
}
/// <summary>Writes a layer file (mod-relative path) and returns its absolute path.</summary>
public string Write(string relativePath, string text)
{
var path = Path.Combine(RootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, text);
return path;
}
/// <summary>
/// Creates scene <paramref name="name"/> with the given backgrounds and a
/// first scenario for it; returns the scenario name.
/// </summary>
public string CreateScene(string name, IReadOnlyList<string> backgrounds, string firstLine)
{
var json = new StringBuilder("{\n \"name\": \"").Append(name).Append('"');
if (backgrounds != null && backgrounds.Count > 0)
{
json.Append(",\n \"defaultBackground\": [");
for (var i = 0; i < backgrounds.Count; i++)
json.Append(i == 0 ? "" : ", ").Append('"').Append(backgrounds[i]).Append('"');
json.Append(']');
}
json.Append("\n}\n");
Write($"scenes/{name}.json", json.ToString());
return CreateScenario(name, firstLine);
}
/// <summary>Creates a new scenario for scene <paramref name="scene"/> with one line; returns its name.</summary>
public string CreateScenario(string scene, string firstLine)
{
var name = scene + "_studio";
for (var n = 2; File.Exists(ScenarioFile(name)); n++)
name = $"{scene}_studio_{n}";
Write($"scenarios/{name}.scenario", $"scene: {scene}\npriority: 50\n---\n{firstLine}\nend\n");
RefreshContent();
return name;
}
/// <summary>Scene names defined by the layer itself.</summary>
public List<string> SceneNames()
{
var result = new List<string>();
var dir = Path.Combine(RootPath, "scenes");
if (!Directory.Exists(dir))
return result;
foreach (var file in Directory.GetFiles(dir, "*.json"))
result.Add(Path.GetFileNameWithoutExtension(file));
return result;
}
/// <summary>
/// Copies the layer into <c>user://mods/&lt;modId&gt;/</c> with a manifest, so
/// the edits become a regular mod that can be added to a game's collection.
/// Returns the folder written.
/// </summary>
public string Export(string modId)
{
var target = Path.Combine(ProjectSettings.GlobalizePath("user://mods/"), modId);
CopyTree(RootPath, target);
var manifest = "{\n" +
$" \"id\": \"{modId}\",\n" +
$" \"name\": \"{modId}\",\n" +
" \"version\": \"0.1.0\",\n" +
" \"author\": \"studio\",\n" +
" \"description\": \"Exported from the scenario studio layer.\",\n" +
" \"category\": \"content\",\n" +
" \"depends\": [\"dialog_base>=1\"]\n" +
"}\n";
File.WriteAllText(Path.Combine(target, "manifest.json"), manifest);
GameLogger.Log("UI", $"Scenario studio: layer exported as mod '{modId}' to {target}");
return target;
}
/// <summary>Makes the next scene composition and the scene table see changed scenario and scene files.</summary>
public static void RefreshContent()
{
ScenarioDefs.Invalidate();
ModManager.Instance.GetResolver<SceneDefinitionResolver>()?.ClearCache();
AppServices.Current.SceneDefs.Clear();
SceneManager.RescanScenes();
}
private string ScenarioFile(string name) => Path.Combine(RootPath, "scenarios", name + ".scenario");
private static void CopyTree(string from, string to)
{
Directory.CreateDirectory(to);
foreach (var file in Directory.GetFiles(from))
File.Copy(file, Path.Combine(to, Path.GetFileName(file)), true);
foreach (var dir in Directory.GetDirectories(from))
CopyTree(dir, Path.Combine(to, Path.GetFileName(dir)));
}
}
}
+151
View File
@@ -0,0 +1,151 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
using Cthangover.Mods.Dialog.Scenarios;
/// <summary>
/// Reads scenario text into <see cref="StudioCodeLine"/>s for the dock: one
/// entry per non-empty line, numbered like the parser numbers
/// <c>SourceLine</c> (1-based, header included), classified by command
/// with the player-visible text (<see cref="StudioCodeLine.Say"/>: the
/// literal, or the line's own <c>key=</c> translated when shown) and the
/// line's target. Lines are split by <see cref="ScenarioParser.SplitLine"/>
/// so the dock never disagrees with the engine; on top of the parser it
/// reports what the parser silently skips — unknown commands, answers and
/// jumps to missing labels.
/// </summary>
public static class StudioLineReader
{
public static List<StudioCodeLine> Read(string text)
{
var result = new List<StudioCodeLine>();
if (string.IsNullOrEmpty(text))
return result;
var lines = text.Replace("\r\n", "\n").Split('\n');
var headerEnd = HeaderEnd(lines);
var labels = new HashSet<string>();
for (var i = 0; i < lines.Length; i++)
{
var trimmed = lines[i].Trim();
if (trimmed.Length == 0)
continue;
var line = new StudioCodeLine { Number = i + 1, Text = lines[i].TrimEnd() };
if (i <= headerEnd)
line.Kind = StudioLineKind.Header;
else
Classify(line, trimmed);
if (line.Kind == StudioLineKind.Label)
labels.Add(line.Target);
result.Add(line);
}
foreach (var line in result)
CheckTarget(line, labels);
return result;
}
private static int HeaderEnd(string[] lines)
{
for (var i = 0; i < lines.Length; i++)
{
if (lines[i].Trim() == "---")
return i;
}
return -1;
}
private static void Classify(StudioCodeLine line, string trimmed)
{
if (trimmed.StartsWith('#'))
{
line.Kind = StudioLineKind.Comment;
return;
}
if (trimmed.StartsWith(':'))
{
line.Kind = StudioLineKind.Label;
line.Target = trimmed.Substring(1).Trim();
return;
}
var positional = new List<string>();
var named = new Dictionary<string, string>();
var cmd = ScenarioParser.SplitLine(trimmed, positional, named, out var arrow);
switch (cmd)
{
case "text":
case "ptext":
line.Kind = StudioLineKind.Line;
line.Say = SayOf(positional, named);
return;
case "select":
line.Kind = StudioLineKind.Choice;
line.Say = SayOf(positional, named);
return;
case "option":
line.Kind = StudioLineKind.Option;
line.Say = SayOf(positional, named);
line.Target = OptionTarget(positional, named, arrow);
return;
case "goto":
line.Kind = StudioLineKind.Goto;
line.Target = !string.IsNullOrEmpty(arrow) ? arrow : named.GetValueOrDefault("target");
return;
case "switch_scene":
line.Kind = StudioLineKind.Jump;
line.Target = positional.Count > 0 ? positional[0] : null;
return;
case "action":
line.Kind = StudioLineKind.Action;
line.Target = positional.Count > 0 ? positional[0] : null;
return;
case "end":
line.Kind = StudioLineKind.End;
return;
case "light_set":
case "light_use_time":
line.Kind = StudioLineKind.Light;
return;
case "background":
case "foreground":
line.Kind = StudioLineKind.Background;
return;
}
line.Kind = StudioLineKind.Command;
if (cmd != null && ScenarioCommandStrategyFactory.Get(cmd) == null)
line.Error = new StudioText("studio/error_unknown_command", cmd);
}
private static StudioText SayOf(List<string> positional, Dictionary<string, string> named)
{
if (named.TryGetValue("key", out var key) && !string.IsNullOrEmpty(key))
return new StudioText(key);
return positional.Count > 0 ? StudioText.Raw(positional[0]) : null;
}
private static string OptionTarget(List<string> positional, Dictionary<string, string> named, string arrow)
{
if (!string.IsNullOrEmpty(arrow))
return arrow;
if (named.TryGetValue("goto", out var target))
return target;
return positional.Count > 1 && positional[1].StartsWith(':') ? positional[1].TrimStart(':') : null;
}
private static void CheckTarget(StudioCodeLine line, HashSet<string> labels)
{
if (line.Kind != StudioLineKind.Option && line.Kind != StudioLineKind.Goto)
return;
if (string.IsNullOrEmpty(line.Target))
line.Error ??= new StudioText("studio/error_no_target");
else if (!labels.Contains(line.Target))
line.Error ??= new StudioText("studio/error_label_missing", ":" + line.Target);
}
}
}
+571
View File
@@ -0,0 +1,571 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Events;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Quests;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.Utils;
using Cthangover.Mods.Dialog;
using Cthangover.Mods.Dialog.Executable;
using Godot;
/// <summary>
/// The studio's connection to the running game, parked at <c>/root</c> for
/// the whole session (the dock comes and goes, this stays). It installs
/// the <see cref="StudioDebugger"/> into the dialog runtime, keeps the
/// <see cref="StudioJournal"/> fed from the event bus, owns the edit
/// <see cref="StudioLayer"/>, the per-scenario <see cref="StudioWorkingCopy"/>s
/// and the rollback <see cref="StudioSnapshot"/> of the running scenario, and
/// performs every action of the dock: apply edits, the restart modes, run a
/// scene candidate, go to a scene, create scenes and scenarios, change
/// state values, export the layer.
/// <para/>
/// <see cref="Version"/> grows on every change the dock may want to show;
/// the dock polls it instead of subscribing, so a burst of dialog actions
/// costs one redraw.
/// </summary>
public partial class StudioLive : Node
{
private const string ChainGroup = "main_event_chain";
private static readonly Regex ModIdPattern = new("^[a-z][a-z0-9_]*$");
private readonly Dictionary<string, StudioWorkingCopy> _copies = new();
private StudioSnapshot _snapshot;
private StudioSnapshot _checkpoint;
public static StudioLive Instance { get; private set; }
public StudioDebugger Debugger { get; } = new();
public StudioJournal Journal { get; } = new();
public StudioLayer Layer { get; }
/// <summary>Grows on every change worth redrawing.</summary>
public int Version { get; private set; }
public StudioLive(StudioLayer layer)
{
Name = "ScenarioStudioLive";
Layer = layer;
}
public override void _EnterTree()
{
Instance = this;
DialogRuntime.Debugger = Debugger;
Debugger.ScenarioStarted += OnScenarioStarted;
Debugger.Held += OnHeld;
Debugger.Changed += Touch;
ModEventBus.Subscribe<SceneEnteredEvent>(OnSceneEntered, priority: 50);
ModEventBus.Subscribe<SceneComposedEvent>(OnSceneComposed);
ModEventBus.Subscribe<DialogEndedEvent>(OnDialogEnded);
}
public override void _ExitTree()
{
if (DialogRuntime.Debugger == Debugger)
DialogRuntime.Debugger = null;
ModEventBus.Unsubscribe<SceneEnteredEvent>(OnSceneEntered);
ModEventBus.Unsubscribe<SceneComposedEvent>(OnSceneComposed);
ModEventBus.Unsubscribe<DialogEndedEvent>(OnDialogEnded);
if (Instance == this)
Instance = null;
}
// ── what is running ────────────────────────────────────────
public DialogBox Box => SceneContextNode.FindNode<DialogBox>("DialogBox");
public static string SceneName => SceneManager.GetCurrentSceneName();
/// <summary>The scenario in the dialog box, or the last one the debugger saw while it still exists.</summary>
public ScenarioEvent CurrentEvent
{
get
{
var box = Box;
if (box != null && box.IsRunning && box.Locker is ScenarioEvent running)
return running;
var last = Debugger.Event;
return last != null && IsInstanceValid(last) ? last : null;
}
}
/// <summary>True while <see cref="CurrentEvent"/> is the dialog in the box.</summary>
public bool IsCurrentRunning
{
get
{
var box = Box;
return box != null && box.IsRunning && box.Locker == CurrentEvent;
}
}
/// <summary>Scenario line of the action the running dialog stands on; 0 when nothing runs.</summary>
public int CurrentLine()
{
if (!IsCurrentRunning)
return 0;
var runtime = Box.Runtime;
var queue = runtime.Dialog?.Queue;
var index = runtime.Index;
return queue != null && index >= 0 && index < queue.Count ? queue[index]?.SourceLine ?? 0 : 0;
}
/// <summary>True when the running dialog has an action built from scenario line <paramref name="line"/>, so it can run next.</summary>
public bool CanRunFrom(int line)
{
var queue = IsCurrentRunning ? Box.Runtime.Dialog?.Queue : null;
return line > 0 && queue != null && StudioDebugger.IndexOf(queue, line) >= 0;
}
/// <summary>Makes scenario line <paramref name="line"/> the next one to run (<see cref="StudioDebugger.SetNext"/>) and logs it.</summary>
public bool SetNextLine(int line)
{
if (!Debugger.SetNext(Box, line))
return false;
Journal.Add(StudioLogCategory.Stop, new StudioText("studio/log_text_next_line", line));
return true;
}
/// <summary>Current text of a scenario event's file (edits applied so far), or its built-in text.</summary>
public static string SourceText(ScenarioEvent evt)
{
if (evt == null)
return null;
if (!string.IsNullOrEmpty(evt.SourceModId))
return ScenarioDefs.ReadText(evt.SourceModId, evt.SourcePath) ?? evt.ScenarioText;
return evt.ScenarioText;
}
/// <summary>The mod (or layer) a scenario file belongs to and how the studio may touch it.</summary>
public StudioModScope ScopeOf(string modId, string path)
{
if (string.IsNullOrEmpty(modId))
return new StudioModScope("inline", StudioScopeKind.ReadOnly);
if (modId == StudioLayer.Id)
return new StudioModScope(modId, StudioScopeKind.Layer);
return string.IsNullOrEmpty(ModManager.Instance.GetFileSystemPath(modId, path))
? new StudioModScope(modId, StudioScopeKind.Overlay)
: new StudioModScope(modId, StudioScopeKind.Own);
}
/// <summary>Where an edit of a file in <paramref name="fileScope"/> is written.</summary>
public StudioModScope EditScopeFor(StudioModScope fileScope)
{
if (fileScope.Kind == StudioScopeKind.Own && !Layer.ForceLayer)
return fileScope;
return new StudioModScope(StudioLayer.Id, StudioScopeKind.Layer);
}
// ── edits ──────────────────────────────────────────────────
/// <summary>Pending edits of a scenario, or null.</summary>
public StudioWorkingCopy Copy(string scenarioName) =>
scenarioName != null && _copies.TryGetValue(scenarioName, out var copy) ? copy : null;
/// <summary>Pending edits of the current scenario, created from its file on first use; null without a scenario.</summary>
public StudioWorkingCopy EnsureCopy()
{
var evt = CurrentEvent;
if (evt == null)
return null;
if (!_copies.TryGetValue(evt.ID, out var copy))
_copies[evt.ID] = copy = new StudioWorkingCopy(evt.ID, SourceText(evt));
return copy;
}
/// <summary>Inserts lines before 0-based line <paramref name="index"/> of the current scenario.</summary>
public void Insert(int index, params string[] lines)
{
EnsureCopy()?.Insert(index, lines);
Touch();
}
public void ReplaceLine(int index, string line)
{
EnsureCopy()?.Replace(index, line);
Touch();
}
public void ReplaceText(string text)
{
EnsureCopy()?.ReplaceAll(text);
Touch();
}
/// <summary>Points the answer at 0-based line <paramref name="index"/> to a scene (a label block leading there is added).</summary>
public void TargetScene(int index, string scene)
{
var copy = EnsureCopy();
if (copy == null)
return;
var label = StudioScriptEdits.EnsureSceneExit(copy, scene);
copy.Replace(index, StudioScriptEdits.WithTarget(copy.Line(index), label));
Touch();
}
/// <summary>Points the answer at 0-based line <paramref name="index"/> to a label of the scenario.</summary>
public void TargetLabel(int index, string label)
{
var copy = EnsureCopy();
if (copy == null)
return;
copy.Replace(index, StudioScriptEdits.WithTarget(copy.Line(index), label));
Touch();
}
/// <summary>Points the answer at 0-based line <paramref name="index"/> to the end of the scenario.</summary>
public void TargetEnd(int index)
{
var copy = EnsureCopy();
if (copy == null)
return;
var label = StudioScriptEdits.EnsureEnd(copy);
copy.Replace(index, StudioScriptEdits.WithTarget(copy.Line(index), label));
Touch();
}
public void Discard()
{
var evt = CurrentEvent;
if (evt != null && _copies.Remove(evt.ID))
Touch();
}
/// <summary>
/// Writes the pending edits of the current scenario — into its folder mod,
/// or into the studio layer for zip mods, layer files and forced layer —
/// and replays the scenario from its start with the game state rolled
/// back. Returns the scope written to, or null when there was nothing.
/// </summary>
public StudioModScope Apply()
{
var evt = CurrentEvent;
var copy = Copy(evt?.ID);
if (copy == null || copy.PendingCount == 0)
return null;
if (string.IsNullOrEmpty(evt.SourceModId))
{
evt.ScenarioText = copy.Text;
_copies.Remove(evt.ID);
Restart(StudioRestartMode.Scenario);
return ScopeOf(null, null);
}
var target = EditScopeFor(ScopeOf(evt.SourceModId, evt.SourcePath));
var pending = copy.PendingCount;
if (target.Kind == StudioScopeKind.Layer)
Layer.Write(evt.SourcePath, copy.Text);
else
File.WriteAllText(ModManager.Instance.GetFileSystemPath(evt.SourceModId, evt.SourcePath), copy.Text);
_copies.Remove(evt.ID);
StudioLayer.RefreshContent();
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_applied", evt.ID, pending, target.ModId));
GameLogger.Log("UI", $"Scenario studio: applied {pending} change(s) of '{evt.ID}' to '{target.ModId}'");
Restart(StudioRestartMode.Rollback);
return target;
}
// ── restarts and navigation ────────────────────────────────
public void Restart(StudioRestartMode mode)
{
var evt = CurrentEvent;
if (evt == null)
return;
var name = evt.ID;
switch (mode)
{
case StudioRestartMode.Scene:
GoToScene(SceneName);
return;
case StudioRestartMode.Rollback when _snapshot != null && _snapshot.ScenarioName == name:
StopDialog();
if (_snapshot.Restore())
ForgetCompleted(name);
RunScenario(name, null, evt);
return;
case StudioRestartMode.FromLabel:
var label = LabelAbove(SourceText(evt), CurrentLine());
StopDialog();
ForgetCompleted(name);
RunScenario(name, label, evt);
return;
default:
StopDialog();
ForgetCompleted(name);
RunScenario(name, null, evt);
return;
}
}
/// <summary>Starts scenario <paramref name="name"/> of the current scene now, stopping the running dialog.</summary>
public bool RunNow(string name)
{
StopDialog();
ForgetCompleted(name);
return RunScenario(name, null, null);
}
/// <summary>Removes <paramref name="name"/> from the one-run completion history.</summary>
public void ForgetCompleted(string name)
{
var runtime = GameData.Instance?.Runtime;
if (runtime == null)
return;
runtime.CompletedScenarioIds.Remove(name);
runtime.LoadedCompletedScenarioIds?.Remove(name);
Touch();
}
/// <summary>Leaves the running dialog and enters <paramref name="scene"/> (a debugging teleport).</summary>
public void GoToScene(string scene)
{
if (string.IsNullOrEmpty(scene))
return;
StopDialog();
Debugger.Reset();
GetNodeOrNull<SceneManager>("/root/SceneManager")?.SwitchScene(scene);
Touch();
}
private void StopDialog()
{
Debugger.Continue(null);
Box?.Abort();
}
/// <summary>
/// Builds a fresh event for scenario <paramref name="name"/> from the
/// current definitions (edits included), puts it on the scene's chain in
/// place of <paramref name="replaced"/> and starts it at once, so other
/// queued scenarios do not run first. A scenario without a definition
/// (test play, inline) is rebuilt from the replaced event's text.
/// </summary>
private bool RunScenario(string name, string startLabel, ScenarioEvent replaced)
{
var chain = GetTree().GetFirstNodeInGroup(ChainGroup) as ExecutableMainEventChain;
var factory = AppServices.Current.SceneEvents.ScenarioEventFactory;
if (chain == null || factory == null)
{
GameLogger.Log("UI", $"Scenario studio: cannot run '{name}' — no event chain or scenario factory", LogLevel.Error);
return false;
}
var def = FindDefinition(name);
ScenarioEvent evt;
if (def != null)
{
var text = ScenarioDefs.ReadText(def.ModId, def.FilePath);
evt = factory.CreateFromDefinition(text, name, def.Condition, def.LightUseTime, def.IsOneRun, def.ModId, def.FilePath) as ScenarioEvent;
}
else if (replaced != null && IsInstanceValid(replaced))
{
evt = factory.CreateFromText(replaced.ScenarioText) as ScenarioEvent;
}
else
{
GameLogger.Log("UI", $"Scenario studio: scenario '{name}' not found", LogLevel.Error);
return false;
}
if (evt == null)
return false;
evt.StartLabel = startLabel;
if (replaced != null && IsInstanceValid(replaced) && replaced.GetParent() == chain)
chain.RemoveEvent(replaced);
chain.AddEvent(evt);
var started = evt.RunDialog();
GameLogger.Log("UI", $"Scenario studio: run '{name}'" + (startLabel != null ? $" from :{startLabel}" : "") + (started ? "" : " — dialog box busy"));
Touch();
return started;
}
public static ScenarioDefinition FindDefinition(string name)
{
var defs = ScenarioDefs.CollectDefinitions();
if (defs == null)
return null;
foreach (var list in defs.Values)
{
foreach (var def in list)
{
if (def.Name == name)
return def;
}
}
return null;
}
private static string LabelAbove(string text, int line)
{
if (text == null || line <= 0)
return null;
var lines = text.Replace("\r\n", "\n").Split('\n');
for (var i = Math.Min(line, lines.Length) - 1; i >= 0; i--)
{
var trimmed = lines[i].Trim();
if (trimmed.StartsWith(':'))
return trimmed.Substring(1).Trim();
}
return null;
}
/// <summary>True when a manual checkpoint was saved in this session.</summary>
public bool HasCheckpoint => _checkpoint != null;
/// <summary>Remembers the whole game state and the scene now, to come back to with <see cref="RestoreCheckpoint"/>.</summary>
public void SaveCheckpoint()
{
_checkpoint = StudioSnapshot.Capture(null, SceneName);
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_checkpoint_saved", SceneName));
Touch();
}
/// <summary>Brings back the saved checkpoint: game state, then its scene entered again.</summary>
public bool RestoreCheckpoint()
{
if (_checkpoint == null)
return false;
StopDialog();
Debugger.Reset();
if (!_checkpoint.Restore())
return false;
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_checkpoint_restored", _checkpoint.SceneName));
GoToScene(_checkpoint.SceneName);
return true;
}
// ── new content ────────────────────────────────────────────
/// <summary>Creates a scene in the layer (backgrounds of the current scene) with a first scenario; returns false on a bad name.</summary>
public bool CreateScene(string name)
{
name = (name ?? string.Empty).Trim().ToLowerInvariant();
if (!ModIdPattern.IsMatch(name))
return false;
var current = SceneName;
var defs = AppServices.Current.SceneDefs.CollectAll();
List<string> backgrounds = null;
if (current != null && defs != null && defs.TryGetValue(current, out var list) && list.Count > 0)
backgrounds = list[0].DefaultBackground;
var scenario = Layer.CreateScene(name, backgrounds, StudioScriptEdits.NewLine());
StudioLayer.RefreshContent();
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_scene_created", name, scenario));
Touch();
return true;
}
/// <summary>Creates a new scenario for the current scene in the layer; returns its name.</summary>
public string CreateScenario()
{
var scene = SceneName;
if (string.IsNullOrEmpty(scene))
return null;
var name = Layer.CreateScenario(scene, StudioScriptEdits.NewLine());
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_scenario_created", name, scene));
Touch();
return name;
}
/// <summary>Exports the layer as mod <paramref name="modId"/>; null on a bad id.</summary>
public string Export(string modId)
{
modId = (modId ?? string.Empty).Trim().ToLowerInvariant();
if (!ModIdPattern.IsMatch(modId))
return null;
var path = Layer.Export(modId);
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_exported", modId));
Touch();
return path;
}
// ── state ──────────────────────────────────────────────────
/// <summary>Writes a value edited in the "State" tab back into the game.</summary>
public void EditState(StudioStateEntry entry, string value)
{
switch (entry.Editor)
{
case StudioStateEditor.DialogVariable:
Box?.Runtime.SetVariable(entry.Id, value);
break;
case StudioStateEditor.Inventory:
var inventory = GameData.Instance?.Runtime.Inventory;
if (inventory == null || !int.TryParse(value, out var count) || count < 0)
return;
var delta = count - inventory.CheckCount(entry.Id);
if (delta > 0)
inventory.Add(entry.Id, delta);
else if (delta < 0)
inventory.Remove(entry.Id, -delta);
break;
case StudioStateEditor.Quest:
QuestServiceRegistry.Provider?.SetStatus(entry.Id, value);
break;
default:
return;
}
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_state", entry.Id, value));
Touch();
}
// ── bus and debugger callbacks ─────────────────────────────
private void OnScenarioStarted(ScenarioEvent evt)
{
try
{
_snapshot = StudioSnapshot.Capture(evt.ID, SceneName);
}
catch (Exception ex)
{
_snapshot = null;
GameLogger.Log("UI", $"Scenario studio: snapshot of '{evt.ID}' failed: {ex.Message}", LogLevel.Warning);
}
Journal.Add(StudioLogCategory.Scenario, new StudioText("studio/log_text_started", evt.ID));
}
private void OnHeld(int line, bool breakpoint)
{
Journal.Add(StudioLogCategory.Stop, new StudioText(breakpoint ? "studio/log_text_breakpoint" : "studio/log_text_paused", line));
}
private void OnSceneEntered(SceneEnteredEvent e)
{
Journal.OnSceneEntered(e);
Touch();
}
private void OnSceneComposed(SceneComposedEvent e)
{
Journal.OnSceneComposed(e);
Touch();
}
private void OnDialogEnded(DialogEndedEvent e)
{
Journal.OnDialogEnded(e);
Touch();
}
private void Touch() => Version++;
}
}
+106
View File
@@ -0,0 +1,106 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
using System.Text;
using Cthangover.Mods.Dialog.Scenarios;
using Godot;
/// <summary>
/// The DSL the dock writes. New lines are written in the current language
/// as literals (what an author would type; the locale atlas convention for
/// scenario text). Edits rewrite one line through
/// <see cref="ScenarioParser.SplitLine"/> and keep its other arguments, so
/// changing an answer's text does not lose its target and vice versa;
/// a literal replaces a <c>key=</c>, because the new text is no longer the
/// translated one.
/// </summary>
public static class StudioScriptEdits
{
public static string NewLine() => "text " + Quote(Tr("studio/new_line_text"));
public static string NewQuestion() => "select " + Quote(Tr("studio/new_choice_text"));
public static string NewAnswer() => "option " + Quote(Tr("studio/new_option_text"));
public static string Jump(string scene) => "switch_scene " + scene;
/// <summary><paramref name="line"/> with its player-visible text replaced by <paramref name="text"/>.</summary>
public static string WithSay(string line, string text)
{
var positional = new List<string>();
var named = new Dictionary<string, string>();
var cmd = ScenarioParser.SplitLine(line.Trim(), positional, named, out var arrow);
if (cmd == null)
return line;
named.Remove("key");
if (positional.Count > 0)
positional[0] = text;
else
positional.Insert(0, text);
return Indent(line) + Compose(cmd, positional, named, arrow);
}
/// <summary><paramref name="line"/> (an answer or goto) pointing at label <paramref name="label"/>.</summary>
public static string WithTarget(string line, string label)
{
var positional = new List<string>();
var named = new Dictionary<string, string>();
var cmd = ScenarioParser.SplitLine(line.Trim(), positional, named, out _);
if (cmd == null)
return line;
named.Remove("goto");
named.Remove("target");
if (positional.Count > 1 && positional[1].StartsWith(':'))
positional.RemoveAt(1);
return Indent(line) + Compose(cmd, positional, named, label);
}
/// <summary>
/// Makes sure the scenario has a label block that leads to scene
/// <paramref name="scene"/> (<c>:to_scene</c> → <c>switch_scene</c> → <c>end</c>),
/// appending it at the end when missing; returns the label name.
/// </summary>
public static string EnsureSceneExit(StudioWorkingCopy copy, string scene)
{
var label = "to_" + scene;
if (copy.FindLabel(label) < 0)
copy.Append("", ":" + label, Jump(scene), "end");
return label;
}
/// <summary>Makes sure the scenario has a label that just ends it; returns the label name.</summary>
public static string EnsureEnd(StudioWorkingCopy copy)
{
const string label = "finish";
if (copy.FindLabel(label) < 0)
copy.Append("", ":" + label, "end");
return label;
}
public static string Quote(string text) => "\"" + (text ?? string.Empty).Replace("\"", "\\\"") + "\"";
private static string Compose(string cmd, List<string> positional, Dictionary<string, string> named, string arrow)
{
var result = new StringBuilder(cmd);
var sayCommand = cmd == "text" || cmd == "ptext" || cmd == "select" || cmd == "option";
for (var i = 0; i < positional.Count; i++)
{
var value = positional[i];
result.Append(' ').Append((i == 0 && sayCommand) || NeedsQuotes(value) ? Quote(value) : value);
}
foreach (var pair in named)
result.Append(' ').Append(pair.Key).Append('=').Append(pair.Value);
if (!string.IsNullOrEmpty(arrow))
result.Append(" -> :").Append(arrow);
return result.ToString();
}
private static bool NeedsQuotes(string value) => value.Length == 0 || value.Contains(' ') || value.Contains('"') || value.Contains('=');
private static string Indent(string line) => line.Substring(0, line.Length - line.TrimStart().Length);
private static string Tr(string key) => TranslationServer.Translate(key).ToString();
}
}
+249
View File
@@ -0,0 +1,249 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Quests;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Mods.Dialog.Executable;
using Godot;
/// <summary>
/// Builds the <see cref="StudioSession"/> the dock renders from the live
/// game: the current scenario's lines (pending edits included) with the
/// debugger's marks, its file and edit scopes, the scene candidates of the
/// last composition merged with what is true now (running, completed), the
/// game state worth seeing while debugging, the log and the scene map
/// around the player. Read-only: nothing here changes the game.
/// </summary>
public static class StudioSessionBuilder
{
private const int VisitedOnMap = 4;
public static StudioSession Build(StudioLive live)
{
var session = new StudioSession { SceneName = StudioLive.SceneName };
var evt = live.CurrentEvent;
session.RunState = RunState(live);
if (evt != null)
FillScenario(session, live, evt);
FillCandidates(session, live, evt);
FillState(session, live);
session.Log.AddRange(live.Journal.Entries);
FillMap(session, live);
return session;
}
private static StudioRunState RunState(StudioLive live)
{
var box = live.Box;
if (box != null && box.IsRunning && box.Runtime.IsHeld)
return StudioRunState.Held;
if (live.Debugger.IsPaused)
return StudioRunState.Paused;
return live.IsCurrentRunning ? StudioRunState.Running : StudioRunState.Idle;
}
private static void FillScenario(StudioSession session, StudioLive live, ScenarioEvent evt)
{
session.ScenarioName = evt.ID;
session.FileScope = live.ScopeOf(evt.SourceModId, evt.SourcePath);
session.EditScope = live.EditScopeFor(session.FileScope);
var copy = live.Copy(evt.ID);
session.PendingCount = copy?.PendingCount ?? 0;
var lines = StudioLineReader.Read(copy != null ? copy.Text : StudioLive.SourceText(evt));
var running = live.IsCurrentRunning && live.Debugger.Event == evt;
var current = live.IsCurrentRunning ? live.CurrentLine() : 0;
string label = null;
foreach (var line in lines)
{
line.SourceLine = copy != null ? copy.OriginalNumber(line.Number) : line.Number;
line.IsNew = copy != null && copy.IsChanged(line.Number - 1);
line.HasBreakpoint = line.SourceLine > 0 && live.Debugger.HasBreakpoint(evt.ID, line.SourceLine);
line.IsCurrent = current > 0 && line.SourceLine == current;
line.IsDone = running && !line.IsCurrent && line.SourceLine > 0 && live.Debugger.IsExecuted(line.SourceLine);
line.CanRunNext = running && !line.IsCurrent && live.CanRunFrom(line.SourceLine);
if (line.Kind == StudioLineKind.Label && current > 0 && line.SourceLine > 0 && line.SourceLine < current)
label = ":" + line.Target;
}
session.LabelName = label;
session.Lines.AddRange(lines);
}
private static void FillCandidates(StudioSession session, StudioLive live, ScenarioEvent evt)
{
var report = live.Journal.Composed(session.SceneName);
if (report == null)
return;
var completed = GameData.Instance?.Runtime.CompletedScenarioIds;
foreach (var candidate in report.Candidates)
{
var item = new StudioCandidate
{
Name = candidate.Id,
Scope = live.ScopeOf(candidate.ModId, candidate.ScenarioPath),
Priority = candidate.Priority,
Condition = string.IsNullOrEmpty(candidate.Condition) ? null : candidate.Condition,
IsOneRun = candidate.IsOneRun,
};
switch (candidate.Outcome)
{
case SceneCandidateOutcome.ConditionFalse:
item.ConditionOk = false;
item.Status = StudioCandidateStatus.Skipped;
item.Reason = new StudioText("studio/reason_condition_false");
break;
case SceneCandidateOutcome.Completed:
item.Status = StudioCandidateStatus.Completed;
item.Reason = new StudioText("studio/reason_completed");
break;
case SceneCandidateOutcome.Shadowed:
item.Status = StudioCandidateStatus.Shadowed;
item.Reason = new StudioText("studio/reason_shadowed");
break;
case SceneCandidateOutcome.Failed:
item.Status = StudioCandidateStatus.Failed;
item.Reason = new StudioText("studio/reason_failed");
break;
default:
item.ConditionOk = true;
item.Status = StudioCandidateStatus.Queued;
item.Reason = new StudioText("studio/reason_queued", candidate.Priority);
break;
}
if (evt != null && live.IsCurrentRunning && evt.ID == candidate.Id)
{
item.Status = StudioCandidateStatus.Running;
item.Reason = new StudioText("studio/reason_running");
}
else if (completed != null && candidate.IsOneRun && completed.Contains(candidate.Id))
{
item.Status = StudioCandidateStatus.Completed;
item.Reason = new StudioText("studio/reason_completed");
}
session.Candidates.Add(item);
}
}
private static void FillState(StudioSession session, StudioLive live)
{
var runtime = GameData.Instance?.Runtime;
if (runtime == null)
return;
var time = runtime.Time;
Add(session, "studio/group_time", new StudioText("studio/state_day"), StudioText.Raw(time.Days.ToString()), StudioStateEditor.None, null);
Add(session, "studio/group_time", new StudioText("studio/state_hour"), StudioText.Raw($"{time.Hours:00}:{time.Minutes:00}"), StudioStateEditor.None, null);
var box = live.Box;
if (box != null && box.IsRunning)
{
foreach (var variable in box.Runtime.Variables)
Add(session, "studio/group_dialog_vars", StudioText.Raw("${" + variable.Key + "}"), StudioText.Raw(variable.Value), StudioStateEditor.DialogVariable, variable.Key);
}
var quests = QuestServiceRegistry.Provider;
if (quests != null)
{
foreach (var id in QuestIds(session.Lines))
{
var quest = quests.Get(id);
if (quest != null)
Add(session, "studio/group_quests", StudioText.Raw(id), StudioText.Raw(quest.Status.ToString()), StudioStateEditor.Quest, id);
}
}
var inventory = runtime.Inventory;
if (inventory?.Items != null)
{
foreach (var slot in inventory.Items)
{
if (slot?.Item != null)
Add(session, "studio/group_inventory", StudioText.Raw(slot.Item.ID), StudioText.Raw(slot.Count.ToString()), StudioStateEditor.Inventory, slot.Item.ID);
}
}
foreach (var id in runtime.CompletedScenarioIds)
Add(session, "studio/group_completed", StudioText.Raw(id), new StudioText("studio/value_yes"), StudioStateEditor.None, id);
}
private static IEnumerable<string> QuestIds(List<StudioCodeLine> lines)
{
var seen = new HashSet<string>();
foreach (var line in lines)
{
var at = line.Text.IndexOf("quest_id=", System.StringComparison.Ordinal);
if (at < 0)
continue;
var start = at + "quest_id=".Length;
var end = line.Text.IndexOf(' ', start);
var id = (end < 0 ? line.Text.Substring(start) : line.Text.Substring(start, end - start)).Trim('"');
if (id.Length > 0 && seen.Add(id))
yield return id;
}
}
private static void Add(StudioSession session, string group, StudioText key, StudioText value, StudioStateEditor editor, string id)
{
session.State.Add(new StudioStateEntry { Group = new StudioText(group), Key = key, Value = value, Editor = editor, Id = id });
}
private static void FillMap(StudioSession session, StudioLive live)
{
var here = session.SceneName;
var scenes = AppServices.Current.SceneDefs.CollectAll();
var visited = new List<string>();
var history = live.Journal.SceneHistory;
for (var i = history.Count - 1; i >= 0 && visited.Count < VisitedOnMap; i--)
{
if (history[i] != here && !visited.Contains(history[i]))
visited.Insert(0, history[i]);
}
foreach (var scene in visited)
session.Nodes.Add(Node(live, scenes, scene, StudioNodeState.Visited, null));
if (string.IsNullOrEmpty(here))
return;
session.Nodes.Add(Node(live, scenes, here, StudioNodeState.Here, null));
var exits = new HashSet<string>();
var defs = ScenarioDefs.CollectDefinitions();
if (defs != null && defs.TryGetValue(here, out var scenarios))
{
foreach (var def in scenarios)
{
foreach (var line in StudioLineReader.Read(ScenarioDefs.ReadText(def.ModId, def.FilePath)))
{
if (line.Kind == StudioLineKind.Jump && !string.IsNullOrEmpty(line.Target) && exits.Add(line.Target))
session.Nodes.Add(Node(live, scenes, line.Target, StudioNodeState.Next, new StudioText("studio/via_scenario", def.Name)));
}
}
}
foreach (var scene in live.Layer.SceneNames())
{
if (scene != here && exits.Add(scene))
session.Nodes.Add(Node(live, scenes, scene, StudioNodeState.New, new StudioText("studio/via_layer")));
}
}
private static StudioSceneNode Node(StudioLive live, Dictionary<string, List<SceneDefinition>> scenes, string name, StudioNodeState state, StudioText via)
{
string modId = null;
if (scenes != null && scenes.TryGetValue(name, out var list) && list.Count > 0)
modId = list[0].ModId;
var scope = modId == null ? new StudioModScope("?", StudioScopeKind.ReadOnly) : live.ScopeOf(modId, $"scenes/{name}.json");
return new StudioSceneNode { Name = name, State = state, Scope = scope, Via = via };
}
}
}
+43
View File
@@ -0,0 +1,43 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Text.Json;
using Cthangover.Core.Settings;
using Cthangover.Core.Utils;
/// <summary>
/// Game state at the moment a scenario started: a full save package
/// (<see cref="SaveService.BuildPackage"/>) kept serialized in memory, plus
/// the scenario and scene it belongs to. Restoring it is the "restart with
/// rollback": quests, inventory, time and completion history go back to
/// the scenario's start. It is kept as JSON because applying a package may
/// reuse its slots, and one snapshot is restored again and again while a
/// scenario is being polished.
/// </summary>
public sealed class StudioSnapshot
{
private readonly string _json;
public string ScenarioName { get; }
public string SceneName { get; }
private StudioSnapshot(string scenarioName, string sceneName, string json)
{
ScenarioName = scenarioName;
SceneName = sceneName;
_json = json;
}
public static StudioSnapshot Capture(string scenarioName, string sceneName)
{
var package = SaveService.BuildPackage("studio_snapshot");
return new StudioSnapshot(scenarioName, sceneName, JsonSerializer.Serialize(package, JsonOptionsProvider.WriteCompactOptions));
}
/// <summary>Brings the game state back; false when the package could not be applied.</summary>
public bool Restore()
{
var package = JsonSerializer.Deserialize<SavePackage>(_json, JsonOptionsProvider.ReadOptions);
return package != null && SaveService.ApplyPackage(package, "studio snapshot of " + ScenarioName);
}
}
}
+129
View File
@@ -0,0 +1,129 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
/// <summary>
/// Unapplied edits of one scenario: its text as lines plus which lines were
/// added or changed since the copy was taken. Every dock edit (insert, edit
/// a line's text, choose an answer's target, edit the whole text) goes here
/// first and reaches the game only on Apply, so an author can prepare
/// several changes and replay them at once. Every line remembers its number
/// in the original text (0 for added lines): the running dialog's actions
/// carry original numbers, so the debugger's marks and breakpoints are
/// mapped through it while edits shift the displayed numbering.
/// </summary>
public sealed class StudioWorkingCopy
{
private readonly List<string> _lines;
private readonly List<bool> _changed;
private readonly List<int> _origin;
public string ScenarioName { get; }
public StudioWorkingCopy(string scenarioName, string text)
{
ScenarioName = scenarioName;
_lines = new List<string>((text ?? string.Empty).Replace("\r\n", "\n").Split('\n'));
_changed = new List<bool>(new bool[_lines.Count]);
_origin = new List<int>(_lines.Count);
for (var i = 0; i < _lines.Count; i++)
_origin.Add(i + 1);
}
/// <summary>Original (1-based) number of the displayed line <paramref name="number"/>; 0 for an added line.</summary>
public int OriginalNumber(int number) => number >= 1 && number <= _origin.Count ? _origin[number - 1] : 0;
public string Text => string.Join("\n", _lines);
public int LineCount => _lines.Count;
/// <summary>Number of added or changed lines.</summary>
public int PendingCount
{
get
{
var count = 0;
foreach (var changed in _changed)
{
if (changed)
count++;
}
return count;
}
}
/// <summary>Whether the line at 0-based <paramref name="index"/> was added or changed.</summary>
public bool IsChanged(int index) => index >= 0 && index < _changed.Count && _changed[index];
public string Line(int index) => index >= 0 && index < _lines.Count ? _lines[index] : string.Empty;
/// <summary>Inserts lines before 0-based <paramref name="index"/> (clamped to the text).</summary>
public void Insert(int index, params string[] lines)
{
index = System.Math.Clamp(index, 0, _lines.Count);
_lines.InsertRange(index, lines);
_changed.InsertRange(index, NewFlags(lines.Length));
_origin.InsertRange(index, new int[lines.Length]);
}
/// <summary>Appends lines at the end of the text.</summary>
public void Append(params string[] lines) => Insert(_lines.Count, lines);
public void Replace(int index, string line)
{
if (index < 0 || index >= _lines.Count || _lines[index] == line)
return;
_lines[index] = line;
_changed[index] = true;
}
/// <summary>
/// Replaces the whole text (the inline text editor). Lines that differ from
/// the line at the same index before the edit are marked changed; this is a
/// display hint, not a diff, so an inserted line marks the shifted tail too.
/// </summary>
public void ReplaceAll(string text)
{
var lines = (text ?? string.Empty).Replace("\r\n", "\n").Split('\n');
for (var i = 0; i < lines.Length; i++)
{
var changed = i >= _lines.Count || _lines[i] != lines[i] || _changed[i];
if (i < _lines.Count)
{
_lines[i] = lines[i];
_changed[i] = changed;
}
else
{
_lines.Add(lines[i]);
_changed.Add(true);
_origin.Add(0);
}
}
if (_lines.Count > lines.Length)
{
_lines.RemoveRange(lines.Length, _lines.Count - lines.Length);
_changed.RemoveRange(lines.Length, _changed.Count - lines.Length);
_origin.RemoveRange(lines.Length, _origin.Count - lines.Length);
}
}
/// <summary>0-based index of the label line <c>:name</c>, or -1.</summary>
public int FindLabel(string name)
{
for (var i = 0; i < _lines.Count; i++)
{
if (_lines[i].Trim() == ":" + name)
return i;
}
return -1;
}
private static IEnumerable<bool> NewFlags(int count)
{
for (var i = 0; i < count; i++)
yield return true;
}
}
}