Scene editor for logical scenes; scenario editor Save refreshes the running game; novel_flow itest; mod cover

This commit is contained in:
cthangover committed 2026-10-22 17:09:38 +02:00
1 parent 2b8eaa5452
commit 972df5ccff
17 files changed
+1692 -3

No files matched your search

+19
View File
@@ -46,6 +46,25 @@ namespace Cthangover.Tools.Services
return ids;
}
/// <summary>Ids of every sound (group <c>sounds</c>, path without extension): the choices for a scene's ambient.</summary>
public static List<string> GetSoundIds()
{
var ids = new List<string>();
var exts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var ext in ModConfig.Instance.AudioExtensions)
exts.Add(ext);
foreach (var id in Files.Collect("sounds").Keys)
{
var ext = Path.GetExtension(id);
if (exts.Contains(ext))
ids.Add(id.Substring(0, id.Length - ext.Length));
}
ids.Sort(StringComparer.OrdinalIgnoreCase);
return ids;
}
private static HashSet<string> GetExt()
{
var set = new HashSet<string>();
+15
View File
@@ -277,6 +277,21 @@ namespace Cthangover.Tools.Services
return true;
}
/// <summary>
/// Creates <c>scenarios/&lt;scene&gt;[_n].scenario</c> in a folder mod: a header bound to
/// <paramref name="scene"/> and one line of text to replace. Returns the file key, or
/// null when the mod is not writable.
/// </summary>
public static string CreateForScene(string modId, string scene, string firstLine)
{
var path = $"{ScenariosDir}/{scene}{ScenarioExtension}";
for (var n = 2; ModManager.Instance.ReadFileText(modId, path) != null; n++)
path = $"{ScenariosDir}/{scene}_{n}{ScenarioExtension}";
var text = $"scene: {scene}\npriority: 10\n---\n{firstLine}\nend\n";
return ModFileService.WriteText(modId, path, text) ? MakeKey(modId, path) : null;
}
/// <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)
{
+89
View File
@@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
namespace Cthangover.Tools.Services
{
/// <summary>
/// One mod's file of a logical scene (<c>scenes/&lt;name&gt;.json</c>). The game merges
/// the files of all mods into one <see cref="SceneDefinition"/> (backgrounds add up,
/// a later non-empty ambient or default scenario wins); the scene editor works on
/// the files themselves, because only one of them — the chosen mod's — is written.
/// </summary>
public sealed class SceneFileEntry
{
/// <summary>Mod that owns the file.</summary>
public string ModId { get; set; }
/// <summary>Mod-relative path of the file.</summary>
public string RelativePath { get; set; }
/// <summary>Scene name (the logical scene id).</summary>
public string Name { get; set; }
/// <summary>Backgrounds this file adds; the game shows one of the merged list at random.</summary>
public List<string> Backgrounds { get; set; } = new();
/// <summary>Ambient sound id (group <c>sounds</c>), empty for none.</summary>
public string Ambient { get; set; } = string.Empty;
/// <summary>Mod-relative path of the scenario played when nothing else starts, empty for none.</summary>
public string DefaultScenario { get; set; } = string.Empty;
/// <summary>Default path of a scene file: <c>scenes/&lt;name&gt;.json</c>.</summary>
public static string PathFor(string name) => "scenes/" + name + ".json";
/// <summary>Reads a scene file with the game's own JSON options; null when it is not a scene.</summary>
public static SceneFileEntry Parse(string modId, string relativePath, string json)
{
SceneDefinition definition;
try
{
definition = JsonSerializer.Deserialize<SceneDefinition>(json ?? string.Empty, JsonOptionsProvider.ReadOptions);
}
catch (JsonException)
{
return null;
}
if (string.IsNullOrEmpty(definition?.Name))
return null;
return new SceneFileEntry
{
ModId = modId,
RelativePath = relativePath,
Name = definition.Name,
Backgrounds = definition.DefaultBackground ?? new List<string>(),
Ambient = definition.DefaultAmbient ?? string.Empty,
DefaultScenario = definition.DefaultScenario ?? string.Empty,
};
}
/// <summary>
/// The file text in the style of the hand-written scene files (two-space indent, LF,
/// camelCase keys). Empty fields are left out, so a file of a mod that only adds a
/// background does not reset another mod's ambient or default scenario.
/// </summary>
public string ToJson()
{
var json = new StringBuilder("{\n \"name\": ").Append(Quote(Name));
if (Backgrounds.Count > 0)
{
json.Append(",\n \"defaultBackground\": [");
for (var i = 0; i < Backgrounds.Count; i++)
json.Append(i == 0 ? string.Empty : ", ").Append(Quote(Backgrounds[i]));
json.Append(']');
}
if (!string.IsNullOrEmpty(Ambient))
json.Append(",\n \"defaultAmbient\": ").Append(Quote(Ambient));
if (!string.IsNullOrEmpty(DefaultScenario))
json.Append(",\n \"defaultScenario\": ").Append(Quote(DefaultScenario));
return json.Append("\n}\n").ToString();
}
private static string Quote(string value) => JsonSerializer.Serialize(value ?? string.Empty);
}
}
+92
View File
@@ -0,0 +1,92 @@
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenes;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Scene files (<c>scenes/**.json</c>) of the loaded mods for the scene editor: reading
/// them per mod in load order (the order the game merges them in), writing one, and
/// making the running game see the change at once. The game caches scene definitions
/// and scenario headers; <see cref="RefreshGame"/> drops those caches and lets
/// <see cref="SceneManager"/> rescan, the same steps the scenario studio takes after
/// creating a scene in its layer, so a new scene can be entered without a mod reload.
/// </summary>
public static class SceneFileService
{
private const string ScenesDir = "scenes";
private static readonly Regex NamePattern = new("^[a-z][a-z0-9_]*$");
/// <summary>Every scene file of the loaded mods and of the studio layer, in load order.</summary>
public static List<SceneFileEntry> Scan()
{
var result = new List<SceneFileEntry>();
var manager = ModManager.Instance;
if (manager?.Mods == null)
return result;
foreach (var modId in ModRegistry.Instance.GetOrderedModIds())
{
if (manager.Mods.TryGetValue(modId, out var mod) && mod?.FileProvider != null)
Collect(mod.FileProvider, modId, ScenesDir, result);
}
foreach (var overlay in ModOverlays.All)
Collect(overlay.Value, overlay.Key, ScenesDir, result);
return result;
}
/// <summary>The scene name typed by the author, trimmed and lowercased; null when it is not an id (latin letters, digits, _).</summary>
public static string ValidName(string name)
{
name = (name ?? string.Empty).Trim().ToLowerInvariant();
return NamePattern.IsMatch(name) ? name : null;
}
/// <summary>Writes the entry to its mod; false when the mod is not a folder mod.</summary>
public static bool Write(SceneFileEntry entry)
{
return ModFileService.WriteText(entry.ModId, entry.RelativePath, entry.ToJson());
}
/// <summary>Deletes the entry's file; false when the mod is not a folder mod or the file is gone.</summary>
public static bool Delete(SceneFileEntry entry)
{
var path = ModFileService.GetWritablePath(entry.ModId, entry.RelativePath);
if (path == null || !File.Exists(path))
return false;
File.Delete(path);
return true;
}
/// <summary>Makes the running game re-read scene files and scenario headers.</summary>
public static void RefreshGame()
{
ScenarioDefs.Invalidate();
ModManager.Instance.GetResolver<SceneDefinitionResolver>()?.ClearCache();
AppServices.Current.SceneDefs.Clear();
SceneManager.RescanScenes();
}
private static void Collect(IModFileProvider provider, string modId, string directory, List<SceneFileEntry> result)
{
foreach (var entry in provider.ListFiles(directory))
{
if (entry.EndsWith("/"))
{
Collect(provider, modId, entry.TrimEnd('/'), result);
continue;
}
if (!entry.EndsWith(".json", System.StringComparison.OrdinalIgnoreCase))
continue;
var scene = SceneFileEntry.Parse(modId, entry, provider.ReadFileText(entry));
if (scene != null)
result.Add(scene);
}
}
}
}