Studio: backgrounds dropped during the game are picked up by the Resources button

Layer gets backgrounds/; StudioBackgrounds re-reads the file list and the background
cache and shows the current background (with _depth/_albedo) anew. The Resources
button sits on the transport bar (every tab); a new scene picks its background from
a list with the source mod. studio_live covers drop, reload, replace and export.
This commit is contained in:
cthangover committed 2026-10-21 19:18:26 +02:00
1 parent 223bb8fe0d
commit 81594808f6
12 files changed
+318 -33

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System.Collections.Generic;
/// <summary>
/// What the "Resources" button found: how many backgrounds the game has now and
/// which of them appeared since the last read (files dropped in while the
/// game ran). A replaced picture is not "added" — it is simply shown anew.
/// </summary>
public sealed class StudioAssetReload
{
public int Total { get; set; }
public List<string> Added { get; set; } = new();
}
}
+11 -1
View File
@@ -12,10 +12,13 @@ namespace Cthangover.Mods.ScenarioStudio
/// leads there. Exits can be taken directly (a debugging teleport); new
/// scenes are created in the studio layer, the current scenario can be
/// linked to any scene, and the layer can be exported as a regular mod.
/// The hint under the buttons tells where to drop new backgrounds (a mod or
/// the layer's <c>backgrounds/</c>); the "Resources" button of the transport
/// bar re-reads them, so a new scene can be built on a new picture.
/// </summary>
public static class StudioMapPage
{
public static void Build(VBoxContainer content, StudioSession session, Action<StudioActionButton> go, Action<StudioActionButton> newScene, Action<StudioActionButton> link, Action<StudioActionButton> export)
public static void Build(VBoxContainer content, StudioSession session, string layerPath, Action<StudioActionButton> go, Action<StudioActionButton> newScene, Action<StudioActionButton> link, Action<StudioActionButton> export)
{
var visited = false;
foreach (var node in session.Nodes)
@@ -57,6 +60,13 @@ namespace Cthangover.Mods.ScenarioStudio
actions.AddChild(StudioActionButton.Create("studio/map_link", link));
actions.AddChild(StudioActionButton.Create("studio/map_export", export));
content.AddChild(actions);
var backgrounds = System.IO.Path.Combine(layerPath, "backgrounds");
// Arbitrary wrap: the layer path is one long "word" and must not widen the dock.
var hint = UiKit.Hint(string.Format(TranslationServer.Translate("studio/map_assets_hint"), backgrounds), UiTokens.FontSmall);
hint.AutowrapMode = TextServer.AutowrapMode.Arbitrary;
hint.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
content.AddChild(hint);
}
private static Control BuildNode(StudioSceneNode node, Action<StudioActionButton> go)
+101
View File
@@ -0,0 +1,101 @@
namespace Cthangover.Mods.ScenarioStudio
{
using System;
using System.Collections.Generic;
using System.IO;
using Cthangover.Core.Actions;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Godot;
/// <summary>
/// Backgrounds as the studio offers them: ids of the <c>backgrounds</c> file
/// group of every mod and of the studio layer (the layer wins, see
/// <see cref="FileListResolver"/>), without the <c>_depth</c>/<c>_albedo</c>
/// lighting maps that belong to a background.
/// <para/>
/// The author drops a picture into a folder mod or into the layer while the
/// game runs; both the file list of the group and the texture cache of
/// <see cref="BackgroundFactory"/> were filled before, so <see cref="Reload"/>
/// drops both and shows the current background again — a replaced picture
/// (or its depth map) is visible at once, a new one can be picked for a scene.
/// </summary>
public static class StudioBackgrounds
{
private const string DepthSuffix = "_depth";
private const string AlbedoSuffix = "_albedo";
/// <summary>Background ids (group-relative paths without extension), sorted.</summary>
public static List<string> Ids()
{
var ids = new List<string>();
var files = Files.Collect(BackgroundFactory.Instance.GroupName);
if (files == null)
return ids;
var seen = new HashSet<string>();
foreach (var key in files.Keys)
{
if (!IsTexture(key))
continue;
var id = key.Substring(0, key.Length - Path.GetExtension(key).Length);
if (!id.EndsWith(DepthSuffix, StringComparison.OrdinalIgnoreCase) && !id.EndsWith(AlbedoSuffix, StringComparison.OrdinalIgnoreCase) && seen.Add(id))
ids.Add(id);
}
ids.Sort(StringComparer.Ordinal);
return ids;
}
/// <summary>The mod (or the layer) whose file the background <paramref name="id"/> is read from; null when unknown.</summary>
public static string SourceOf(string id)
{
var files = Files.Collect(BackgroundFactory.Instance.GroupName);
if (files == null)
return null;
foreach (var extension in ModConfig.Instance.TextureExtensions)
{
if (files.TryGetValue(id + extension, out var entry))
return entry.ModId;
}
return null;
}
/// <summary>
/// Re-reads the background files: drops the collected file lists and the
/// cached background textures, then puts the current background and its
/// lighting maps on screen again from the files as they are now.
/// </summary>
public static void Reload()
{
ModManager.Instance.GetResolver<FileListResolver>()?.ClearCache();
BackgroundFactory.Instance.Clear();
var id = SceneContextNode.LastBackgroundID;
if (string.IsNullOrEmpty(id))
return;
var view = SceneContextNode.FindNode<Node>("ViewBox") as IBackgroundView;
var texture = BackgroundFactory.Instance.Get(id);
if (view == null || texture == null)
return;
view.SetBackgroundTexture(texture);
LightingBridge.SetupBackgroundMaps(BackgroundFactory.Instance.Get(id + DepthSuffix), BackgroundFactory.Instance.Get(id + AlbedoSuffix));
}
private static bool IsTexture(string key)
{
var extension = Path.GetExtension(key);
foreach (var known in ModConfig.Instance.TextureExtensions)
{
if (string.Equals(known, extension, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}
}
+6 -3
View File
@@ -15,10 +15,12 @@ namespace Cthangover.Mods.ScenarioStudio
/// <summary>
/// The studio's edit layer: a folder per game (<c>user://studio/&lt;game&gt;/</c>,
/// or <c>--studio-layer=&lt;dir&gt;</c>) laid out like a mod (<c>scenarios/</c>,
/// <c>scenes/</c>) and mounted as content overlay <see cref="Id"/> through
/// <c>scenes/</c>, <c>backgrounds/</c>) and mounted as content overlay <see cref="Id"/> through
/// <see cref="ModOverlays"/>. Scenarios written here override same-named
/// scenarios of any mod, so a player can change content of read-only zip
/// mods without touching them; new scenes and scenarios live here too.
/// mods without touching them; new scenes and scenarios live here too, and
/// pictures the author drops into <c>backgrounds/</c> become backgrounds
/// after <see cref="StudioBackgrounds.Reload"/>.
/// <para/>
/// Edits of folder mods go into the mod itself (the author's workflow)
/// unless <c>--studio-write=layer</c> forces the layer — tests use it to
@@ -53,11 +55,12 @@ namespace Cthangover.Mods.ScenarioStudio
RootPath = Path.Combine(ProjectSettings.GlobalizePath(dir), game);
}
/// <summary>Creates the folder if needed, mounts it as overlay and refreshes scenario and scene tables.</summary>
/// <summary>Creates the folders if needed, mounts it as overlay and refreshes scenario and scene tables.</summary>
public void Mount()
{
Directory.CreateDirectory(Path.Combine(RootPath, "scenarios"));
Directory.CreateDirectory(Path.Combine(RootPath, "scenes"));
Directory.CreateDirectory(Path.Combine(RootPath, "backgrounds"));
ModOverlays.Mount(Id, new FolderModFileProvider(RootPath, Id));
RefreshContent();
GameLogger.Log("UI", $"Scenario studio: layer '{Id}' mounted at {RootPath}");
+53 -11
View File
@@ -24,8 +24,9 @@ namespace Cthangover.Mods.ScenarioStudio
/// <see cref="StudioLayer"/>, the per-scenario <see cref="StudioWorkingCopy"/>s
/// and the rollback <see cref="StudioSnapshot"/> of the running scenario, and
/// performs every action of the dock: apply edits, the restart modes, run a
/// scene candidate, go to a scene, create scenes and scenarios, change
/// state values, export the layer.
/// scene candidate, go to a scene, create scenes and scenarios, re-read
/// backgrounds dropped in while the game runs, change state values, export
/// the layer.
/// <para/>
/// <see cref="Version"/> grows on every change the dock may want to show;
/// the dock polls it instead of subscribing, so a burst of dialog actions
@@ -452,18 +453,35 @@ namespace Cthangover.Mods.ScenarioStudio
// ── new content ────────────────────────────────────────────
/// <summary>Creates a scene in the layer (backgrounds of the current scene) with a first scenario; returns false on a bad name.</summary>
public bool CreateScene(string name)
/// <summary>The scene or mod id typed by the author, trimmed and lowercased; null when it is not one (latin letters, digits, _).</summary>
public static string ValidName(string name)
{
name = (name ?? string.Empty).Trim().ToLowerInvariant();
if (!ModIdPattern.IsMatch(name))
return ModIdPattern.IsMatch(name) ? name : null;
}
/// <summary>
/// Creates a scene in the layer with a first scenario; returns false on a bad name.
/// The scene shows <paramref name="background"/>, or the backgrounds of the current scene when it is null.
/// </summary>
public bool CreateScene(string name, string background = null)
{
name = ValidName(name);
if (name == null)
return false;
var current = SceneName;
var defs = AppServices.Current.SceneDefs.CollectAll();
List<string> backgrounds = null;
if (current != null && defs != null && defs.TryGetValue(current, out var list) && list.Count > 0)
backgrounds = list[0].DefaultBackground;
if (background != null)
{
backgrounds = new List<string> { background };
}
else
{
var current = SceneName;
var defs = AppServices.Current.SceneDefs.CollectAll();
if (current != null && defs != null && defs.TryGetValue(current, out var list) && list.Count > 0)
backgrounds = list[0].DefaultBackground;
}
var scenario = Layer.CreateScene(name, backgrounds, StudioScriptEdits.NewLine());
StudioLayer.RefreshContent();
@@ -484,11 +502,35 @@ namespace Cthangover.Mods.ScenarioStudio
return name;
}
/// <summary>
/// Re-reads the backgrounds after the author dropped pictures into a mod or
/// the layer (<see cref="StudioBackgrounds.Reload"/>): the current background
/// is shown from its file again, new ones can be picked for a scene.
/// </summary>
public StudioAssetReload ReloadAssets()
{
var before = new HashSet<string>(StudioBackgrounds.Ids());
StudioBackgrounds.Reload();
var result = new StudioAssetReload();
foreach (var id in StudioBackgrounds.Ids())
{
result.Total++;
if (!before.Contains(id))
result.Added.Add(id);
}
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_assets_reloaded", result.Total, result.Added.Count));
GameLogger.Log("UI", $"Scenario studio: backgrounds re-read, {result.Total} in total, new: [{string.Join(", ", result.Added)}]");
Touch();
return result;
}
/// <summary>Exports the layer as mod <paramref name="modId"/>; null on a bad id.</summary>
public string Export(string modId)
{
modId = (modId ?? string.Empty).Trim().ToLowerInvariant();
if (!ModIdPattern.IsMatch(modId))
modId = ValidName(modId);
if (modId == null)
return null;
var path = Layer.Export(modId);
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_exported", modId));
+42 -2
View File
@@ -33,6 +33,8 @@ namespace Cthangover.Mods.ScenarioStudio
private const long TargetEnd = 0;
private const long TargetLabelBase = 1000;
private const long TargetSceneBase = 2000;
private const long BackgroundCurrent = 0;
private const long BackgroundBase = 1;
private const int CheckpointSave = 100;
private const int CheckpointRestore = 101;
@@ -50,6 +52,8 @@ namespace Cthangover.Mods.ScenarioStudio
private StudioCodeLine _promptLine;
private List<string> _pickLabels = new();
private List<string> _pickScenes = new();
private List<string> _pickBackgrounds = new();
private string _newSceneName;
private PanelContainer _runState;
private Label _runStateText;
@@ -125,6 +129,10 @@ namespace Cthangover.Mods.ScenarioStudio
_continueButton.Pressed += OnContinuePressed;
_restartButton = GetNode<MenuButton>(Root + "Transport/Row/RestartButton");
BuildRestartMenu(_restartButton);
var reload = GetNode<Button>(Root + "Transport/Row/ReloadButton");
StudioStyles.Transport(reload, Tr("studio/reload_assets"));
reload.TooltipText = string.Format(Tr("studio/reload_assets_tip"), System.IO.Path.Combine(Live.Layer.RootPath, "backgrounds"));
reload.Pressed += OnReloadAssets;
_crumbs = GetNode<HFlowContainer>(Root + "Context/Column/Crumbs");
_scope = GetNode<HFlowContainer>(Root + "Context/Column/Scope");
@@ -325,7 +333,7 @@ namespace Cthangover.Mods.ScenarioStudio
StudioLogPage.Build(_content, _session);
break;
case StudioTab.Map:
StudioMapPage.Build(_content, _session, OnGoToScene, OnNewScene, OnLinkScene, OnExport);
StudioMapPage.Build(_content, _session, Live.Layer.RootPath, OnGoToScene, OnNewScene, OnLinkScene, OnExport);
break;
}
}
@@ -667,7 +675,39 @@ namespace Cthangover.Mods.ScenarioStudio
private void OnSceneNameEntered(string name)
{
_prompt = null;
_status = Live.CreateScene(name) ? string.Format(Tr("studio/scene_created"), name.Trim().ToLowerInvariant()) : Tr("studio/bad_name");
_newSceneName = StudioLive.ValidName(name);
if (_newSceneName == null)
{
_status = Tr("studio/bad_name");
Refresh();
return;
}
var menu = StudioPrompt.Pick(this, OnSceneBackgroundPicked);
menu.AddItem(Tr("studio/background_current"), (int)BackgroundCurrent);
_pickBackgrounds = StudioBackgrounds.Ids();
menu.AddSeparator(Tr("studio/backgrounds"));
for (var i = 0; i < _pickBackgrounds.Count; i++)
menu.AddItem(string.Format(Tr("studio/background_item"), _pickBackgrounds[i], StudioBackgrounds.SourceOf(_pickBackgrounds[i])), (int)(BackgroundBase + i));
_prompt = menu;
StudioPrompt.Open(menu);
}
private void OnSceneBackgroundPicked(long id)
{
_prompt = null;
var index = (int)(id - BackgroundBase);
var background = index >= 0 && index < _pickBackgrounds.Count ? _pickBackgrounds[index] : null;
_status = Live.CreateScene(_newSceneName, background) ? string.Format(Tr("studio/scene_created"), _newSceneName) : Tr("studio/bad_name");
Refresh();
}
private void OnReloadAssets()
{
var reload = Live.ReloadAssets();
_status = reload.Added.Count == 0
? string.Format(Tr("studio/assets_reloaded"), reload.Total)
: string.Format(Tr("studio/assets_reloaded_new"), reload.Total, string.Join(", ", reload.Added));
Refresh();
}