This commit is contained in:
cthangover committed 2026-09-10 10:59:40 +03:00
commit 8e6ad52ee4
44 files changed
+6842

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
using System.Collections.Generic;
using System.IO;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Godot;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Static service for discovering and loading mod background resources.
/// Queries <see cref="Files.Collect"/> for files under
/// <c>"backgrounds"</c>, strips extensions to produce IDs, and loads textures
/// via <see cref="BackgroundFactory"/>. Used by the light editor, interactive
/// editor, and scenario editor to populate background selection lists and
/// render previews.
/// </summary>
public static class ModResourceService
{
/// <summary>
/// Returns all unique background IDs (filenames without extension) from
/// the <c>"backgrounds"</c> file collection across all installed mods.
/// </summary>
public static List<string> GetBackgroundIds()
{
var ids = new List<string>();
var files = Files.Collect("backgrounds");
var exts = GetExt();
foreach (var kvp in files)
{
var id = kvp.Key;
var ext = Path.GetExtension(id);
if (!string.IsNullOrWhiteSpace(id) && exts.Contains(ext.ToLower()))
{
var cleanId = id.Substring(0, id.LastIndexOf('.'));
if (!cleanId.EndsWith("_albedo") && !cleanId.EndsWith("_depth"))
ids.Add(cleanId);
}
}
return ids;
}
private static HashSet<string> GetExt()
{
var set = new HashSet<string>();
foreach (var ext in ModConfig.Instance.TextureExtensions)
{
if(string.IsNullOrWhiteSpace(ext))
continue;
set.Add(ext.ToLower());
}
return set;
}
/// <summary>
/// Loads a background texture by ID via <see cref="BackgroundFactory.Instance.Get"/>.
/// Returns <c>null</c> on any error (missing resource, load failure).
/// </summary>
public static Texture2D LoadBackgroundTexture(string id)
{
try
{
return BackgroundFactory.Instance.Get(id);
}
catch
{
return null;
}
}
}
}
+338
View File
@@ -0,0 +1,338 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Utils;
using Cthangover.Core.Mods;
using Godot;
namespace Cthangover.Tools.Services
{
/// <summary>Parsed metadata from a scenario file header (before the <c>---</c> separator).</summary>
public sealed class ScenarioMetadata
{
/// <summary>Target scene name for the scenario.</summary>
public string Scene { get; set; } = "-";
/// <summary>Priority level of the scenario.</summary>
public string Priority { get; set; } = "-";
/// <summary>Condition string for the scenario to activate.</summary>
public string Condition { get; set; } = "-";
}
/// <summary>Collected references extracted from scenario command lines for the info panel.</summary>
public sealed class ScenarioReferences
{
/// <summary>Scene switch targets referenced in commands.</summary>
public List<string> SwitchTargets { get; set; } = new();
/// <summary>Localisation keys found in named parameters.</summary>
public List<string> LocaleKeys { get; set; } = new();
/// <summary>Quest IDs referenced in named <c>quest_id</c> parameters.</summary>
public List<string> QuestRefs { get; set; } = new();
/// <summary>Background IDs referenced in positional parameters of background-related commands.</summary>
public List<string> BackgroundRefs { get; set; } = new();
}
/// <summary>Describes a single scenario file found during scanning.</summary>
public sealed class ScenarioFileInfo
{
/// <summary>Absolute path on disk.</summary>
public string AbsolutePath { 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>A group of scenario files within the same subdirectory under <c>scenarios/</c>.</summary>
public sealed class ScenarioGroup
{
/// <summary>Relative directory path (empty for root-level files).</summary>
public string DirectoryPath { get; set; }
/// <summary>Files in this group, sorted by name.</summary>
public List<ScenarioFileInfo> Files { get; set; } = new();
}
/// <summary>Represents all scenario files for one mod, grouped by subdirectory.</summary>
public sealed class ModScenarioTree
{
/// <summary>The mod's identifier.</summary>
public string ModId { get; set; }
/// <summary>Groups of scenario files within this mod.</summary>
public List<ScenarioGroup> Groups { get; set; } = new();
}
/// <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.
/// </summary>
public static class ScenarioFileService
{
/// <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.
/// </summary>
public static List<ModScenarioTree> ScanScenarioFiles()
{
var result = new List<ModScenarioTree>();
var roots = ModRegistry.AllModsRoots;
foreach (var (modsPath, _) in roots)
{
if (!Directory.Exists(modsPath))
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)
.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()
})
.ToList();
result.Add(new ModScenarioTree { ModId = modId, Groups = groups });
}
}
return result;
}
/// <summary>
/// Parses the header block (before first <c>---</c> line) in YAML-like
/// <c>key: value</c> format to extract scene, priority, and condition.
/// </summary>
public static ScenarioMetadata ParseMetadata(string text)
{
var meta = new ScenarioMetadata();
var metaEnd = text.IndexOf("\n---");
var metaText = metaEnd >= 0 ? text.Substring(0, metaEnd) : "";
foreach (var line in metaText.Split('\n'))
{
var t = line.Trim();
if (t.Length == 0 || t.StartsWith("#"))
continue;
var ci = t.IndexOf(':');
if (ci <= 0)
continue;
var key = t.Substring(0, ci).Trim().ToLowerInvariant();
var val = t.Substring(ci + 1).Trim();
switch (key)
{
case "scene": meta.Scene = val; break;
case "priority": meta.Priority = val; break;
case "condition": meta.Condition = val; break;
}
}
return meta;
}
/// <summary>
/// Walks the body of the scenario (after <c>---</c>) and extracts references:
/// background IDs, scene switch targets, locale keys, and quest IDs.
/// Uses <see cref="ScenarioSyntaxService.ExtractCommandParams"/> to tokenise
/// each line and <see cref="ScenarioCommandStrategyFactory"/> metadata to
/// classify positional parameters.
/// </summary>
/// <param name="text">Full scenario file text including header.</param>
public static ScenarioReferences ExtractReferences(string text)
{
var refs = new ScenarioReferences();
var metaEnd = text.IndexOf("\n---");
var content = metaEnd >= 0 ? text.Substring(metaEnd + 4) : text;
foreach (var rawLine in content.Split('\n'))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(":"))
continue;
ScenarioSyntaxService.ExtractCommandParams(line, out var cmd, out var positional, out var named);
if (string.IsNullOrEmpty(cmd))
continue;
var meta = ScenarioCommandStrategyFactory.GetReferenceMetadata(cmd);
if (meta != null && positional.Count > 0)
{
switch (meta.Positional0Kind)
{
case PositionalReferenceKind.Background:
refs.BackgroundRefs.Add(positional[0]);
break;
case PositionalReferenceKind.Scene:
refs.SwitchTargets.Add(positional[0]);
break;
}
}
if (named.TryGetValue("key", out var lk))
refs.LocaleKeys.Add(lk);
if (named.TryGetValue("quest_id", out var qid))
refs.QuestRefs.Add(qid);
}
return refs;
}
/// <summary>Reads the entire file at <paramref name="filePath"/> as a UTF-8 string.</summary>
public static string ReadAllText(string filePath)
{
return File.ReadAllText(filePath);
}
/// <summary>Writes <paramref name="text"/> to <paramref name="filePath"/>, overwriting existing content.</summary>
public static void WriteAllText(string filePath, string text)
{
File.WriteAllText(filePath, text);
}
/// <summary>Converts an absolute file path to a path relative to <c>res://</c>, using forward slashes.</summary>
public static string GetRelativePath(string absolutePath)
{
var projectPath = GameEnvironment.IsRuntime ? ProjectSettings.GlobalizePath("res://") : "";
return Path.GetRelativePath(projectPath, absolutePath).Replace('\\', '/');
}
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 allFiles = ScanAllScenarioFiles();
foreach (var file in allFiles)
{
string text;
try
{
text = File.ReadAllText(file.AbsolutePath);
}
catch
{
continue;
}
var metadata = ParseMetadata(text);
if (string.IsNullOrEmpty(metadata.Scene) || metadata.Scene == "-")
continue;
var sceneKey = metadata.Scene.ToLowerInvariant();
if (!sceneNodes.TryGetValue(sceneKey, out var node))
{
node = new SceneGraphNode
{
SceneName = metadata.Scene,
ModId = file.ModId,
IsStartScene = string.Equals(metadata.Scene, "start_scene", System.StringComparison.OrdinalIgnoreCase)
};
sceneNodes[sceneKey] = node;
}
node.ScenarioCount++;
node.Scenarios.Add(new ScenarioGraphLink
{
Name = file.Name,
Priority = int.TryParse(metadata.Priority, out var p) ? p : 0,
FilePath = file.AbsolutePath
});
var refs = ExtractReferences(text);
foreach (var target in refs.SwitchTargets)
{
var srcKey = metadata.Scene.ToLowerInvariant();
var dstKey = target.ToLowerInvariant();
var edgeKey = srcKey + "|" + dstKey;
if (!edgeSet.Contains(edgeKey))
{
edgeSet.Add(edgeKey);
data.Edges.Add(new SceneGraphEdge
{
FromScene = metadata.Scene,
ToScene = target
});
}
}
}
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;
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.Collections.Generic;
namespace Cthangover.Tools.Services
{
public sealed class ScenarioGraphLink
{
public string Name { get; set; }
public int Priority { get; set; }
public string FilePath { get; set; }
}
public sealed class SceneGraphNode
{
public string SceneName { get; set; }
public string ModId { get; set; }
public int ScenarioCount { get; set; }
public List<string> Backgrounds { get; set; } = new();
public bool IsStartScene { get; set; }
public List<ScenarioGraphLink> Scenarios { get; set; } = new();
public bool HasErrors { get; set; }
}
public sealed class SceneGraphEdge
{
public string FromScene { get; set; }
public string ToScene { get; set; }
}
public sealed class SceneGraphData
{
public List<SceneGraphNode> Nodes { get; set; } = new();
public List<SceneGraphEdge> Edges { get; set; } = new();
}
}
+126
View File
@@ -0,0 +1,126 @@
using System.Collections.Generic;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Factories.Impls;
using Godot;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Static service providing scenario DSL syntax highlighting, tokenisation,
/// and command-line parsing. <see cref="CreateHighlighter"/> produces a
/// <see cref="CodeHighlighter"/> for the scenario editor's
/// <see cref="TextEdit"/>. <see cref="ExtractCommandParams"/> tokenises a
/// scenario command line into a command name, positional arguments, and
/// named (<c>key=value</c>) parameters — used by
/// <see cref="ScenarioFileService.ExtractReferences"/> for reference analysis.
/// </summary>
public static class ScenarioSyntaxService
{
/// <summary>
/// Builds a <see cref="CodeHighlighter"/> for scenario DSL syntax.
/// Comments (lines starting with <c>#</c>) are green, strings are orange,
/// command names are blue, and argument keywords (e.g. <c>key</c>,
/// <c>wait</c>, <c>quest_id</c>) are amber.
/// </summary>
public static CodeHighlighter CreateHighlighter()
{
var hl = new CodeHighlighter();
hl.AddColorRegion("#", null, new Color(0.45f, 0.75f, 0.45f), true);
hl.AddColorRegion("\"", "\"", new Color(1f, 0.6f, 0.4f), false);
hl.NumberColor = new Color(0.7f, 0.7f, 1f);
hl.SymbolColor = new Color(0.8f, 0.8f, 0.8f);
var cmdColor = new Color(0.4f, 0.7f, 1f);
hl.KeywordColors = new Godot.Collections.Dictionary();
foreach (var cmd in ScenarioCommandStrategyFactory.GetAllCommandNames())
hl.KeywordColors[cmd] = cmdColor;
foreach (var cmd in new[] {
"option", "select"
}) hl.KeywordColors[cmd] = cmdColor;
var argColor = new Color(0.8f, 0.6f, 0.3f);
foreach (var kw in new[] {
"key", "first", "second", "hide_color", "wait", "speed", "hidden",
"sprites", "loop", "target", "quest_id", "show", "hide", "duration"
}) hl.KeywordColors[kw] = argColor;
return hl;
}
/// <summary>
/// Splits a line into tokens, handling quoted strings (with backslash
/// escaping) and whitespace. Returns all non-whitespace tokens in order.
/// </summary>
public static List<string> Tokenize(string line)
{
var result = new List<string>();
var i = 0;
while (i < line.Length)
{
if (char.IsWhiteSpace(line[i])) { i++; continue; }
if (line[i] == '"')
{
i++;
var start = i;
while (i < line.Length && line[i] != '"')
{
if (line[i] == '\\' && i + 1 < line.Length) i++;
i++;
}
result.Add(line.Substring(start, i - start));
if (i < line.Length) i++;
continue;
}
var tok = i;
while (i < line.Length && !char.IsWhiteSpace(line[i])) i++;
result.Add(line.Substring(tok, i - tok));
}
return result;
}
/// <summary>Strips surrounding double quotes from a value if present.</summary>
public static string StripQuotes(string val)
{
if (val.Length >= 2 && val.StartsWith("\"") && val.EndsWith("\""))
return val.Substring(1, val.Length - 2);
return val;
}
/// <summary>
/// Parses a scenario command line into its components. Tokenises via
/// <see cref="Tokenize"/>, then extracts the first token as the command
/// name (lowercased), remaining bare tokens as positional arguments, and
/// <c>key=value</c> pairs as named arguments. The <c>-></c> arrow token
/// is consumed but discarded.
/// </summary>
/// <param name="line">A single scenario command line (trimmed).</param>
/// <param name="cmd">The command name, lowercased.</param>
/// <param name="positional">Positional arguments in order.</param>
/// <param name="named">Named arguments keyed by lowercased name.</param>
public static void ExtractCommandParams(string line, out string cmd, out List<string> positional, out Dictionary<string, string> named)
{
positional = new List<string>();
named = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
cmd = "";
var tokens = Tokenize(line);
if (tokens.Count == 0)
return;
cmd = tokens[0].ToLowerInvariant();
for (int t = 1; t < tokens.Count; t++)
{
var tk = tokens[t];
if (tk == "->") { t++; continue; }
var eq = tk.IndexOf('=');
if (eq > 0)
named[tk.Substring(0, eq).ToLowerInvariant()] = StripQuotes(tk.Substring(eq + 1));
else
positional.Add(tk);
}
}
}
}