Scene editor for logical scenes; scenario editor Save refreshes the running game; novel_flow itest; mod cover
This commit is contained in:
1 parent
2b8eaa5452
commit
972df5ccff
17 files changed
+1692
-3
No files matched your search
@@ -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>();
|
||||
|
||||
@@ -277,6 +277,21 @@ namespace Cthangover.Tools.Services
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates <c>scenarios/<scene>[_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)
|
||||
{
|
||||
|
||||
@@ -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/<name>.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/<name>.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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user