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

+9
View File
@@ -156,3 +156,12 @@ studio/log_text_checkpoint_restored = back to the checkpoint in {0}
studio/drag_next_line = Drag to another line to run it next studio/drag_next_line = Drag to another line to run it next
studio/next_line_failed = The dialog cannot continue from this line studio/next_line_failed = The dialog cannot continue from this line
studio/log_text_next_line = next line moved to {0} studio/log_text_next_line = next line moved to {0}
studio/reload_assets = Resources
studio/reload_assets_tip = Re-read backgrounds dropped during the game into backgrounds/ of a mod or of the layer ({0})
studio/map_assets_hint = Put new backgrounds into backgrounds/ of your mod or of the layer ({0}), then press "Resources" on the bar above. A <background>_depth next to it feeds the lighting.
studio/assets_reloaded = Resources re-read: {0} backgrounds, nothing new
studio/assets_reloaded_new = Resources re-read: {0} backgrounds, new: {1}
studio/log_text_assets_reloaded = resources re-read: {0} backgrounds, {1} new
studio/background_current = Backgrounds of the current scene
studio/backgrounds = Backgrounds
studio/background_item = background {0} · {1}
+9
View File
@@ -156,3 +156,12 @@ studio/log_text_checkpoint_restored = возврат к контрольной
studio/drag_next_line = Перетащите на другую строку, чтобы она выполнилась следующей studio/drag_next_line = Перетащите на другую строку, чтобы она выполнилась следующей
studio/next_line_failed = С этой строки диалог продолжить не может studio/next_line_failed = С этой строки диалог продолжить не может
studio/log_text_next_line = следующая строка перенесена на {0} studio/log_text_next_line = следующая строка перенесена на {0}
studio/reload_assets = Ресурсы
studio/reload_assets_tip = Перечитать фоны, положенные во время игры в backgrounds/ мода или слоя ({0})
studio/map_assets_hint = Новые фоны кладите в backgrounds/ своего мода или слоя ({0}), затем нажмите «Ресурсы» на панели сверху. Рядом можно положить <фон>_depth для освещения.
studio/assets_reloaded = Ресурсы перечитаны: фонов {0}, новых нет
studio/assets_reloaded_new = Ресурсы перечитаны: фонов {0}, новые: {1}
studio/log_text_assets_reloaded = ресурсы перечитаны: фонов {0}, новых {1}
studio/background_current = Фоны текущей сцены
studio/backgrounds = Фоны
studio/background_item = фон {0} · {1}
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "Scenario Studio", "name": "Scenario Studio",
"version": "0.1.0", "version": "0.1.0",
"author": "ct", "author": "ct",
"description": "In-game scenario debugger and live editor: a non-modal dock over the running game with pause, step, breakpoints and restarts (with state rollback), the current scenario line, scene candidates with the reason they did or did not start, editable game state, an event log and the scene map. Edits go into folder mods or into a per-game studio layer over read-only mods, which can be exported as a mod. Author mode shows the script, novice mode the story moment.", "description": "In-game scenario debugger and live editor: a non-modal dock over the running game with pause, step, breakpoints and restarts (with state rollback), the current scenario line, scene candidates with the reason they did or did not start, editable game state, an event log and the scene map. Edits go into folder mods or into a per-game studio layer over read-only mods, which can be exported as a mod; backgrounds dropped into a mod or the layer during the game are picked up with the \"Resources\" button of the transport bar. Author mode shows the script, novice mode the story moment.",
"category": "tools, editor", "category": "tools, editor",
"sources": [ "sources": [
"src/**/*.cs" "src/**/*.cs"
+4
View File
@@ -126,6 +126,10 @@ layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
mouse_filter = 2 mouse_filter = 2
[node name="ReloadButton" type="Button" parent="Frame/Dock/Column/Transport/Row"]
layout_mode = 2
focus_mode = 0
[node name="RestartButton" type="MenuButton" parent="Frame/Dock/Column/Transport/Row"] [node name="RestartButton" type="MenuButton" parent="Frame/Dock/Column/Transport/Row"]
layout_mode = 2 layout_mode = 2
focus_mode = 0 focus_mode = 0
+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 /// leads there. Exits can be taken directly (a debugging teleport); new
/// scenes are created in the studio layer, the current scenario can be /// 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. /// 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> /// </summary>
public static class StudioMapPage 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; var visited = false;
foreach (var node in session.Nodes) 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_link", link));
actions.AddChild(StudioActionButton.Create("studio/map_export", export)); actions.AddChild(StudioActionButton.Create("studio/map_export", export));
content.AddChild(actions); 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) 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> /// <summary>
/// The studio's edit layer: a folder per game (<c>user://studio/&lt;game&gt;/</c>, /// 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>, /// 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 /// <see cref="ModOverlays"/>. Scenarios written here override same-named
/// scenarios of any mod, so a player can change content of read-only zip /// 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/> /// <para/>
/// Edits of folder mods go into the mod itself (the author's workflow) /// 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 /// 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); 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() public void Mount()
{ {
Directory.CreateDirectory(Path.Combine(RootPath, "scenarios")); Directory.CreateDirectory(Path.Combine(RootPath, "scenarios"));
Directory.CreateDirectory(Path.Combine(RootPath, "scenes")); Directory.CreateDirectory(Path.Combine(RootPath, "scenes"));
Directory.CreateDirectory(Path.Combine(RootPath, "backgrounds"));
ModOverlays.Mount(Id, new FolderModFileProvider(RootPath, Id)); ModOverlays.Mount(Id, new FolderModFileProvider(RootPath, Id));
RefreshContent(); RefreshContent();
GameLogger.Log("UI", $"Scenario studio: layer '{Id}' mounted at {RootPath}"); 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 /// <see cref="StudioLayer"/>, the per-scenario <see cref="StudioWorkingCopy"/>s
/// and the rollback <see cref="StudioSnapshot"/> of the running scenario, and /// and the rollback <see cref="StudioSnapshot"/> of the running scenario, and
/// performs every action of the dock: apply edits, the restart modes, run a /// performs every action of the dock: apply edits, the restart modes, run a
/// scene candidate, go to a scene, create scenes and scenarios, change /// scene candidate, go to a scene, create scenes and scenarios, re-read
/// state values, export the layer. /// backgrounds dropped in while the game runs, change state values, export
/// the layer.
/// <para/> /// <para/>
/// <see cref="Version"/> grows on every change the dock may want to show; /// <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 /// the dock polls it instead of subscribing, so a burst of dialog actions
@@ -452,18 +453,35 @@ namespace Cthangover.Mods.ScenarioStudio
// ── new content ──────────────────────────────────────────── // ── 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> /// <summary>The scene or mod id typed by the author, trimmed and lowercased; null when it is not one (latin letters, digits, _).</summary>
public bool CreateScene(string name) public static string ValidName(string name)
{ {
name = (name ?? string.Empty).Trim().ToLowerInvariant(); 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; return false;
var current = SceneName;
var defs = AppServices.Current.SceneDefs.CollectAll();
List<string> backgrounds = null; List<string> backgrounds = null;
if (current != null && defs != null && defs.TryGetValue(current, out var list) && list.Count > 0) if (background != null)
backgrounds = list[0].DefaultBackground; {
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()); var scenario = Layer.CreateScene(name, backgrounds, StudioScriptEdits.NewLine());
StudioLayer.RefreshContent(); StudioLayer.RefreshContent();
@@ -484,11 +502,35 @@ namespace Cthangover.Mods.ScenarioStudio
return name; 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> /// <summary>Exports the layer as mod <paramref name="modId"/>; null on a bad id.</summary>
public string Export(string modId) public string Export(string modId)
{ {
modId = (modId ?? string.Empty).Trim().ToLowerInvariant(); modId = ValidName(modId);
if (!ModIdPattern.IsMatch(modId)) if (modId == null)
return null; return null;
var path = Layer.Export(modId); var path = Layer.Export(modId);
Journal.Add(StudioLogCategory.Edit, new StudioText("studio/log_text_exported", 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 TargetEnd = 0;
private const long TargetLabelBase = 1000; private const long TargetLabelBase = 1000;
private const long TargetSceneBase = 2000; private const long TargetSceneBase = 2000;
private const long BackgroundCurrent = 0;
private const long BackgroundBase = 1;
private const int CheckpointSave = 100; private const int CheckpointSave = 100;
private const int CheckpointRestore = 101; private const int CheckpointRestore = 101;
@@ -50,6 +52,8 @@ namespace Cthangover.Mods.ScenarioStudio
private StudioCodeLine _promptLine; private StudioCodeLine _promptLine;
private List<string> _pickLabels = new(); private List<string> _pickLabels = new();
private List<string> _pickScenes = new(); private List<string> _pickScenes = new();
private List<string> _pickBackgrounds = new();
private string _newSceneName;
private PanelContainer _runState; private PanelContainer _runState;
private Label _runStateText; private Label _runStateText;
@@ -125,6 +129,10 @@ namespace Cthangover.Mods.ScenarioStudio
_continueButton.Pressed += OnContinuePressed; _continueButton.Pressed += OnContinuePressed;
_restartButton = GetNode<MenuButton>(Root + "Transport/Row/RestartButton"); _restartButton = GetNode<MenuButton>(Root + "Transport/Row/RestartButton");
BuildRestartMenu(_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"); _crumbs = GetNode<HFlowContainer>(Root + "Context/Column/Crumbs");
_scope = GetNode<HFlowContainer>(Root + "Context/Column/Scope"); _scope = GetNode<HFlowContainer>(Root + "Context/Column/Scope");
@@ -325,7 +333,7 @@ namespace Cthangover.Mods.ScenarioStudio
StudioLogPage.Build(_content, _session); StudioLogPage.Build(_content, _session);
break; break;
case StudioTab.Map: case StudioTab.Map:
StudioMapPage.Build(_content, _session, OnGoToScene, OnNewScene, OnLinkScene, OnExport); StudioMapPage.Build(_content, _session, Live.Layer.RootPath, OnGoToScene, OnNewScene, OnLinkScene, OnExport);
break; break;
} }
} }
@@ -667,7 +675,39 @@ namespace Cthangover.Mods.ScenarioStudio
private void OnSceneNameEntered(string name) private void OnSceneNameEntered(string name)
{ {
_prompt = null; _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(); Refresh();
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "Scenario Studio Live", "name": "Scenario Studio Live",
"description": "GUI test of the scenario studio on the real kitchen scenario: opens the dock like the toolbar button, checks the current line, pause and continue, the novice moment, adds and redirects an answer, inserts a line, applies the edits into a test layer and checks the rollback restart plays them without touching the mod, holds at a breakpoint, walks the other tabs and cleans the layer. --shots=<dir> saves screenshots. Activated by --itest=studio_live.", "description": "GUI test of the scenario studio on the real kitchen scenario: opens the dock like the toolbar button, checks the current line, pause and continue, the novice moment, adds and redirects an answer, inserts a line, applies the edits into a test layer and checks the rollback restart plays them without touching the mod, holds at a breakpoint, walks the other tabs, drops a background into the layer, reloads it and builds a scene on it, and cleans the layer. --shots=<dir> saves screenshots. Activated by --itest=studio_live.",
"sources": [ "sources": [
"src/*.cs" "src/*.cs"
], ],
+66 -14
View File
@@ -2,6 +2,7 @@ namespace Cthangover.Mods.ScenarioStudio.Tests.Live
{ {
using System.IO; using System.IO;
using Cthangover.Core.Bootstrap; using Cthangover.Core.Bootstrap;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Mods; using Cthangover.Core.Mods;
using Cthangover.Core.Scenes; using Cthangover.Core.Scenes;
using Cthangover.Core.UI; using Cthangover.Core.UI;
@@ -30,9 +31,12 @@ namespace Cthangover.Mods.ScenarioStudio.Tests.Live
/// ▶ marker back onto the inserted line, continues from there and is held /// ▶ marker back onto the inserted line, continues from there and is held
/// by the breakpoint again, continue;</item> /// by the breakpoint again, continue;</item>
/// <item>the Scene, State (a checkpoint is saved there), Log and Map tabs;</item> /// <item>the Scene, State (a checkpoint is saved there), Log and Map tabs;</item>
/// <item>creates a scene in the layer, goes there and sees its scenario run /// <item>drops a picture into the layer's <c>backgrounds/</c> and reloads
/// from the layer, exports the layer as a mod, returns to the checkpoint /// the resources: it becomes a layer background;</item>
/// and closes the dock.</item> /// <item>creates a scene in the layer on that background, goes there and sees
/// its scenario run from the layer and the picture on screen; replaces the
/// picture, reloads, and the new file is shown; exports the layer as a mod
/// (background included), returns to the checkpoint and closes the dock.</item>
/// </list> /// </list>
/// The test layer is deleted at the end. Ends with /// The test layer is deleted at the end. Ends with
/// <c>[TEST_RESULT] name=studio_live passed=N failed=M</c>. /// <c>[TEST_RESULT] name=studio_live passed=N failed=M</c>.
@@ -48,6 +52,9 @@ namespace Cthangover.Mods.ScenarioStudio.Tests.Live
private const float TimeoutSeconds = 150f; private const float TimeoutSeconds = 150f;
private const string NewScene = "studio_test_yard"; private const string NewScene = "studio_test_yard";
private const string ExportId = "studio_test_export"; private const string ExportId = "studio_test_export";
private const string LayerBackground = "studio_test_backdrop";
private static readonly Vector2I FirstSize = new(64, 32);
private static readonly Vector2I SecondSize = new(48, 48);
private readonly string _shotsDir; private readonly string _shotsDir;
private StudioPanel _panel; private StudioPanel _panel;
@@ -239,37 +246,57 @@ namespace Cthangover.Mods.ScenarioStudio.Tests.Live
Check(HasNode(_panel.Session, StudioNodeState.Here, "home_kitchen"), "map tab: you are in the kitchen"); Check(HasNode(_panel.Session, StudioNodeState.Here, "home_kitchen"), "map tab: you are in the kitchen");
Check(HasNode(_panel.Session, StudioNodeState.Next, "home_vestibule"), "map tab: the vestibule is an exit"); Check(HasNode(_panel.Session, StudioNodeState.Next, "home_vestibule"), "map tab: the vestibule is an exit");
Shot("map"); Shot("map");
PressText("studio/map_new_scene"); Check(!StudioBackgrounds.Ids().Contains(LayerBackground), "a picture not dropped yet is no background");
WriteLayerBackground(FirstSize);
Press(Root + "Transport/Row/ReloadButton");
break; break;
case 22: case 22:
AnswerPrompt(NewScene); Check(StudioBackgrounds.SourceOf(LayerBackground) == StudioLayer.Id, "picture dropped into the layer is a layer background after the reload");
var dropped = BackgroundFactory.Instance.Get(LayerBackground);
Check(dropped != null && dropped.GetWidth() == FirstSize.X && dropped.GetHeight() == FirstSize.Y, "the layer background loads from the dropped file");
PressText("studio/map_new_scene");
break; break;
case 23: case 23:
Check(File.Exists(Path.Combine(Live.Layer.RootPath, "scenes", NewScene + ".json")), "new scene written to the layer"); AnswerPrompt(NewScene);
break;
case 24:
PickBackground(LayerBackground);
break;
case 25:
var sceneFile = Path.Combine(Live.Layer.RootPath, "scenes", NewScene + ".json");
Check(File.Exists(sceneFile), "new scene written to the layer");
Check(File.Exists(sceneFile) && File.ReadAllText(sceneFile).Contains($"\"{LayerBackground}\""), "the new scene uses the picked background");
Check(GetNodeOrNull<SceneManager>("/root/SceneManager")?.HasScene(NewScene) == true, "new scene is known to the scene manager"); Check(GetNodeOrNull<SceneManager>("/root/SceneManager")?.HasScene(NewScene) == true, "new scene is known to the scene manager");
PressPayload(NewScene); PressPayload(NewScene);
break; break;
case 24: case 26:
if (SceneManager.GetCurrentSceneName() != NewScene || Box?.Locker is not ScenarioEvent) if (SceneManager.GetCurrentSceneName() != NewScene || Box?.Locker is not ScenarioEvent || ShownBackgroundSize() != FirstSize)
{ {
_step--; _step--;
break; break;
} }
Check(((ScenarioEvent)Box.Locker).SourceModId == StudioLayer.Id, $"the new scene's scenario runs from the layer ('{Box.Locker.ID}')"); Check(((ScenarioEvent)Box.Locker).SourceModId == StudioLayer.Id, $"the new scene's scenario runs from the layer ('{Box.Locker.ID}')");
Check(true, "the new scene shows the layer background");
Shot("new_scene"); Shot("new_scene");
WriteLayerBackground(SecondSize);
Press(Root + "Transport/Row/ReloadButton");
break;
case 27:
Check(ShownBackgroundSize() == SecondSize, $"a replaced background is shown from the new file after the reload ({ShownBackgroundSize()})");
PressText("studio/map_export"); PressText("studio/map_export");
break; break;
case 25: case 28:
AnswerPrompt(ExportId); AnswerPrompt(ExportId);
break; break;
case 26: case 29:
var exported = Path.Combine(ProjectSettings.GlobalizePath("user://mods/"), ExportId); var exported = Path.Combine(ProjectSettings.GlobalizePath("user://mods/"), ExportId);
Check(File.Exists(Path.Combine(exported, "manifest.json")) && File.Exists(Path.Combine(exported, "scenes", NewScene + ".json")), "layer exported as a mod with a manifest"); Check(File.Exists(Path.Combine(exported, "manifest.json")) && File.Exists(Path.Combine(exported, "scenes", NewScene + ".json")), "layer exported as a mod with a manifest");
Check(File.Exists(Path.Combine(exported, "backgrounds", LayerBackground + ".png")), "the layer background is exported with the mod");
if (Directory.Exists(exported)) if (Directory.Exists(exported))
Directory.Delete(exported, true); Directory.Delete(exported, true);
MenuPick(101); MenuPick(101);
break; break;
case 27: case 30:
if (SceneManager.GetCurrentSceneName() != "home_kitchen") if (SceneManager.GetCurrentSceneName() != "home_kitchen")
{ {
_step--; _step--;
@@ -372,6 +399,17 @@ namespace Cthangover.Mods.ScenarioStudio.Tests.Live
} }
private void PickScene(string scene) private void PickScene(string scene)
{
PickItem(string.Format(TranslationServer.Translate("studio/target_scene"), scene), $"scene {scene} as a target");
}
private void PickBackground(string background)
{
PickItem(string.Format(TranslationServer.Translate("studio/background_item"), background, StudioLayer.Id), $"layer background {background}");
}
/// <summary>Picks the item <paramref name="caption"/> in the open studio pick list.</summary>
private void PickItem(string caption, string what)
{ {
PopupMenu menu = null; PopupMenu menu = null;
foreach (var child in _panel.GetChildren()) foreach (var child in _panel.GetChildren())
@@ -379,11 +417,10 @@ namespace Cthangover.Mods.ScenarioStudio.Tests.Live
if (child is PopupMenu popup && popup.Visible) if (child is PopupMenu popup && popup.Visible)
menu = popup; menu = popup;
} }
Check(menu != null, "target menu opened"); Check(menu != null, $"pick list opened for {what}");
if (menu == null) if (menu == null)
return; return;
var caption = string.Format(TranslationServer.Translate("studio/target_scene"), scene);
for (var i = 0; i < menu.ItemCount; i++) for (var i = 0; i < menu.ItemCount; i++)
{ {
if (menu.GetItemText(i) != caption) if (menu.GetItemText(i) != caption)
@@ -392,7 +429,22 @@ namespace Cthangover.Mods.ScenarioStudio.Tests.Live
menu.Hide(); menu.Hide();
return; return;
} }
Fail($"scene {scene} not offered as a target"); Fail($"not offered: {what}");
}
/// <summary>Writes a plain <paramref name="size"/> PNG as background <see cref="LayerBackground"/> of the test layer.</summary>
private static void WriteLayerBackground(Vector2I size)
{
var path = Path.Combine(Live.Layer.RootPath, "backgrounds", LayerBackground + ".png");
var image = Image.CreateEmpty(size.X, size.Y, false, Image.Format.Rgb8);
image.Fill(new Color(0.2f, 0.4f, 0.3f));
image.SavePng(path);
}
private static Vector2I ShownBackgroundSize()
{
var texture = (SceneContextNode.FindNode<Node>("ViewBox") as IBackgroundView)?.Background?.Texture;
return texture == null ? Vector2I.Zero : new Vector2I(texture.GetWidth(), texture.GetHeight());
} }
private void PressPayload(string payload) private void PressPayload(string payload)