This commit is contained in:
cthangover committed 2026-09-23 17:55:57 +03:00
1 parent 8e6ad52ee4
commit de310e7295
10 files changed
+201 -138

No files matched your search

+1
View File
@@ -80,6 +80,7 @@ tools/scenario_editor/no_backgrounds = No backgrounds
tools/scenario_editor/status_modified = Modified (unsaved)
tools/scenario_editor/status_saved = Saved
tools/scenario_editor/save_error = Save error: {0}
tools/scenario_editor/readonly_zip = File is provided by a zip mod and is read-only
tools/scenario_editor/unsaved_switch_text = You have unsaved changes. Switch file without saving?
tools/unsaved_title = Unsaved changes
+1
View File
@@ -81,6 +81,7 @@ tools/scenario_editor/no_backgrounds = Нет фонов
tools/scenario_editor/status_modified = Изменено (не сохранено)
tools/scenario_editor/status_saved = Сохранено
tools/scenario_editor/save_error = Ошибка сохранения: {0}
tools/scenario_editor/readonly_zip = Файл из zip-мода доступен только для чтения
tools/scenario_editor/unsaved_switch_text = У вас есть несохранённые изменения. Сменить файл без сохранения?
tools/unsaved_title = Есть изменения
@@ -55,7 +55,6 @@ namespace Cthangover.Tools.InteractiveEditor
public InteractiveEditorWindow() : base("tools/interactive_editor/title")
{
Title = "Interactive Editor";
Size = new Vector2I(1280, 820);
var outer = CreateFillContainer();
+4 -1
View File
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.UI.Lights;
using Cthangover.Core.Utils;
using Cthangover.Tools.Services;
using Godot;
@@ -366,8 +368,9 @@ namespace Cthangover.Core.UI.Tool.LightEditor
selectedIndex = -1;
RefreshAll();
}
catch
catch (Exception ex)
{
GameLogger.Log("LIGHT_EDITOR", $"Import JSON failed: {ex.Message}", LogLevel.Error);
}
}
}
+25 -8
View File
@@ -10,7 +10,9 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
public sealed class TabData
{
public string FilePath;
public string Key;
public string ModId;
public string RelativePath;
public string FileName;
public string SavedText;
public bool IsDirty;
@@ -31,8 +33,12 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
public TextEdit CodeEditor => _textEdit;
public string CurrentText => _textEdit.Text;
public string CurrentAbsolutePath =>
_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count ? _tabs[_activeTabIndex].FilePath : null;
public string CurrentKey =>
_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count ? _tabs[_activeTabIndex].Key : null;
public string CurrentModId =>
_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count ? _tabs[_activeTabIndex].ModId : null;
public string CurrentRelativePath =>
_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count ? _tabs[_activeTabIndex].RelativePath : null;
public bool HasActiveFile => _activeTabIndex >= 0 && _activeTabIndex < _tabs.Count;
public bool IsCurrentDirty =>
_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count && _tabs[_activeTabIndex].IsDirty;
@@ -73,24 +79,35 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
AddChild(_graphView);
}
public void OpenFile(string absolutePath)
public void OpenFile(string key)
{
for (int i = 0; i < _tabs.Count; i++)
{
if (_tabs[i].FilePath == absolutePath)
if (_tabs[i].Key == key)
{
SwitchToTextTab(i);
return;
}
}
if (!ScenarioFileService.TryParseKey(key, out var modId, out var relativePath))
return;
try
{
var text = ScenarioFileService.ReadAllText(absolutePath);
var text = ScenarioFileService.ReadAllText(modId, relativePath);
if (text == null)
{
GameLogger.Log("SCENARIO_EDITOR", $"Failed to read scenario '{key}'", LogLevel.Error);
return;
}
var tab = new TabData
{
FilePath = absolutePath,
FileName = Path.GetFileNameWithoutExtension(absolutePath),
Key = key,
ModId = modId,
RelativePath = relativePath,
FileName = Path.GetFileNameWithoutExtension(relativePath),
SavedText = text,
IsDirty = false
};
+32 -11
View File
@@ -180,8 +180,8 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private void OnTextTabSelected(string text)
{
_filePathLabel.Text = _editTabs.CurrentAbsolutePath != null
? ScenarioFileService.GetRelativePath(_editTabs.CurrentAbsolutePath) : "";
_filePathLabel.Text = _editTabs.CurrentKey != null
? ScenarioFileService.GetDisplayPath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath) : "";
_saveBtn.Disabled = false;
_openFileBtn.Disabled = false;
_playBtn.Disabled = false;
@@ -221,17 +221,23 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
if (!_editTabs.HasActiveFile)
return;
var path = _editTabs.CurrentAbsolutePath;
var modId = _editTabs.CurrentModId;
var relativePath = _editTabs.CurrentRelativePath;
var text = _editTabs.CurrentText;
try
{
ScenarioFileService.WriteAllText(path, text);
if (!ScenarioFileService.WriteAllText(modId, relativePath, text))
{
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip");
return;
}
_editTabs.SaveCurrentFile();
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/status_saved");
_infoPanel.Update(text);
_thumbPanel.Load(text);
_editTabs.RefreshGraph();
GameLogger.Log("SCENARIO_EDITOR", $"Saved: {path}");
GameLogger.Log("SCENARIO_EDITOR", $"Saved: {modId}/{relativePath}");
}
catch (Exception ex)
{
@@ -243,10 +249,16 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private void OnOpenFilePressed()
{
var path = _editTabs.CurrentAbsolutePath;
if (path == null)
if (_editTabs.CurrentKey == null)
return;
var path = ScenarioFileService.GetAbsolutePath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath);
if (string.IsNullOrEmpty(path))
{
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip");
return;
}
GD.Print($"[ScenarioEditor] Opening: {path}");
var err = OS.ShellOpen(path);
if (err != Error.Ok)
@@ -267,10 +279,21 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
return;
}
var path = _editTabs.CurrentAbsolutePath;
var modId = _editTabs.CurrentModId;
var relativePath = _editTabs.CurrentRelativePath;
try
{
ScenarioFileService.WriteAllText(path, text);
if (ScenarioFileService.WriteAllText(modId, relativePath, text))
{
_editTabs.SaveCurrentFile();
}
else
{
_statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip");
GameLogger.Log("SCENARIO_EDITOR",
$"Test play: '{modId}/{relativePath}' is read-only, playing from the editor buffer",
LogLevel.Warning);
}
}
catch (Exception ex)
{
@@ -279,8 +302,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
return;
}
_editTabs.SaveCurrentFile();
_editLayout.Visible = false;
_playLayout.Visible = true;
+12 -11
View File
@@ -8,8 +8,8 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
public partial class ScenarioFileTreePanel : VBoxContainer
{
private Tree _fileTree;
private readonly Dictionary<string, string> _fileItemToPath = new();
private readonly Dictionary<string, TreeItem> _pathToTreeItem = new();
private readonly Dictionary<string, string> _fileItemToKey = new();
private readonly Dictionary<string, TreeItem> _keyToTreeItem = new();
private bool _isProgrammaticSelection;
public event Action<string> FileSelected;
@@ -25,7 +25,8 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private void Populate()
{
_fileTree.Clear();
_fileItemToPath.Clear();
_fileItemToKey.Clear();
_keyToTreeItem.Clear();
var root = _fileTree.CreateItem();
var mods = ScenarioFileService.ScanScenarioFiles();
@@ -56,8 +57,8 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
var fileItem = _fileTree.CreateItem(parentItem);
fileItem.SetText(0, file.Name);
fileItem.SetMetadata(0, Variant.From("scenario"));
_fileItemToPath[GetItemKey(fileItem)] = file.AbsolutePath;
_pathToTreeItem[file.AbsolutePath] = fileItem;
_fileItemToKey[GetItemKey(fileItem)] = file.Key;
_keyToTreeItem[file.Key] = fileItem;
}
}
}
@@ -68,16 +69,16 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
return item.GetInstanceId().ToString();
}
public void SelectFile(string absolutePath)
public void SelectFile(string key)
{
if (!_pathToTreeItem.TryGetValue(absolutePath, out var item))
if (!_keyToTreeItem.TryGetValue(key, out var item))
return;
_isProgrammaticSelection = true;
item.Select(0);
_isProgrammaticSelection = false;
FileSelected?.Invoke(absolutePath);
FileSelected?.Invoke(key);
}
private void OnCellSelected()
@@ -93,11 +94,11 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
if (meta.VariantType != Variant.Type.String || meta.AsString() != "scenario")
return;
var key = GetItemKey(item);
if (!_fileItemToPath.TryGetValue(key, out var filePath))
var itemKey = GetItemKey(item);
if (!_fileItemToKey.TryGetValue(itemKey, out var fileKey))
return;
FileSelected?.Invoke(filePath);
FileSelected?.Invoke(fileKey);
}
}
}
+1 -1
View File
@@ -190,7 +190,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
foreach (var sc in nodeData.Scenarios.OrderBy(s => s.Priority))
{
var url = Uri.EscapeDataString(sc.FilePath);
var url = Uri.EscapeDataString(sc.Key);
sb.AppendLine($" [{sc.Priority}] [url={url}]{sc.Name}[/url]");
}
+124 -104
View File
@@ -6,7 +6,6 @@ using Cthangover.Core.Scenarios;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Utils;
using Cthangover.Core.Mods;
using Godot;
namespace Cthangover.Tools.Services
{
@@ -34,15 +33,24 @@ namespace Cthangover.Tools.Services
public List<string> BackgroundRefs { get; set; } = new();
}
/// <summary>Describes a single scenario file found during scanning.</summary>
/// <summary>
/// Describes a single scenario file found during scanning. The file is
/// addressed by its owning mod and mod-relative path, so both folder
/// mods and zip mods are covered; <see cref="Key"/> is the stable
/// identity used by the editor UI.
/// </summary>
public sealed class ScenarioFileInfo
{
/// <summary>Absolute path on disk.</summary>
public string AbsolutePath { get; set; }
/// <summary>Identifier of the mod that provides the file.</summary>
public string ModId { get; set; }
/// <summary>Mod-relative path, e.g. <c>scenarios/start.scenario</c>.</summary>
public string RelativePath { get; set; }
/// <summary>Filename without extension.</summary>
public string Name { get; set; }
/// <summary>Directory path relative to the mod's <c>scenarios/</c> folder.</summary>
public string RelativeDir { get; set; }
/// <summary>Stable UI identity: <c>modId:relativePath</c>.</summary>
public string Key => ScenarioFileService.MakeKey(ModId, RelativePath);
}
/// <summary>A group of scenario files within the same subdirectory under <c>scenarios/</c>.</summary>
@@ -65,67 +73,107 @@ namespace Cthangover.Tools.Services
/// <summary>
/// Static service for discovering, reading, writing, and analysing scenario files.
/// Scans all <c>mods/*/scenarios/**/*.scenario</c> files in the project, groups
/// them by mod and subdirectory, parses YAML-like metadata blocks, and extracts
/// referenced background/scene/locale/quest identifiers. Used by
/// <see cref="ScenarioEditorWindow"/> for the file tree and info panel.
/// Scans every loaded mod's <c>scenarios/</c> folder through the mod file
/// provider abstraction (<see cref="IModFileProvider"/>), so folder and zip
/// mods are discovered identically. Writing is only possible for folder mods:
/// zip mods are read-only, and <see cref="WriteAllText"/> reports that instead
/// of touching the filesystem. Used by <see cref="ScenarioEditorWindow"/> for
/// the file tree and info panel.
/// </summary>
public static class ScenarioFileService
{
private const string ScenariosDir = "scenarios";
private const string ScenarioExtension = ".scenario";
private const string KeySeparator = ":";
/// <summary>
/// Recursively scans <c>res://mods/*/scenarios/</c> for <c>.scenario</c> files.
/// Groups results by mod ID and subdirectory. Returns an empty list if the
/// mods directory does not exist.
/// Recursively scans every loaded mod's <c>scenarios/</c> folder for
/// <c>.scenario</c> files via <see cref="IModFileProvider"/>. Groups
/// results by mod ID and subdirectory. Returns an empty list when no
/// mods are loaded or none provide scenarios.
/// </summary>
public static List<ModScenarioTree> ScanScenarioFiles()
{
var result = new List<ModScenarioTree>();
var roots = ModRegistry.AllModsRoots;
var manager = ModManager.Instance;
foreach (var (modsPath, _) in roots)
foreach (var mod in manager.Mods.Values.OrderBy(m => m.Id, StringComparer.OrdinalIgnoreCase))
{
if (!Directory.Exists(modsPath))
var files = new List<ScenarioFileInfo>();
CollectScenarioFiles(manager, mod.Id, ScenariosDir, files);
if (files.Count == 0)
continue;
foreach (var modDir in Directory.GetDirectories(modsPath).OrderBy(d => Path.GetFileName(d)))
{
var modId = Path.GetFileName(modDir);
var scenariosDir = Path.Combine(modDir, "scenarios");
if (!Directory.Exists(scenariosDir))
continue;
var scenarioFiles = Directory.GetFiles(scenariosDir, "*.scenario", SearchOption.AllDirectories);
if (scenarioFiles.Length == 0)
continue;
var groups = scenarioFiles
.GroupBy(f =>
{
var relDir = Path.GetRelativePath(scenariosDir, Path.GetDirectoryName(f));
return relDir == "." ? "" : relDir.Replace('\\', '/');
})
.OrderBy(g => g.Key)
var groups = files
.GroupBy(f => f.RelativeDir)
.OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.Select(g => new ScenarioGroup
{
DirectoryPath = g.Key,
Files = g.OrderBy(f => Path.GetFileNameWithoutExtension(f))
.Select(f => new ScenarioFileInfo
{
AbsolutePath = f,
Name = Path.GetFileNameWithoutExtension(f),
RelativeDir = g.Key
})
.ToList()
Files = g.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase).ToList()
})
.ToList();
result.Add(new ModScenarioTree { ModId = modId, Groups = groups });
}
result.Add(new ModScenarioTree { ModId = mod.Id, Groups = groups });
}
return result;
}
private static void CollectScenarioFiles(ModManager manager, string modId, string directory, List<ScenarioFileInfo> result)
{
foreach (var entry in manager.ListFiles(modId, directory))
{
if (entry.EndsWith("/"))
{
CollectScenarioFiles(manager, modId, entry.TrimEnd('/'), result);
continue;
}
if (!entry.EndsWith(ScenarioExtension, StringComparison.OrdinalIgnoreCase))
continue;
var path = entry.Replace('\\', '/');
var lastSlash = path.LastIndexOf('/');
var fileName = lastSlash >= 0 ? path.Substring(lastSlash + 1) : path;
var dirPath = lastSlash >= 0 ? path.Substring(0, lastSlash) : "";
var relativeDir = "";
if (dirPath.StartsWith(ScenariosDir + "/", StringComparison.OrdinalIgnoreCase))
relativeDir = dirPath.Substring(ScenariosDir.Length + 1);
else if (!dirPath.Equals(ScenariosDir, StringComparison.OrdinalIgnoreCase))
relativeDir = dirPath;
result.Add(new ScenarioFileInfo
{
ModId = modId,
RelativePath = path,
Name = Path.GetFileNameWithoutExtension(fileName),
RelativeDir = relativeDir
});
}
}
/// <summary>Builds the stable UI identity for a scenario file.</summary>
public static string MakeKey(string modId, string relativePath) => modId + KeySeparator + relativePath;
/// <summary>Splits a key produced by <see cref="MakeKey"/> back into mod ID and mod-relative path.</summary>
public static bool TryParseKey(string key, out string modId, out string relativePath)
{
modId = null;
relativePath = null;
if (string.IsNullOrEmpty(key))
return false;
var separator = key.IndexOf(KeySeparator, StringComparison.Ordinal);
if (separator <= 0 || separator >= key.Length - 1)
return false;
modId = key.Substring(0, separator);
relativePath = key.Substring(separator + 1);
return true;
}
/// <summary>
/// Parses the header block (before first <c>---</c> line) in YAML-like
/// <c>key: value</c> format to extract scene, priority, and condition.
@@ -208,44 +256,53 @@ namespace Cthangover.Tools.Services
return refs;
}
/// <summary>Reads the entire file at <paramref name="filePath"/> as a UTF-8 string.</summary>
public static string ReadAllText(string filePath)
/// <summary>Reads a scenario file through its mod provider. Returns <c>null</c> when not found.</summary>
public static string ReadAllText(string modId, string relativePath)
{
return File.ReadAllText(filePath);
return ModManager.Instance.ReadFileText(modId, relativePath);
}
/// <summary>Writes <paramref name="text"/> to <paramref name="filePath"/>, overwriting existing content.</summary>
public static void WriteAllText(string filePath, string text)
/// <summary>
/// Writes a scenario file back to disk. Returns <c>false</c> when the
/// owning mod is not filesystem-backed (zip mods are read-only).
/// </summary>
public static bool WriteAllText(string modId, string relativePath, string text)
{
File.WriteAllText(filePath, text);
var absolutePath = ModManager.Instance.GetFileSystemPath(modId, relativePath);
if (string.IsNullOrEmpty(absolutePath))
return false;
File.WriteAllText(absolutePath, text);
return true;
}
/// <summary>Converts an absolute file path to a path relative to <c>res://</c>, using forward slashes.</summary>
public static string GetRelativePath(string absolutePath)
/// <summary>Absolute filesystem path of a scenario file, or <c>null</c> for zip-backed mods.</summary>
public static string GetAbsolutePath(string modId, string relativePath)
{
var projectPath = GameEnvironment.IsRuntime ? ProjectSettings.GlobalizePath("res://") : "";
return Path.GetRelativePath(projectPath, absolutePath).Replace('\\', '/');
return ModManager.Instance.GetFileSystemPath(modId, relativePath);
}
/// <summary>Project-style display path (<c>mods/&lt;modId&gt;/&lt;relativePath&gt;</c>).</summary>
public static string GetDisplayPath(string modId, string relativePath)
{
return "mods/" + modId + "/" + relativePath;
}
public static SceneGraphData BuildSceneGraph()
{
var data = new SceneGraphData();
var sceneNodes = new Dictionary<string, SceneGraphNode>(System.StringComparer.OrdinalIgnoreCase);
var edgeSet = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
var sceneNodes = new Dictionary<string, SceneGraphNode>(StringComparer.OrdinalIgnoreCase);
var edgeSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var allFiles = ScanAllScenarioFiles();
foreach (var file in allFiles)
foreach (var mod in ScanScenarioFiles())
{
string text;
try
foreach (var group in mod.Groups)
{
text = File.ReadAllText(file.AbsolutePath);
}
catch
foreach (var file in group.Files)
{
var text = ReadAllText(file.ModId, file.RelativePath);
if (text == null)
continue;
}
var metadata = ParseMetadata(text);
if (string.IsNullOrEmpty(metadata.Scene) || metadata.Scene == "-")
@@ -259,7 +316,7 @@ namespace Cthangover.Tools.Services
{
SceneName = metadata.Scene,
ModId = file.ModId,
IsStartScene = string.Equals(metadata.Scene, "start_scene", System.StringComparison.OrdinalIgnoreCase)
IsStartScene = string.Equals(metadata.Scene, "start_scene", StringComparison.OrdinalIgnoreCase)
};
sceneNodes[sceneKey] = node;
}
@@ -269,7 +326,7 @@ namespace Cthangover.Tools.Services
{
Name = file.Name,
Priority = int.TryParse(metadata.Priority, out var p) ? p : 0,
FilePath = file.AbsolutePath
Key = file.Key
});
var refs = ExtractReferences(text);
@@ -289,50 +346,13 @@ namespace Cthangover.Tools.Services
}
}
}
}
}
foreach (var node in sceneNodes.Values)
data.Nodes.Add(node);
return data;
}
private sealed class FlatScenarioFile
{
public string AbsolutePath;
public string Name;
public string ModId;
}
private static List<FlatScenarioFile> ScanAllScenarioFiles()
{
var result = new List<FlatScenarioFile>();
var roots = ModRegistry.AllModsRoots;
foreach (var (modsPath, _) in roots)
{
if (!Directory.Exists(modsPath))
continue;
foreach (var modDir in Directory.GetDirectories(modsPath))
{
var modId = Path.GetFileName(modDir);
var scenariosDir = Path.Combine(modDir, "scenarios");
if (!Directory.Exists(scenariosDir))
continue;
foreach (var file in Directory.GetFiles(scenariosDir, "*.scenario", SearchOption.AllDirectories))
{
result.Add(new FlatScenarioFile
{
AbsolutePath = file,
Name = Path.GetFileNameWithoutExtension(file),
ModId = modId
});
}
}
}
return result;
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Cthangover.Tools.Services
{
public string Name { get; set; }
public int Priority { get; set; }
public string FilePath { get; set; }
public string Key { get; set; }
}
public sealed class SceneGraphNode