Tool windows redesign and the Novel Editor workspace
Tool windows: - Every editor (scenario, scene builder, map, interactive objects, lighting, catalog export) is rebuilt on the interface ToolWindow chrome and the denser ToolTheme: header with title/close, status bar, Ctrl+S, dirty-state confirmation. - Shared building blocks in src/Common: ToolUi (rows, buttons, sections), CollapsibleSection, FilteredList, ModFileSelector, DragHandle and BackgroundCanvas (scene background with pan/zoom). - The map, interactive and light editors draw on canvases (MapEditorCanvas, InteractiveEditorCanvas, LightEditorCanvas) instead of one Control "handle" node per object; the *Handle classes are removed. - Scenario editor: tab data model (TabData), automatic graph layout (ScenarioGraphLayout), file tree/info/thumbnail panel updates. - Services: ModFileService (mod file listing and writing), SceneBackgroundService; MapFileService/ModResourceService follow. - Catalog export: per-language name/description editing. - Locale (en/ru) regrouped under tools/*; unused keys removed. Novel Editor: - collections/novel_editor.json: a "game" whose entry scene is an authoring workspace over the novel engine and the story content. - NovelEditorHub (scenes/novel_editor_hub.tscn) lists every installed tool through IToolProvider.AddMenuButton; ToolsInitializer registers it as the logical scene "novel_editor".
This commit is contained in:
1 parent
578ba7c5d5
commit
8742aa3da5
45 files changed
+4718
-3463
No files matched your search
@@ -1,102 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Mods.Providers;
|
||||
using Cthangover.Mods.Map;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
namespace Cthangover.Tools.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Static service for discovering, reading and writing world map zone files
|
||||
/// (<c>mods/<mod>/map/*.json</c>) from the map editor. Follows
|
||||
/// <see cref="ScenarioFileService"/>: reading goes through the mod file
|
||||
/// provider abstraction so zip mods are visible, writing is deliberately
|
||||
/// limited to folder-backed mods because zip archives are read-only.
|
||||
/// <para/>
|
||||
/// New files are created through the provider's <c>RootPath</c> rather than
|
||||
/// <c>GetFileSystemPath</c>, which reports <c>null</c> for files that do not
|
||||
/// exist yet — the editor must be able to author a brand-new map file, not
|
||||
/// only overwrite an existing one.
|
||||
/// (De)serialisation of world map zone files (<c>mods/<mod>/map/*.json</c>)
|
||||
/// for the map editor. File access itself goes through
|
||||
/// <see cref="ModFileService"/>.
|
||||
/// </summary>
|
||||
public static class MapFileService
|
||||
{
|
||||
private const string MapDir = "map";
|
||||
private const string JsonExtension = ".json";
|
||||
|
||||
/// <summary>
|
||||
/// IDs of every loaded mod that can be written to (folder-backed).
|
||||
/// Zip mods are skipped silently: they appear in the game but the editor
|
||||
/// cannot save into them.
|
||||
/// </summary>
|
||||
public static List<string> ListWritableMods()
|
||||
{
|
||||
var result = new List<string>();
|
||||
var manager = ModManager.Instance;
|
||||
if (manager?.Mods == null)
|
||||
return result;
|
||||
|
||||
foreach (var pair in manager.Mods)
|
||||
{
|
||||
if (pair.Value?.FileProvider is FolderModFileProvider)
|
||||
result.Add(pair.Key);
|
||||
}
|
||||
|
||||
result.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mod-relative paths of all <c>.json</c> files under the mod's
|
||||
/// <c>map/</c> directory, including nested subdirectories. Returns an
|
||||
/// empty list for unknown mods.
|
||||
/// </summary>
|
||||
public static List<string> ListMapFiles(string modId)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (string.IsNullOrEmpty(modId))
|
||||
return result;
|
||||
|
||||
CollectJsonFiles(modId, MapDir, result);
|
||||
result.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Reads a map file through the mod provider; <c>null</c> when missing.</summary>
|
||||
public static string ReadFile(string modId, string relativePath)
|
||||
{
|
||||
return ModManager.Instance.ReadFileText(modId, relativePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a map file into a folder-backed mod, creating the
|
||||
/// <c>map/</c> directory and any missing parents. Returns <c>false</c>
|
||||
/// for zip mods, unknown mods and empty paths.
|
||||
/// </summary>
|
||||
public static bool WriteFile(string modId, string relativePath, string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(modId) || string.IsNullOrEmpty(relativePath))
|
||||
return false;
|
||||
|
||||
var mod = ModManager.Instance.GetMod(modId);
|
||||
if (mod?.FileProvider is not FolderModFileProvider provider)
|
||||
return false;
|
||||
|
||||
var root = provider.RootPath;
|
||||
if (string.IsNullOrEmpty(root))
|
||||
return false;
|
||||
|
||||
var absolute = Path.GetFullPath(Path.Combine(root, relativePath.Replace('\\', '/')));
|
||||
var directory = Path.GetDirectoryName(absolute);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
File.WriteAllText(absolute, text);
|
||||
return true;
|
||||
}
|
||||
/// <summary>Folder of map zone files inside a mod.</summary>
|
||||
public const string MapDir = "map";
|
||||
|
||||
/// <summary>
|
||||
/// Parses the standard <c>{"Items": [...]}</c> envelope into zones.
|
||||
@@ -122,20 +41,5 @@ namespace Cthangover.Tools.Services
|
||||
var data = new FileData<MapZone> { Items = zones?.ToList() ?? new List<MapZone>() };
|
||||
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
|
||||
}
|
||||
|
||||
private static void CollectJsonFiles(string modId, string directory, List<string> result)
|
||||
{
|
||||
foreach (var entry in ModManager.Instance.ListFiles(modId, directory))
|
||||
{
|
||||
if (entry.EndsWith("/"))
|
||||
{
|
||||
CollectJsonFiles(modId, entry.TrimEnd('/'), result);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.EndsWith(JsonExtension, StringComparison.OrdinalIgnoreCase))
|
||||
result.Add(entry.Replace('\\', '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Mods.Providers;
|
||||
|
||||
namespace Cthangover.Tools.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// File access shared by the editors: listing a mod's files by folder and
|
||||
/// extension, reading them through the mod provider and writing them back.
|
||||
/// Reading works for every mod (folder, zip, test-suite subdir), writing only
|
||||
/// for folder-backed mods — the only ones with a real directory behind them.
|
||||
/// New files are created under the provider's <c>RootPath</c> because
|
||||
/// <c>GetFileSystemPath</c> reports <c>null</c> for files that do not exist
|
||||
/// yet, and an editor must be able to author a brand-new file.
|
||||
/// </summary>
|
||||
public static class ModFileService
|
||||
{
|
||||
/// <summary>IDs of every loaded folder-backed (writable) mod, sorted.</summary>
|
||||
public static List<string> ListWritableMods()
|
||||
{
|
||||
var result = new List<string>();
|
||||
var manager = ModManager.Instance;
|
||||
if (manager?.Mods == null)
|
||||
return result;
|
||||
|
||||
foreach (var pair in manager.Mods)
|
||||
{
|
||||
if (pair.Value?.FileProvider is FolderModFileProvider)
|
||||
result.Add(pair.Key);
|
||||
}
|
||||
|
||||
result.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>True when the mod is folder-backed, i.e. the editors can save into it.</summary>
|
||||
public static bool IsWritable(string modId)
|
||||
{
|
||||
return !string.IsNullOrEmpty(modId)
|
||||
&& ModManager.Instance.GetMod(modId)?.FileProvider is FolderModFileProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mod-relative paths of the files under <paramref name="directory"/>
|
||||
/// (recursively) ending with <paramref name="extension"/>, sorted.
|
||||
/// </summary>
|
||||
public static List<string> ListFiles(string modId, string directory, string extension)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (string.IsNullOrEmpty(modId))
|
||||
return result;
|
||||
|
||||
Collect(modId, directory, extension, result);
|
||||
result.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Reads a file through the mod provider; <c>null</c> when missing.</summary>
|
||||
public static string ReadText(string modId, string relativePath)
|
||||
{
|
||||
return ModManager.Instance.ReadFileText(modId, relativePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a file into a folder-backed mod, creating missing directories.
|
||||
/// Returns <c>false</c> for zip/virtual mods, unknown mods and empty paths.
|
||||
/// </summary>
|
||||
public static bool WriteText(string modId, string relativePath, string text)
|
||||
{
|
||||
var absolute = GetWritablePath(modId, relativePath);
|
||||
if (absolute == null)
|
||||
return false;
|
||||
|
||||
var directory = Path.GetDirectoryName(absolute);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
File.WriteAllText(absolute, text);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path a file of a folder-backed mod has (or would have once
|
||||
/// written); <c>null</c> for read-only mods and empty paths.
|
||||
/// </summary>
|
||||
public static string GetWritablePath(string modId, string relativePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(modId) || string.IsNullOrEmpty(relativePath))
|
||||
return null;
|
||||
|
||||
if (ModManager.Instance.GetMod(modId)?.FileProvider is not FolderModFileProvider provider
|
||||
|| string.IsNullOrEmpty(provider.RootPath))
|
||||
return null;
|
||||
|
||||
return Path.GetFullPath(Path.Combine(provider.RootPath, relativePath.Replace('\\', '/')));
|
||||
}
|
||||
|
||||
private static void Collect(string modId, string directory, string extension, List<string> result)
|
||||
{
|
||||
foreach (var entry in ModManager.Instance.ListFiles(modId, directory))
|
||||
{
|
||||
if (entry.EndsWith("/"))
|
||||
{
|
||||
Collect(modId, entry.TrimEnd('/'), extension, result);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
||||
result.Add(entry.Replace('\\', '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,8 @@ namespace Cthangover.Tools.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns all unique background IDs (filenames without extension) from
|
||||
/// the <c>"backgrounds"</c> file collection across all installed mods.
|
||||
/// the <c>"backgrounds"</c> file collection across all installed mods,
|
||||
/// sorted, without the <c>_albedo</c>/<c>_depth</c> lighting masks.
|
||||
/// </summary>
|
||||
public static List<string> GetBackgroundIds()
|
||||
{
|
||||
@@ -35,11 +36,13 @@ namespace Cthangover.Tools.Services
|
||||
if (!string.IsNullOrWhiteSpace(id) && exts.Contains(ext.ToLower()))
|
||||
{
|
||||
var cleanId = id.Substring(0, id.LastIndexOf('.'));
|
||||
if (!cleanId.EndsWith("_albedo") && !cleanId.EndsWith("_depth"))
|
||||
var bare = cleanId.TrimEnd('_');
|
||||
if (!bare.EndsWith("_albedo") && !bare.EndsWith("_depth"))
|
||||
ids.Add(cleanId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ids.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return ids;
|
||||
}
|
||||
|
||||
@@ -76,6 +79,29 @@ namespace Cthangover.Tools.Services
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Texture IDs that make sense as small icons (map markers): every
|
||||
/// texture except backgrounds and their <c>_depth</c>/<c>_albedo</c>
|
||||
/// masks. Textures are keyed by file name, so backgrounds are excluded
|
||||
/// by the file-name part of their IDs.
|
||||
/// </summary>
|
||||
public static List<string> GetIconIds()
|
||||
{
|
||||
var backgrounds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var id in GetBackgroundIds())
|
||||
backgrounds.Add(id.Substring(id.LastIndexOf('/') + 1));
|
||||
|
||||
var result = new List<string>();
|
||||
foreach (var id in GetTextureIds())
|
||||
{
|
||||
if (backgrounds.Contains(id) || id.EndsWith("_depth", StringComparison.OrdinalIgnoreCase)
|
||||
|| id.EndsWith("_albedo", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
result.Add(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a background texture by ID via <see cref="BackgroundFactory.Instance.Get"/>.
|
||||
/// Returns <c>null</c> on any error (missing resource, load failure).
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
|
||||
namespace Cthangover.Tools.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds which background an editor should show for a piece of content,
|
||||
/// so the author does not have to guess it from a list of hundreds:
|
||||
/// <list type="bullet">
|
||||
/// <item>a scene's background is the first <c>defaultBackground</c> of its
|
||||
/// <c>scenes/*.json</c> definition;</item>
|
||||
/// <item>an interactive belongs to the scene of the scenario that adds it
|
||||
/// (<c>interactive_add <id></c>).</item>
|
||||
/// </list>
|
||||
/// Both are best-effort hints: <c>null</c> means "no idea", and the author
|
||||
/// can always pick another background by hand.
|
||||
/// </summary>
|
||||
public static class SceneBackgroundService
|
||||
{
|
||||
private const string InteractiveAddCommand = "interactive_add";
|
||||
|
||||
/// <summary>Default background ID of a logical scene, or <c>null</c>.</summary>
|
||||
public static string ForScene(string sceneName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sceneName) || sceneName == "-")
|
||||
return null;
|
||||
|
||||
var defs = SceneDefinitions.Collect();
|
||||
if (defs == null)
|
||||
return null;
|
||||
|
||||
foreach (var pair in defs)
|
||||
{
|
||||
if (!string.Equals(pair.Key, sceneName, StringComparison.OrdinalIgnoreCase) || pair.Value == null)
|
||||
continue;
|
||||
|
||||
// Several mods may define the scene; the last one wins at runtime.
|
||||
for (var i = pair.Value.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (pair.Value[i]?.DefaultBackground is { Count: > 0 } list)
|
||||
return list[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans every scenario once and maps interactive IDs to the scene of
|
||||
/// the first scenario that adds them.
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> MapInteractivesToScenes()
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var mod in ScenarioFileService.ScanScenarioFiles())
|
||||
{
|
||||
foreach (var group in mod.Groups)
|
||||
{
|
||||
foreach (var file in group.Files)
|
||||
{
|
||||
var text = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath);
|
||||
if (string.IsNullOrEmpty(text) || text.IndexOf(InteractiveAddCommand, StringComparison.Ordinal) < 0)
|
||||
continue;
|
||||
|
||||
var scene = ScenarioFileService.ParseMetadata(text).Scene;
|
||||
foreach (var raw in text.Split('\n'))
|
||||
{
|
||||
var tokens = ScenarioSyntaxService.Tokenize(raw.Trim());
|
||||
if (tokens.Count >= 2 && tokens[0] == InteractiveAddCommand && !result.ContainsKey(tokens[1]))
|
||||
result[tokens[1]] = scene;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user