diff --git a/locale/ui_en.properties b/locale/ui_en.properties
index 9ffc073..9d7e8f9 100644
--- a/locale/ui_en.properties
+++ b/locale/ui_en.properties
@@ -248,3 +248,60 @@ tools/hub/hint = Pick an editor — windows open over this screen. A scenario ca
tools/hub/editors = Editors
tools/hub/empty = No editors are installed.
tools/hub/main_menu = Main menu
+tools/scene_editor/title = Scene editor
+tools/scene_editor/hint = A scene is a place: backgrounds, ambient sound and scenarios. Ctrl+S saves · double-click a background in the list to add it
+tools/scene_editor/save_tip = Save the scene file into the chosen mod (Ctrl+S); the game sees the change at once
+tools/scene_editor/new_scene = + New scene
+tools/scene_editor/new_scene_tip = Create a new place: name, mod, backgrounds. Scenarios can be added later; a scene may also have none
+tools/scene_editor/delete = Delete
+tools/scene_editor/delete_tip = Delete the chosen mod's scene file
+tools/scene_editor/delete_confirm = Delete this scene's file from the chosen mod? The scene's scenarios stay.
+tools/scene_editor/go = ▶ Go to scene
+tools/scene_editor/go_tip = Switch the game to this scene
+tools/scene_editor/rescan_tip = Re-read scenes, backgrounds and sounds (after adding files to mods)
+tools/scene_editor/scenes = Scenes
+tools/scene_editor/backgrounds = Scene backgrounds (one is picked at random on entry)
+tools/scene_editor/all_backgrounds = All backgrounds
+tools/scene_editor/add_background = + Add background to scene
+tools/scene_editor/add_background_tip = Add the shown background to the scene (or double-click it in the list)
+tools/scene_editor/remove_background = Remove the background from the scene
+tools/scene_editor/properties = Properties
+tools/scene_editor/name = Name
+tools/scene_editor/name_tip = Scene id: latin letters, digits and _; switch_scene and scenario headers refer to it. Set only when creating
+tools/scene_editor/mod = Save to
+tools/scene_editor/mod_tip = Mod the scene file is written to (folder mods only)
+tools/scene_editor/ambient = Sound
+tools/scene_editor/ambient_tip = Ambient sound playing in the scene
+tools/scene_editor/listen = ▶
+tools/scene_editor/listen_tip = Listen to the ambient sound
+tools/scene_editor/stop = ■
+tools/scene_editor/default_scenario = Default scenario
+tools/scene_editor/default_scenario_tip = Plays when no other scenario of the scene is left
+tools/scene_editor/scenarios = Scenarios of the scene
+tools/scene_editor/open_scenario = Open
+tools/scene_editor/open_scenario_tip = Open the selected scenario in the scenario editor (or double-click)
+tools/scene_editor/new_scenario = + New scenario
+tools/scene_editor/new_scenario_tip = Create a scenario of this scene in the chosen mod and open it
+tools/scene_editor/none = — none —
+tools/scene_editor/effective = In game: {0} (from mod {1})
+tools/scene_editor/adds_to = The scene is defined in: {0}. Saving creates a file in the chosen mod that adds to it.
+tools/scene_editor/also_in = Also added to by: {0}
+tools/scene_editor/no_writable_mod = No folder mod to save the scene to.
+tools/scene_editor/bad_name = Set the scene name: latin letters, digits and _ (starting with a letter).
+tools/scene_editor/name_taken = Scene "{0}" already exists; pick it in the list.
+tools/scene_editor/need_background = Add at least one background to the scene.
+tools/scene_editor/created = Scene "{0}" created.
+tools/scene_editor/deleted = Deleted: {0}
+tools/scene_editor/opened = Scene: {0}
+tools/scene_editor/new_started = New scene: set the name and the mod, add backgrounds.
+tools/scene_editor/already_there = Background {0} is already in the scene.
+tools/scene_editor/go_failed = The game does not know scene "{0}"; save it first.
+tools/scene_editor/went = Switched to scene {0}.
+tools/scene_editor/rescanned = Scenes, backgrounds and sounds re-read.
+tools/scene_editor/scenario_created = Scenario {0} created.
+tools/scene_editor/no_scenarios = The scene has no scenarios yet
+tools/scene_editor/no_backgrounds = The scene has no backgrounds: pick one in the list below and add it.
+tools/scene_editor/pick_scene = Pick a scene on the left or create a new one.
+tools/scene_editor/preview_hint = Pick a background to see it.
+tools/scene_editor/not_in_scene = (not in the scene)
+tools/scene_editor/new_line_text = A new scene. Replace this line with your text.
diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties
index 2f255fc..d27f4ae 100644
--- a/locale/ui_ru.properties
+++ b/locale/ui_ru.properties
@@ -248,3 +248,60 @@ tools/hub/hint = Выберите редактор — окна открываю
tools/hub/editors = Редакторы
tools/hub/empty = Не установлено ни одного редактора.
tools/hub/main_menu = Главное меню
+tools/scene_editor/title = Редактор сцен
+tools/scene_editor/hint = Сцена — это место: фоны, звук окружения и сценарии. Ctrl+S — сохранить · двойной клик по фону в списке — добавить в сцену
+tools/scene_editor/save_tip = Сохранить файл сцены в выбранный мод (Ctrl+S); игра увидит изменения сразу
+tools/scene_editor/new_scene = + Новая сцена
+tools/scene_editor/new_scene_tip = Создать новое место: имя, мод, фоны. Сценарии можно добавить потом — сцена может обходиться и без них
+tools/scene_editor/delete = Удалить
+tools/scene_editor/delete_tip = Удалить файл сцены выбранного мода
+tools/scene_editor/delete_confirm = Удалить файл этой сцены из выбранного мода? Сценарии сцены останутся на месте.
+tools/scene_editor/go = ▶ Перейти в сцену
+tools/scene_editor/go_tip = Переключить игру на эту сцену
+tools/scene_editor/rescan_tip = Перечитать сцены, фоны и звуки (после добавления файлов в моды)
+tools/scene_editor/scenes = Сцены
+tools/scene_editor/backgrounds = Фоны сцены (при входе выбирается один случайно)
+tools/scene_editor/all_backgrounds = Все фоны
+tools/scene_editor/add_background = + Добавить фон в сцену
+tools/scene_editor/add_background_tip = Добавить показанный фон в сцену (или двойной клик по нему в списке)
+tools/scene_editor/remove_background = Убрать фон из сцены
+tools/scene_editor/properties = Свойства
+tools/scene_editor/name = Имя
+tools/scene_editor/name_tip = Идентификатор сцены: латиница, цифры и _; на него ссылаются switch_scene и заголовки сценариев. Задаётся только при создании
+tools/scene_editor/mod = Сохранять в
+tools/scene_editor/mod_tip = Мод, в который пишется файл сцены (только моды-папки)
+tools/scene_editor/ambient = Звук
+tools/scene_editor/ambient_tip = Звук окружения, который играет в сцене
+tools/scene_editor/listen = ▶
+tools/scene_editor/listen_tip = Прослушать звук окружения
+tools/scene_editor/stop = ■
+tools/scene_editor/default_scenario = Сценарий по умолч.
+tools/scene_editor/default_scenario_tip = Играет, когда в сцене не осталось других сценариев
+tools/scene_editor/scenarios = Сценарии сцены
+tools/scene_editor/open_scenario = Открыть
+tools/scene_editor/open_scenario_tip = Открыть выбранный сценарий в редакторе сценариев (или двойной клик)
+tools/scene_editor/new_scenario = + Новый сценарий
+tools/scene_editor/new_scenario_tip = Создать сценарий этой сцены в выбранном моде и открыть его
+tools/scene_editor/none = — нет —
+tools/scene_editor/effective = В игре: {0} (из мода {1})
+tools/scene_editor/adds_to = Сцена определена в: {0}. Сохранение создаст в выбранном моде файл, который дополнит её.
+tools/scene_editor/also_in = Сцену дополняют также: {0}
+tools/scene_editor/no_writable_mod = Нет мода-папки, куда можно сохранить сцену.
+tools/scene_editor/bad_name = Задайте имя сцены: латиница, цифры и _ (начинается с буквы).
+tools/scene_editor/name_taken = Сцена «{0}» уже есть — выберите её в списке.
+tools/scene_editor/need_background = Добавьте в сцену хотя бы один фон.
+tools/scene_editor/created = Сцена «{0}» создана.
+tools/scene_editor/deleted = Удалено: {0}
+tools/scene_editor/opened = Сцена: {0}
+tools/scene_editor/new_started = Новая сцена: задайте имя, мод и добавьте фоны.
+tools/scene_editor/already_there = Фон {0} уже есть в сцене.
+tools/scene_editor/go_failed = Игра не знает сцену «{0}» — сохраните её.
+tools/scene_editor/went = Переход в сцену {0}.
+tools/scene_editor/rescanned = Сцены, фоны и звуки перечитаны.
+tools/scene_editor/scenario_created = Создан сценарий {0}.
+tools/scene_editor/no_scenarios = У сцены пока нет сценариев
+tools/scene_editor/no_backgrounds = В сцене нет фонов — выберите фон в списке ниже и добавьте его.
+tools/scene_editor/pick_scene = Выберите сцену слева или создайте новую.
+tools/scene_editor/preview_hint = Выберите фон, чтобы посмотреть его.
+tools/scene_editor/not_in_scene = (не в сцене)
+tools/scene_editor/new_line_text = Новая сцена. Замените эту строку своим текстом.
diff --git a/manifest.json b/manifest.json
index 087ce15..955917f 100644
--- a/manifest.json
+++ b/manifest.json
@@ -3,7 +3,7 @@
"name": "Editor Tools",
"version": "1.0.0",
"author": "ct",
- "description": "Developer toolbox of ToolBox windows: scenario graph editor, map editor with zones, scene builder with inspector, interactive and light editors, and the mod catalog export window.",
+ "description": "Developer toolbox of ToolBox windows: scenario graph editor, scene editor (create and change the places of a novel: backgrounds, ambient sound, default scenario, the scene's scenarios), map editor with zones, scene builder with inspector, interactive and light editors, and the mod catalog export window.",
"category": "tools, editor",
"sources": [
"src/**/*.cs"
@@ -11,6 +11,7 @@
"depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1"],
"test_suites": [
{"id": "windows", "activate": ["--itest=tools_windows"]},
- {"id": "ingame_play", "activate": ["--itest=ingame_play"]}
+ {"id": "ingame_play", "activate": ["--itest=ingame_play"]},
+ {"id": "novel_flow", "activate": ["--itest=novel_flow"]}
]
}
diff --git a/mod.jpg b/mod.jpg
new file mode 100644
index 0000000..09b914d
Binary files /dev/null and b/mod.jpg differ
diff --git a/src/ScenarioEditor/ScenarioEditorWindow.cs b/src/ScenarioEditor/ScenarioEditorWindow.cs
index 1d7c87a..a335df3 100644
--- a/src/ScenarioEditor/ScenarioEditorWindow.cs
+++ b/src/ScenarioEditor/ScenarioEditorWindow.cs
@@ -36,6 +36,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private Label _filePathLabel;
private bool _playActive;
+ private string _pendingKey;
private string _returnRoute;
private string _returnScene;
private CanvasLayer _stopOverlay;
@@ -47,10 +48,31 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
SetHint(ToolUi.Tr("tools/scenario_editor/hint"));
}
- /// Opens with the graph view so the editor starts on the cross-scene map.
+ /// Opens with the graph view so the editor starts on the cross-scene map, or on the file asked for by .
public override void _Ready()
{
_editTabs.SetViewToGraph();
+ if (_pendingKey != null)
+ OpenScenario(_pendingKey);
+ }
+
+ ///
+ /// Opens a scenario file (key mod:path) in a text tab and selects it in the
+ /// tree — for other tools, e.g. the scene editor's "open" button. Before the window
+ /// is in the tree the key waits for , which would otherwise
+ /// switch the view back to the graph.
+ ///
+ public void OpenScenario(string key)
+ {
+ if (!IsInsideTree())
+ {
+ _pendingKey = key;
+ return;
+ }
+
+ _pendingKey = null;
+ _fileTreePanel.SelectFile(key);
+ _editTabs.OpenFile(key);
}
/// Returns from play-test mode as soon as the in-game test play ends.
@@ -222,6 +244,9 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
_editTabs.SaveCurrentFile();
+ // The running game caches scenario headers and texts: drop them, so a new or
+ // edited scenario plays on the next entry into its scene without a mod reload.
+ SceneFileService.RefreshGame();
SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), ScenarioFileService.GetDisplayPath(modId, relativePath)), ToolStatusKind.Ok);
_infoPanel.Update(text);
_thumbPanel.Load(text);
diff --git a/src/SceneEditor/SceneBackgroundTile.cs b/src/SceneEditor/SceneBackgroundTile.cs
new file mode 100644
index 0000000..3697254
--- /dev/null
+++ b/src/SceneEditor/SceneBackgroundTile.cs
@@ -0,0 +1,69 @@
+using System;
+using Cthangover.Core.UI.Theming;
+using Cthangover.Mods.Interface.Theming;
+using Cthangover.Tools.Common;
+using Cthangover.Tools.Services;
+using Godot;
+
+namespace Cthangover.Tools.SceneEditor
+{
+ ///
+ /// One background of the edited scene in : a thumbnail
+ /// (click shows it in the preview), the background id and the mod whose scene file
+ /// adds it, and a remove button only for backgrounds of the file being edited —
+ /// another mod's background can only be hidden by editing that mod.
+ ///
+ public partial class SceneBackgroundTile : PanelContainer
+ {
+ private readonly string _background;
+ private readonly Action _picked;
+ private readonly Action _removed;
+
+ /// Builds the tile; shows the ✕ button, the accent frame.
+ public SceneBackgroundTile(string background, string modId, bool removable, bool selected,
+ Action picked, Action removed)
+ {
+ _background = background;
+ _picked = picked;
+ _removed = removed;
+ AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusSm, UiTokens.Surface1,
+ selected ? UiTokens.Accent : UiTokens.Line));
+
+ var column = new VBoxContainer();
+ column.AddThemeConstantOverride("separation", UiTokens.Space1);
+ AddChild(column);
+
+ var thumb = new TextureButton
+ {
+ TextureNormal = ModResourceService.LoadBackgroundTexture(background),
+ IgnoreTextureSize = true,
+ StretchMode = TextureButton.StretchModeEnum.KeepAspectCentered,
+ CustomMinimumSize = SceneEditorWindow.ThumbSize,
+ TooltipText = background
+ };
+ thumb.Pressed += OnPicked;
+ column.AddChild(thumb);
+
+ var row = new HBoxContainer();
+ row.AddThemeConstantOverride("separation", UiTokens.Space1);
+ column.AddChild(row);
+ var caption = ToolUi.Hint(background + " · " + modId);
+ caption.TooltipText = background + " (" + modId + ")";
+ caption.MouseFilter = MouseFilterEnum.Pass;
+ caption.SizeFlagsHorizontal = SizeFlags.ExpandFill;
+ caption.CustomMinimumSize = new Vector2(SceneEditorWindow.ThumbSize.X - 32, 0);
+ caption.TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis;
+ row.AddChild(caption);
+ if (removable)
+ {
+ var remove = UiKit.IconButton("✕", ToolUi.Tr("tools/scene_editor/remove_background"));
+ remove.Pressed += OnRemoved;
+ row.AddChild(remove);
+ }
+ }
+
+ private void OnPicked() => _picked?.Invoke(_background);
+
+ private void OnRemoved() => _removed?.Invoke(_background);
+ }
+}
diff --git a/src/SceneEditor/SceneEditorToolBoxButton.cs b/src/SceneEditor/SceneEditorToolBoxButton.cs
new file mode 100644
index 0000000..74030f2
--- /dev/null
+++ b/src/SceneEditor/SceneEditorToolBoxButton.cs
@@ -0,0 +1,17 @@
+using Cthangover.Core.UI.Tool;
+
+namespace Cthangover.Tools.SceneEditor
+{
+ /// Toolbox button that opens the scene editor. Always visible.
+ public class SceneEditorToolBoxButton : IToolBoxButton
+ {
+ /// Matches .
+ public string ToolId => "scene_editor";
+ /// No custom icon — uses text label.
+ public string IconPath => "";
+ /// Translation key for the button label.
+ public string LocaleKey => "tools/scene_editor/title";
+ /// Always visible in the toolbox.
+ public bool IsVisible() => true;
+ }
+}
diff --git a/src/SceneEditor/SceneEditorToolProvider.cs b/src/SceneEditor/SceneEditorToolProvider.cs
new file mode 100644
index 0000000..c95d8dc
--- /dev/null
+++ b/src/SceneEditor/SceneEditorToolProvider.cs
@@ -0,0 +1,19 @@
+using Cthangover.Core.UI.Tool;
+using Cthangover.Mods.Interface.Tool;
+using Godot;
+
+namespace Cthangover.Tools.SceneEditor
+{
+ /// Registers the scene editor as a tool window (hub, main menu tools, in-game toolbox).
+ public class SceneEditorToolProvider : IToolProvider
+ {
+ /// Unique tool identifier.
+ public string Id => "scene_editor";
+ /// Translation key for the tool's display name.
+ public string LocaleKey => "tools/scene_editor/title";
+ /// Creates the editor window instance.
+ public Window CreateWindow() => new SceneEditorWindow();
+ /// Adds the launcher button to the main menu tool block.
+ public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
+ }
+}
diff --git a/src/SceneEditor/SceneEditorWindow.cs b/src/SceneEditor/SceneEditorWindow.cs
new file mode 100644
index 0000000..989fdc0
--- /dev/null
+++ b/src/SceneEditor/SceneEditorWindow.cs
@@ -0,0 +1,743 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Cthangover.Core.Audio;
+using Cthangover.Core.Mods;
+using Cthangover.Core.Scenes;
+using Cthangover.Core.UI.Theming;
+using Cthangover.Core.UI.Tool.ScenarioEditor;
+using Cthangover.Mods.Dialog;
+using Cthangover.Mods.Interface.Theming;
+using Cthangover.Mods.Interface.Tool;
+using Cthangover.Tools.Common;
+using Cthangover.Tools.Services;
+using Godot;
+
+namespace Cthangover.Tools.SceneEditor
+{
+ ///
+ /// Editor of logical scenes — the places of a novel: which backgrounds a scene shows
+ /// (one of them at random on every entry), its ambient sound, the scenario played
+ /// when nothing else starts, and the scenarios that happen there. Left: every scene
+ /// of the loaded mods; centre: the background preview, the scene's backgrounds and a
+ /// searchable list of all backgrounds to add; right: properties and the scene's
+ /// scenarios (open one in the scenario editor, or start a new one).
+ ///
+ /// A scene is the merge of the scenes/<name>.json files of all mods
+ /// (backgrounds add up, a later ambient or default scenario wins). Scenarios are optional:
+ /// a place may hold only interactives, so a new scene starts without any. The editor writes
+ /// one of them — the file of the mod chosen in "Save to": the scene's own file when
+ /// that mod defines it, otherwise a new file that adds to a foreign scene without
+ /// touching the foreign mod. Backgrounds of the other files are shown locked, with
+ /// their mod. After saving the game re-reads scenes (),
+ /// so a new scene can be entered at once with "Go to scene".
+ ///
+ public partial class SceneEditorWindow : ToolWindow
+ {
+ private const int ThumbWidth = 160;
+ private const int ThumbHeight = 90;
+
+ private readonly List _files = new();
+ private readonly List _writableMods = new();
+ private readonly List _sounds = new();
+ private readonly List _scenarioKeys = new();
+ private readonly List _defaultChoices = new();
+
+ private string _sceneName;
+ private bool _isNew;
+ private SceneFileEntry _target;
+ private string _preview;
+ private bool _suppress;
+ private bool _ambientPlaying;
+ private string _pendingMod;
+ private string _pendingScene;
+
+ private FilteredList _sceneList;
+ private TextureRect _previewRect;
+ private Label _previewCaption;
+ private HFlowContainer _backgroundRow;
+ private FilteredList _picker;
+ private Button _addBackgroundBtn;
+ private LineEdit _nameEdit;
+ private OptionButton _modSelect;
+ private Label _modHint;
+ private OptionButton _ambientSelect;
+ private Button _ambientPlayBtn;
+ private Label _ambientHint;
+ private OptionButton _defaultSelect;
+ private Label _defaultHint;
+ private ItemList _scenarioList;
+ private Button _openScenarioBtn;
+ private Button _newScenarioBtn;
+ private Button _deleteBtn;
+ private Button _goBtn;
+
+ public SceneEditorWindow() : base("tools/scene_editor/title")
+ {
+ CthangoverTheme.Apply(this);
+ BuildUI();
+ LoadSources();
+ SetHint(ToolUi.Tr("tools/scene_editor/hint"));
+ RefreshAll();
+ }
+
+ // ── UI construction ────────────────────────────────────────
+
+ private void BuildUI()
+ {
+ var outer = CreateFillContainer();
+ AddChild(outer);
+
+ var toolbar = CreateToolbar();
+ outer.AddChild(toolbar);
+ toolbar.AddChild(ToolUi.Button("tools/common/save", Save, ButtonKind.Primary, "tools/scene_editor/save_tip"));
+ toolbar.AddChild(ToolUi.Button("tools/scene_editor/new_scene", NewScene, ButtonKind.Secondary, "tools/scene_editor/new_scene_tip"));
+ _deleteBtn = ToolUi.Button("tools/scene_editor/delete", AskDelete, ButtonKind.Danger, "tools/scene_editor/delete_tip");
+ toolbar.AddChild(_deleteBtn);
+ toolbar.AddChild(ToolUi.Divider());
+ _goBtn = ToolUi.Button("tools/scene_editor/go", GoToScene, ButtonKind.Secondary, "tools/scene_editor/go_tip");
+ toolbar.AddChild(_goBtn);
+ toolbar.AddChild(UiKit.Spacer());
+ toolbar.AddChild(ToolUi.Button("tools/scenario_editor/rescan", Rescan, ButtonKind.Secondary, "tools/scene_editor/rescan_tip"));
+
+ var main = new HSplitContainer
+ {
+ SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
+ SizeFlagsVertical = Control.SizeFlags.ExpandFill
+ };
+ outer.AddChild(main);
+
+ var left = new VBoxContainer();
+ main.AddChild(CreateSidebar(left, 260));
+ left.AddChild(ToolUi.Section("tools/scene_editor/scenes"));
+ _sceneList = new FilteredList(200);
+ _sceneList.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
+ _sceneList.Chosen += OnScenePicked;
+ left.AddChild(_sceneList);
+
+ var rest = new HSplitContainer
+ {
+ SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
+ SizeFlagsVertical = Control.SizeFlags.ExpandFill
+ };
+ main.AddChild(rest);
+ rest.AddChild(BuildCentre());
+
+ var right = new VBoxContainer();
+ right.AddThemeConstantOverride("separation", UiTokens.Space2);
+ rest.AddChild(CreateSidebar(right, 340));
+ BuildProperties(right);
+ }
+
+ private Control BuildCentre()
+ {
+ var column = new VBoxContainer
+ {
+ SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
+ SizeFlagsVertical = Control.SizeFlags.ExpandFill
+ };
+ column.AddThemeConstantOverride("separation", UiTokens.Space2);
+
+ var frame = new PanelContainer
+ {
+ SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
+ SizeFlagsVertical = Control.SizeFlags.ExpandFill,
+ CustomMinimumSize = new Vector2(0, 260)
+ };
+ frame.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line));
+ column.AddChild(frame);
+ _previewRect = new TextureRect
+ {
+ ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
+ StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered,
+ MouseFilter = Control.MouseFilterEnum.Ignore
+ };
+ frame.AddChild(_previewRect);
+ _previewCaption = ToolUi.Hint(string.Empty);
+ column.AddChild(_previewCaption);
+
+ column.AddChild(ToolUi.Section("tools/scene_editor/backgrounds"));
+ _backgroundRow = new HFlowContainer();
+ _backgroundRow.AddThemeConstantOverride("h_separation", UiTokens.Space2);
+ _backgroundRow.AddThemeConstantOverride("v_separation", UiTokens.Space2);
+ column.AddChild(_backgroundRow);
+
+ column.AddChild(ToolUi.Section("tools/scene_editor/all_backgrounds"));
+ _picker = new FilteredList(120);
+ _picker.Chosen += ShowPreview;
+ _picker.Activated += AddBackground;
+ column.AddChild(_picker);
+ _addBackgroundBtn = ToolUi.Button("tools/scene_editor/add_background", AddPreviewed, ButtonKind.Secondary, "tools/scene_editor/add_background_tip");
+ column.AddChild(_addBackgroundBtn);
+ return column;
+ }
+
+ private void BuildProperties(VBoxContainer column)
+ {
+ column.AddChild(ToolUi.Section("tools/scene_editor/properties"));
+ _nameEdit = ToolUi.Field(column, "tools/scene_editor/name", ToolUi.Text("my_scene", OnNameChanged), "tools/scene_editor/name_tip");
+ _modSelect = ToolUi.Field(column, "tools/scene_editor/mod", new OptionButton(), "tools/scene_editor/mod_tip");
+ _modSelect.ItemSelected += OnModPicked;
+ _modHint = ToolUi.Hint(string.Empty);
+ column.AddChild(_modHint);
+
+ var ambientRow = new HBoxContainer();
+ ambientRow.AddThemeConstantOverride("separation", UiTokens.Space1);
+ _ambientSelect = new OptionButton { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, FitToLongestItem = false };
+ _ambientSelect.ItemSelected += OnAmbientPicked;
+ ambientRow.AddChild(_ambientSelect);
+ _ambientPlayBtn = ToolUi.Button("tools/scene_editor/listen", ToggleAmbientPreview, ButtonKind.Secondary, "tools/scene_editor/listen_tip");
+ ambientRow.AddChild(_ambientPlayBtn);
+ ToolUi.Field(column, "tools/scene_editor/ambient", ambientRow, "tools/scene_editor/ambient_tip");
+ _ambientHint = ToolUi.Hint(string.Empty);
+ column.AddChild(_ambientHint);
+
+ _defaultSelect = ToolUi.Field(column, "tools/scene_editor/default_scenario",
+ new OptionButton { FitToLongestItem = false }, "tools/scene_editor/default_scenario_tip");
+ _defaultSelect.ItemSelected += OnDefaultPicked;
+ _defaultHint = ToolUi.Hint(string.Empty);
+ column.AddChild(_defaultHint);
+
+ column.AddChild(ToolUi.Section("tools/scene_editor/scenarios"));
+ _scenarioList = new ItemList { CustomMinimumSize = new Vector2(0, 140), SizeFlagsVertical = Control.SizeFlags.ExpandFill };
+ _scenarioList.ItemActivated += OnScenarioActivated;
+ column.AddChild(_scenarioList);
+
+ var row = new HBoxContainer();
+ row.AddThemeConstantOverride("separation", UiTokens.Space1);
+ column.AddChild(row);
+ _openScenarioBtn = ToolUi.Button("tools/scene_editor/open_scenario", OpenSelectedScenario, ButtonKind.Secondary, "tools/scene_editor/open_scenario_tip");
+ _openScenarioBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
+ row.AddChild(_openScenarioBtn);
+ _newScenarioBtn = ToolUi.Button("tools/scene_editor/new_scenario", NewScenario, ButtonKind.Secondary, "tools/scene_editor/new_scenario_tip");
+ _newScenarioBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
+ row.AddChild(_newScenarioBtn);
+ }
+
+ // ── loading ────────────────────────────────────────────────
+
+ private void LoadSources()
+ {
+ _files.Clear();
+ _files.AddRange(SceneFileService.Scan());
+ _writableMods.Clear();
+ _writableMods.AddRange(ModFileService.ListWritableMods());
+ _sounds.Clear();
+ _sounds.AddRange(ModResourceService.GetSoundIds());
+
+ _picker.SetItems(ModResourceService.GetBackgroundIds());
+ _modSelect.Clear();
+ foreach (var mod in _writableMods)
+ _modSelect.AddItem(mod);
+ _ambientSelect.Clear();
+ _ambientSelect.AddItem(ToolUi.Tr("tools/scene_editor/none"));
+ foreach (var sound in _sounds)
+ _ambientSelect.AddItem(sound);
+ RebuildSceneList();
+ }
+
+ private void RebuildSceneList()
+ {
+ var items = new List<(string Id, string Text, Texture2D Icon)>();
+ foreach (var group in _files.GroupBy(f => f.Name).OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
+ items.Add((group.Key, group.Key + " · " + string.Join(", ", group.Select(f => f.ModId).Distinct()), null));
+ _sceneList.SetItems(items);
+ _sceneList.Select(_isNew ? null : _sceneName);
+ }
+
+ private void OnScenePicked(string name)
+ {
+ if (name == _sceneName && !_isNew)
+ return;
+ _pendingScene = name;
+ RunDiscardingChanges(OpenPendingScene, KeepSceneSelection);
+ }
+
+ private void OpenPendingScene() => OpenScene(_pendingScene);
+
+ private void KeepSceneSelection() => _sceneList.Select(_isNew ? null : _sceneName);
+
+ private void OpenScene(string name)
+ {
+ _sceneName = name;
+ _isNew = false;
+ var target = FilesOf(name).LastOrDefault(f => _writableMods.Contains(f.ModId))?.ModId
+ ?? _writableMods.FirstOrDefault();
+ LoadTarget(target);
+ _preview = Merged().FirstOrDefault().Background;
+ MarkClean();
+ SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/opened"), name));
+ RefreshAll();
+ }
+
+ /// Takes the chosen mod's file of the current scene as the edited one (a copy), or a new empty file.
+ private void LoadTarget(string modId)
+ {
+ if (modId == null)
+ {
+ _target = null;
+ return;
+ }
+
+ var own = FilesOf(_sceneName).FirstOrDefault(f => f.ModId == modId);
+ _target = new SceneFileEntry
+ {
+ ModId = modId,
+ RelativePath = own?.RelativePath ?? SceneFileEntry.PathFor(_sceneName ?? string.Empty),
+ Name = _sceneName,
+ Backgrounds = new List(own?.Backgrounds ?? new List()),
+ Ambient = own?.Ambient ?? string.Empty,
+ DefaultScenario = own?.DefaultScenario ?? string.Empty,
+ };
+ }
+
+ private IEnumerable FilesOf(string name) => _files.Where(f => f.Name == name);
+
+ ///
+ /// The scene as the game will merge it after saving: the other mods' files in load
+ /// order with the edited file at its mod's place. Yields each background with the
+ /// file it comes from.
+ ///
+ private IEnumerable<(string Background, SceneFileEntry File)> Merged()
+ {
+ foreach (var file in MergedFiles())
+ {
+ foreach (var background in file.Backgrounds)
+ yield return (background, file);
+ }
+ }
+
+ private List MergedFiles()
+ {
+ var order = ModRegistry.Instance.GetOrderedModIds();
+ var result = FilesOf(_sceneName).Where(f => _target == null || f.ModId != _target.ModId).ToList();
+ if (_target != null)
+ result.Add(_target);
+ return result.OrderBy(f => Rank(order, f.ModId)).ToList();
+ }
+
+ private static int Rank(IReadOnlyList order, string modId)
+ {
+ for (var i = 0; i < order.Count; i++)
+ {
+ if (order[i] == modId)
+ return i;
+ }
+ return order.Count;
+ }
+
+ // ── editing ────────────────────────────────────────────────
+
+ private void NewScene()
+ {
+ RunDiscardingChanges(StartNewScene);
+ }
+
+ private void StartNewScene()
+ {
+ _sceneName = string.Empty;
+ _isNew = true;
+ LoadTarget(_writableMods.FirstOrDefault());
+ _preview = null;
+ _sceneList.Select(null);
+ SetDirty();
+ SetStatus(ToolUi.Tr("tools/scene_editor/new_started"));
+ RefreshAll();
+ _nameEdit.GrabFocus();
+ }
+
+ private void OnNameChanged(string text)
+ {
+ if (_suppress || !_isNew || _target == null)
+ return;
+ _sceneName = SceneFileService.ValidName(text) ?? string.Empty;
+ _target.Name = _sceneName;
+ _target.RelativePath = SceneFileEntry.PathFor(_sceneName);
+ SetDirty();
+ }
+
+ private void OnModPicked(long index)
+ {
+ if (_suppress)
+ return;
+ var mod = index >= 0 && index < _writableMods.Count ? _writableMods[(int)index] : null;
+ if (_isNew)
+ {
+ if (_target != null)
+ _target.ModId = mod;
+ RefreshAll();
+ return;
+ }
+ _pendingMod = mod;
+ RunDiscardingChanges(SwitchTargetMod, RefreshAll);
+ }
+
+ private void SwitchTargetMod()
+ {
+ LoadTarget(_pendingMod);
+ MarkClean();
+ RefreshAll();
+ }
+
+ private void OnAmbientPicked(long index)
+ {
+ if (_suppress || _target == null)
+ return;
+ _target.Ambient = index > 0 ? _sounds[(int)index - 1] : string.Empty;
+ StopAmbientPreview();
+ SetDirty();
+ RefreshAll();
+ }
+
+ private void OnDefaultPicked(long index)
+ {
+ if (_suppress || _target == null)
+ return;
+ _target.DefaultScenario = index > 0 ? _defaultChoices[(int)index - 1] : string.Empty;
+ SetDirty();
+ RefreshAll();
+ }
+
+ private void ShowPreview(string background)
+ {
+ _preview = background;
+ RefreshPreview();
+ }
+
+ private void AddPreviewed() => AddBackground(_preview);
+
+ private void AddBackground(string background)
+ {
+ if (_target == null || string.IsNullOrEmpty(background))
+ return;
+ if (Merged().Any(m => m.Background == background))
+ {
+ SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/already_there"), background));
+ return;
+ }
+ _target.Backgrounds.Add(background);
+ _preview = background;
+ SetDirty();
+ RefreshAll();
+ }
+
+ private void RemoveBackground(string background)
+ {
+ if (_target == null || !_target.Backgrounds.Remove(background))
+ return;
+ if (_preview == background)
+ _preview = Merged().FirstOrDefault().Background;
+ SetDirty();
+ RefreshAll();
+ }
+
+ // ── saving, deleting, going ────────────────────────────────
+
+ /// Ctrl+S.
+ protected override void OnSaveShortcut() => Save();
+
+ private void Save()
+ {
+ if (_target == null || !ModFileService.IsWritable(_target.ModId))
+ {
+ SetStatus(ToolUi.Tr("tools/scene_editor/no_writable_mod"), ToolStatusKind.Error);
+ return;
+ }
+ if (string.IsNullOrEmpty(_sceneName))
+ {
+ SetStatus(ToolUi.Tr("tools/scene_editor/bad_name"), ToolStatusKind.Error);
+ return;
+ }
+ if (_isNew && _files.Any(f => f.Name == _sceneName))
+ {
+ SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/name_taken"), _sceneName), ToolStatusKind.Error);
+ return;
+ }
+ if (!Merged().Any())
+ {
+ SetStatus(ToolUi.Tr("tools/scene_editor/need_background"), ToolStatusKind.Error);
+ return;
+ }
+
+ if (!SceneFileService.Write(_target))
+ {
+ SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
+ return;
+ }
+
+ var created = _isNew;
+ SceneFileService.RefreshGame();
+ var name = _sceneName;
+ var mod = _target.ModId;
+ LoadSources();
+ _sceneName = name;
+ _isNew = false;
+ LoadTarget(mod);
+ MarkClean();
+ _sceneList.Select(name);
+ RefreshAll();
+ SetStatus(string.Format(ToolUi.Tr(created ? "tools/scene_editor/created" : "tools/common/saved"),
+ created ? name : mod + "/" + _target.RelativePath), ToolStatusKind.Ok);
+ }
+
+ private void AskDelete()
+ {
+ if (_target == null || _isNew || !FilesOf(_sceneName).Any(f => f.ModId == _target.ModId))
+ return;
+ ShowConfirm("tools/scene_editor/delete", "tools/scene_editor/delete_confirm", Delete);
+ }
+
+ private void Delete()
+ {
+ if (!SceneFileService.Delete(_target))
+ {
+ SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
+ return;
+ }
+
+ var path = _target.ModId + "/" + _target.RelativePath;
+ SceneFileService.RefreshGame();
+ LoadSources();
+ if (FilesOf(_sceneName).Any())
+ {
+ OpenScene(_sceneName);
+ }
+ else
+ {
+ _sceneName = null;
+ _target = null;
+ MarkClean();
+ RefreshAll();
+ }
+ SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/deleted"), path), ToolStatusKind.Ok);
+ }
+
+ private void GoToScene()
+ {
+ if (_isNew || string.IsNullOrEmpty(_sceneName))
+ return;
+ var manager = GetTree().Root.GetNodeOrNull("SceneManager");
+ if (manager == null || !manager.HasScene(_sceneName))
+ {
+ SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/go_failed"), _sceneName), ToolStatusKind.Error);
+ return;
+ }
+ StopAmbientPreview();
+ // Like the studio's teleport: a dialog left running would keep the box, and the
+ // entered scene's own scenario would wait behind it (or the old one would go on).
+ SceneContextNode.FindNode("DialogBox")?.Abort();
+ manager.SwitchScene(_sceneName);
+ SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/went"), _sceneName), ToolStatusKind.Ok);
+ }
+
+ private void Rescan()
+ {
+ RunDiscardingChanges(RescanNow);
+ }
+
+ private void RescanNow()
+ {
+ var name = _isNew ? null : _sceneName;
+ LoadSources();
+ if (name != null && FilesOf(name).Any())
+ OpenScene(name);
+ else
+ RefreshAll();
+ SetStatus(ToolUi.Tr("tools/scene_editor/rescanned"));
+ }
+
+ // ── scenarios ──────────────────────────────────────────────
+
+ private void NewScenario()
+ {
+ if (_target == null || _isNew || string.IsNullOrEmpty(_sceneName))
+ return;
+ var key = ScenarioFileService.CreateForScene(_target.ModId, _sceneName, NewLine());
+ if (key == null)
+ {
+ SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
+ return;
+ }
+ SceneFileService.RefreshGame();
+ RefreshAll();
+ OpenScenarioEditor(key);
+ SetStatus(string.Format(ToolUi.Tr("tools/scene_editor/scenario_created"), key), ToolStatusKind.Ok);
+ }
+
+ private void OnScenarioActivated(long index) => OpenSelectedScenario();
+
+ private void OpenSelectedScenario()
+ {
+ var selected = _scenarioList.GetSelectedItems();
+ if (selected.Length == 0 || selected[0] >= _scenarioKeys.Count)
+ return;
+ OpenScenarioEditor(_scenarioKeys[selected[0]]);
+ }
+
+ private static void OpenScenarioEditor(string key)
+ {
+ var window = new ScenarioEditorWindow();
+ window.OpenScenario(key);
+ ShowWindow(window);
+ }
+
+ private static string NewLine() => "text \"" + ToolUi.Tr("tools/scene_editor/new_line_text").Replace("\"", "\\\"") + "\"";
+
+ // ── ambient preview ────────────────────────────────────────
+
+ private void ToggleAmbientPreview()
+ {
+ if (_ambientPlaying)
+ {
+ StopAmbientPreview();
+ RefreshAll();
+ return;
+ }
+
+ var ambient = EffectiveAmbient().Ambient;
+ var audio = GetTree().Root.GetNodeOrNull("AudioService") as IAudioService;
+ if (audio == null || string.IsNullOrEmpty(ambient))
+ return;
+ audio.PlayAmbient(ambient);
+ _ambientPlaying = true;
+ RefreshAll();
+ }
+
+ private void StopAmbientPreview()
+ {
+ if (!_ambientPlaying)
+ return;
+ _ambientPlaying = false;
+ (GetTree()?.Root.GetNodeOrNull("AudioService") as IAudioService)?.StopAmbient();
+ }
+
+ /// Stops a playing ambient preview when the window closes.
+ protected override void Cleanup() => StopAmbientPreview();
+
+ // ── refresh ────────────────────────────────────────────────
+
+ /// The ambient the game will play: the last non-empty one in load order, and the mod it comes from.
+ private (string Ambient, string ModId) EffectiveAmbient()
+ {
+ var file = MergedFiles().LastOrDefault(f => !string.IsNullOrEmpty(f.Ambient));
+ return (file?.Ambient ?? string.Empty, file?.ModId);
+ }
+
+ private (string Scenario, string ModId) EffectiveDefault()
+ {
+ var file = MergedFiles().LastOrDefault(f => !string.IsNullOrEmpty(f.DefaultScenario));
+ return (file?.DefaultScenario ?? string.Empty, file?.ModId);
+ }
+
+ private void RefreshAll()
+ {
+ _suppress = true;
+ var hasScene = _target != null && (_isNew || !string.IsNullOrEmpty(_sceneName));
+
+ _nameEdit.Text = _sceneName ?? string.Empty;
+ _nameEdit.Editable = _isNew;
+ _modSelect.Disabled = !hasScene;
+ _modSelect.Select(_target == null ? -1 : _writableMods.IndexOf(_target.ModId));
+ _modHint.Text = ModHint();
+
+ _ambientSelect.Disabled = !hasScene;
+ _ambientSelect.Select(_target == null ? 0 : _sounds.IndexOf(_target.Ambient) + 1);
+ var ambient = EffectiveAmbient();
+ _ambientHint.Text = ambient.ModId != null && ambient.ModId != _target?.ModId
+ ? string.Format(ToolUi.Tr("tools/scene_editor/effective"), ambient.Ambient, ambient.ModId)
+ : string.Empty;
+ _ambientPlayBtn.Text = ToolUi.Tr(_ambientPlaying ? "tools/scene_editor/stop" : "tools/scene_editor/listen");
+ _ambientPlayBtn.Disabled = string.IsNullOrEmpty(ambient.Ambient) && !_ambientPlaying;
+
+ RefreshScenarios(hasScene);
+ RefreshBackgrounds();
+ RefreshPreview();
+
+ _deleteBtn.Disabled = _target == null || _isNew || !FilesOf(_sceneName).Any(f => f.ModId == _target.ModId);
+ _goBtn.Disabled = _isNew || string.IsNullOrEmpty(_sceneName);
+ _suppress = false;
+ }
+
+ private string ModHint()
+ {
+ if (_writableMods.Count == 0)
+ return ToolUi.Tr("tools/scene_editor/no_writable_mod");
+ if (_target == null || _isNew)
+ return string.Empty;
+ var others = FilesOf(_sceneName).Select(f => f.ModId).Where(m => m != _target.ModId).Distinct().ToList();
+ var own = FilesOf(_sceneName).Any(f => f.ModId == _target.ModId);
+ if (!own)
+ return string.Format(ToolUi.Tr("tools/scene_editor/adds_to"), string.Join(", ", others));
+ return others.Count > 0 ? string.Format(ToolUi.Tr("tools/scene_editor/also_in"), string.Join(", ", others)) : string.Empty;
+ }
+
+ private void RefreshScenarios(bool hasScene)
+ {
+ _scenarioKeys.Clear();
+ _defaultChoices.Clear();
+ _scenarioList.Clear();
+ if (hasScene && !_isNew)
+ {
+ foreach (var file in ScenarioFileService.ScanScenarioFiles().SelectMany(m => m.Groups).SelectMany(g => g.Files))
+ {
+ var text = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath);
+ if (text == null || ScenarioFileService.ParseMetadata(text).Scene != _sceneName)
+ continue;
+ _scenarioKeys.Add(file.Key);
+ _scenarioList.AddItem(file.ModId + ": " + file.RelativePath);
+ if (!_defaultChoices.Contains(file.RelativePath))
+ _defaultChoices.Add(file.RelativePath);
+ }
+ }
+ if (_target != null && !string.IsNullOrEmpty(_target.DefaultScenario) && !_defaultChoices.Contains(_target.DefaultScenario))
+ _defaultChoices.Add(_target.DefaultScenario);
+
+ _defaultSelect.Clear();
+ _defaultSelect.AddItem(ToolUi.Tr("tools/scene_editor/none"));
+ foreach (var path in _defaultChoices)
+ _defaultSelect.AddItem(path);
+ _defaultSelect.Select(_target == null ? 0 : _defaultChoices.IndexOf(_target.DefaultScenario) + 1);
+ _defaultSelect.Disabled = !hasScene || _isNew;
+ var effective = EffectiveDefault();
+ _defaultHint.Text = effective.ModId != null && effective.ModId != _target?.ModId
+ ? string.Format(ToolUi.Tr("tools/scene_editor/effective"), effective.Scenario, effective.ModId)
+ : string.Empty;
+
+ if (hasScene && !_isNew && _scenarioKeys.Count == 0)
+ _scenarioList.AddItem(ToolUi.Tr("tools/scene_editor/no_scenarios"), null, false);
+ _openScenarioBtn.Disabled = _scenarioKeys.Count == 0;
+ _newScenarioBtn.Disabled = !hasScene || _isNew || !ModFileService.IsWritable(_target?.ModId);
+ }
+
+ private void RefreshBackgrounds()
+ {
+ foreach (var child in _backgroundRow.GetChildren())
+ {
+ _backgroundRow.RemoveChild(child);
+ child.QueueFree();
+ }
+
+ var any = false;
+ foreach (var (background, file) in Merged())
+ {
+ any = true;
+ _backgroundRow.AddChild(new SceneBackgroundTile(background, file.ModId, file == _target, background == _preview,
+ ShowPreview, RemoveBackground));
+ }
+ if (!any)
+ _backgroundRow.AddChild(ToolUi.Hint(ToolUi.Tr(_target == null ? "tools/scene_editor/pick_scene" : "tools/scene_editor/no_backgrounds")));
+ }
+
+ private void RefreshPreview()
+ {
+ _previewRect.Texture = string.IsNullOrEmpty(_preview) ? null : ModResourceService.LoadBackgroundTexture(_preview);
+ _previewCaption.Text = string.IsNullOrEmpty(_preview)
+ ? ToolUi.Tr("tools/scene_editor/preview_hint")
+ : _preview + (Merged().Any(m => m.Background == _preview) ? string.Empty : " " + ToolUi.Tr("tools/scene_editor/not_in_scene"));
+ if (_addBackgroundBtn != null)
+ _addBackgroundBtn.Disabled = _target == null || string.IsNullOrEmpty(_preview) || Merged().Any(m => m.Background == _preview);
+ }
+
+ internal static Vector2 ThumbSize => new(ThumbWidth, ThumbHeight);
+ }
+}
diff --git a/src/Services/ModResourceService.cs b/src/Services/ModResourceService.cs
index a5570ec..0e4a7d3 100644
--- a/src/Services/ModResourceService.cs
+++ b/src/Services/ModResourceService.cs
@@ -46,6 +46,25 @@ namespace Cthangover.Tools.Services
return ids;
}
+ /// Ids of every sound (group sounds, path without extension): the choices for a scene's ambient.
+ public static List GetSoundIds()
+ {
+ var ids = new List();
+ var exts = new HashSet(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 GetExt()
{
var set = new HashSet();
diff --git a/src/Services/ScenarioFileService.cs b/src/Services/ScenarioFileService.cs
index 16fa032..a7e23ce 100644
--- a/src/Services/ScenarioFileService.cs
+++ b/src/Services/ScenarioFileService.cs
@@ -277,6 +277,21 @@ namespace Cthangover.Tools.Services
return true;
}
+ ///
+ /// Creates scenarios/<scene>[_n].scenario in a folder mod: a header bound to
+ /// and one line of text to replace. Returns the file key, or
+ /// null when the mod is not writable.
+ ///
+ 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;
+ }
+
/// Absolute filesystem path of a scenario file, or null for zip-backed mods.
public static string GetAbsolutePath(string modId, string relativePath)
{
diff --git a/src/Services/SceneFileEntry.cs b/src/Services/SceneFileEntry.cs
new file mode 100644
index 0000000..21b4f3d
--- /dev/null
+++ b/src/Services/SceneFileEntry.cs
@@ -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
+{
+ ///
+ /// One mod's file of a logical scene (scenes/<name>.json). The game merges
+ /// the files of all mods into one (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.
+ ///
+ public sealed class SceneFileEntry
+ {
+ /// Mod that owns the file.
+ public string ModId { get; set; }
+
+ /// Mod-relative path of the file.
+ public string RelativePath { get; set; }
+
+ /// Scene name (the logical scene id).
+ public string Name { get; set; }
+
+ /// Backgrounds this file adds; the game shows one of the merged list at random.
+ public List Backgrounds { get; set; } = new();
+
+ /// Ambient sound id (group sounds), empty for none.
+ public string Ambient { get; set; } = string.Empty;
+
+ /// Mod-relative path of the scenario played when nothing else starts, empty for none.
+ public string DefaultScenario { get; set; } = string.Empty;
+
+ /// Default path of a scene file: scenes/<name>.json.
+ public static string PathFor(string name) => "scenes/" + name + ".json";
+
+ /// Reads a scene file with the game's own JSON options; null when it is not a scene.
+ public static SceneFileEntry Parse(string modId, string relativePath, string json)
+ {
+ SceneDefinition definition;
+ try
+ {
+ definition = JsonSerializer.Deserialize(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(),
+ Ambient = definition.DefaultAmbient ?? string.Empty,
+ DefaultScenario = definition.DefaultScenario ?? string.Empty,
+ };
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+}
diff --git a/src/Services/SceneFileService.cs b/src/Services/SceneFileService.cs
new file mode 100644
index 0000000..906a2b7
--- /dev/null
+++ b/src/Services/SceneFileService.cs
@@ -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
+{
+ ///
+ /// Scene files (scenes/**.json) 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; drops those caches and lets
+ /// 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.
+ ///
+ public static class SceneFileService
+ {
+ private const string ScenesDir = "scenes";
+ private static readonly Regex NamePattern = new("^[a-z][a-z0-9_]*$");
+
+ /// Every scene file of the loaded mods and of the studio layer, in load order.
+ public static List Scan()
+ {
+ var result = new List();
+ 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;
+ }
+
+ /// The scene name typed by the author, trimmed and lowercased; null when it is not an id (latin letters, digits, _).
+ public static string ValidName(string name)
+ {
+ name = (name ?? string.Empty).Trim().ToLowerInvariant();
+ return NamePattern.IsMatch(name) ? name : null;
+ }
+
+ /// Writes the entry to its mod; false when the mod is not a folder mod.
+ public static bool Write(SceneFileEntry entry)
+ {
+ return ModFileService.WriteText(entry.ModId, entry.RelativePath, entry.ToJson());
+ }
+
+ /// Deletes the entry's file; false when the mod is not a folder mod or the file is gone.
+ 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;
+ }
+
+ /// Makes the running game re-read scene files and scenario headers.
+ public static void RefreshGame()
+ {
+ ScenarioDefs.Invalidate();
+ ModManager.Instance.GetResolver()?.ClearCache();
+ AppServices.Current.SceneDefs.Clear();
+ SceneManager.RescanScenes();
+ }
+
+ private static void Collect(IModFileProvider provider, string modId, string directory, List 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);
+ }
+ }
+ }
+}
diff --git a/test_suites/novel_flow/manifest.json b/test_suites/novel_flow/manifest.json
new file mode 100644
index 0000000..a3cdd0c
--- /dev/null
+++ b/test_suites/novel_flow/manifest.json
@@ -0,0 +1,10 @@
+{
+ "name": "Novel Flow Test",
+ "description": "End-to-end authoring test on the novel template with the editors added, using only the editors' UI: opens the scene editor from the in-game toolbar, creates a new scene with a background, writes its scenario (a way back to the town) in the scenario editor, adds a way to it to the town's scenario, goes to the town from the scene editor and walks town -> new scene -> town through the real dialog choices. Restores the game's files at the end. Activated by --itest=novel_flow; --shots= saves screenshots (windowed runs only).",
+ "sources": [
+ "src/*.cs"
+ ],
+ "depends": [
+ "my_novel_game>=1"
+ ]
+}
diff --git a/test_suites/novel_flow/src/NovelFlowTestDriver.cs b/test_suites/novel_flow/src/NovelFlowTestDriver.cs
new file mode 100644
index 0000000..afae6b3
--- /dev/null
+++ b/test_suites/novel_flow/src/NovelFlowTestDriver.cs
@@ -0,0 +1,359 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Cthangover.Core.Bootstrap;
+using Cthangover.Core.Scenes;
+using Cthangover.Core.UI.Dialog;
+using Cthangover.Core.UI.Tool.ScenarioEditor;
+using Cthangover.Core.Utils;
+using Cthangover.Mods.Dialog;
+using Cthangover.Tools.Common;
+using Cthangover.Tools.SceneEditor;
+using Cthangover.Tools.Services;
+using Godot;
+
+namespace Cthangover.Tools.Tests.NovelFlow
+{
+ ///
+ /// An author's session in the running novel template, through the editors' UI only:
+ ///
+ /// the toolbar's tools menu opens the scene editor;
+ /// New scene, a name, the game's mod, a background, Save;
+ /// New scenario of that scene opens the scenario editor; the scenario (a way back to
+ /// the town) is typed in and saved;
+ /// the town is picked in the scene editor, its scenario opened and a way to the new
+ /// scene typed in and saved;
+ /// Go to scene puts the game into the town, and the player walks town → new scene →
+ /// town by clicking the dialog's answers.
+ ///
+ /// Buttons, list rows, answers and text edits get the signals a mouse or keyboard would
+ /// raise. The game's files are restored at the end (town scenario text, new files deleted).
+ /// Result line: [TEST_RESULT] name=novel_flow passed=N failed=M, 150 s watchdog.
+ ///
+ public partial class NovelFlowTestDriver : Node
+ {
+ private const float TickSeconds = 0.3f;
+ private const float TimeoutSeconds = 150f;
+ private const string Mod = "my_novel_game";
+ private const string Town = "my_novel_town";
+ private const string Park = "my_novel_park";
+ private const string TownScenario = "scenarios/my_novel_town.scenario";
+ private const string ParkScenario = "scenarios/my_novel_park.scenario";
+ private const string ToPark = "Go to the park";
+ private const string ToTown = "Back to the town";
+ // The town scenario before the test edits it; survives a run that is killed midway
+ // (the next run restores it first, so an interrupted run never leaves the park behind).
+ private const string Backup = "user://novel_flow_town.scenario.bak";
+
+ private const string ParkText =
+ "scene: " + Park + "\npriority: 10\n---\n" +
+ "text \"A quiet park behind the town. Wet leaves, an empty bench.\"\n" +
+ "select \"What next?\"\n" +
+ "option \"" + ToTown + "\" -> :town\n\n" +
+ ":town\nswitch_scene " + Town + "\nend\n";
+
+ private readonly string shotsDir;
+ private readonly List failures = new();
+ private int passed;
+ private int step;
+ private int wait;
+ private double elapsed;
+ private string townOriginal;
+ private SceneEditorWindow sceneEditor;
+
+ /// Creates the driver; null disables screenshots.
+ public NovelFlowTestDriver(string shotsDir)
+ {
+ this.shotsDir = shotsDir;
+ Name = "NovelFlowTestDriver";
+ var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
+ AddChild(timer);
+ timer.Timeout += Tick;
+ }
+
+ private SceneManager Scenes => GetTree().Root.GetNodeOrNull("SceneManager");
+
+ private void Tick()
+ {
+ if (step < 0)
+ return;
+ elapsed += TickSeconds;
+ if (elapsed > TimeoutSeconds)
+ {
+ Fail($"timeout at step {step}");
+ Finish();
+ return;
+ }
+
+ try
+ {
+ RunStep();
+ }
+ catch (System.Exception ex)
+ {
+ Fail($"step {step}: {ex.GetType().Name}: {ex.Message}");
+ Finish();
+ }
+ }
+
+ private void RunStep()
+ {
+ switch (step)
+ {
+ case 0:
+ if (!Boot.IsReady || Scenes?.CurrentSceneName != Town || GodotSceneService.IsTransitioning)
+ return;
+ RestoreFromBackup();
+ townOriginal = ModFileService.ReadText(Mod, TownScenario);
+ File.WriteAllText(ProjectSettings.GlobalizePath(Backup), townOriginal ?? string.Empty);
+ DeleteParkFiles();
+ SceneFileService.RefreshGame();
+ Check(townOriginal != null && !Scenes.HasScene(Park), "the novel runs in the town, no park yet");
+ OpenToolFromToolbar("tools/scene_editor/title");
+ Next();
+ return;
+ case 1:
+ sceneEditor = Windows().FirstOrDefault();
+ if (sceneEditor == null)
+ return;
+ Check(true, "the toolbar's tools menu opens the scene editor");
+ Press(sceneEditor, "tools/scene_editor/new_scene");
+ var name = sceneEditor.FindChildren("*", "", true, false).OfType().First(e => e.PlaceholderText == "my_scene");
+ name.Text = Park;
+ name.EmitSignal(LineEdit.SignalName.TextChanged, Park);
+ var mods = sceneEditor.FindChildren("*", "", true, false).OfType()
+ .First(o => Enumerable.Range(0, o.ItemCount).Any(i => o.GetItemText(i) == Mod));
+ var modIndex = Enumerable.Range(0, mods.ItemCount).First(i => mods.GetItemText(i) == Mod);
+ mods.Select(modIndex);
+ mods.EmitSignal(OptionButton.SignalName.ItemSelected, modIndex);
+ var picker = Lists(sceneEditor)[1];
+ var background = Row(picker, "home/vestibule") >= 0 ? Row(picker, "home/vestibule") : 0;
+ picker.EmitSignal(ItemList.SignalName.ItemActivated, background);
+ Next();
+ return;
+ case 2:
+ Shot("1_new_scene");
+ Press(sceneEditor, "tools/common/save");
+ Check(Scenes.HasScene(Park), "Save in the scene editor: the game knows the new scene at once");
+ Check(File.Exists(ModFileService.GetWritablePath(Mod, "scenes/" + Park + ".json")), "the scene file is in the game's mod");
+ Press(sceneEditor, "tools/scene_editor/new_scenario");
+ Next();
+ return;
+ case 3:
+ var parkEditor = EditorWith("scene: " + Park);
+ if (parkEditor == null)
+ return;
+ Check(true, "New scenario opens the scenario editor on the scene's new scenario");
+ Type(parkEditor, ParkText);
+ Press(parkEditor, "tools/common/save");
+ Check(ModFileService.ReadText(Mod, ParkScenario)?.Contains(ToTown) == true, "the park scenario is saved with the way back");
+ Next();
+ return;
+ case 4:
+ // A tick after typing, so the frame shows the new text.
+ Shot("2_park_scenario");
+ PickScene(Town);
+ Next();
+ return;
+ case 5:
+ var rows = sceneEditor.FindChildren("*", "", true, false).OfType().Last();
+ var townRow = Row(rows, Mod + ": " + TownScenario);
+ Check(townRow >= 0, "the town's scenario is listed under the town in the scene editor");
+ rows.Select(townRow);
+ rows.EmitSignal(ItemList.SignalName.ItemActivated, townRow);
+ Next();
+ return;
+ case 6:
+ var townEditor = EditorWith("scene: " + Town);
+ if (townEditor == null)
+ return;
+ var text = Code(townEditor).Text;
+ text = text.Replace("option \"Go back home\"", "option \"" + ToPark + "\" -> :park\noption \"Go back home\"")
+ + "\n:park\nswitch_scene " + Park + "\nend\n";
+ Type(townEditor, text);
+ Press(townEditor, "tools/common/save");
+ Check(ModFileService.ReadText(Mod, TownScenario)?.Contains("switch_scene " + Park) == true, "the town scenario is saved with the way to the park");
+ Next();
+ return;
+ case 7:
+ Shot("3_town_scenario");
+ Press(sceneEditor, "tools/scene_editor/go");
+ Next();
+ return;
+ case 8:
+ if (++wait < 3 || GodotSceneService.IsTransitioning)
+ return;
+ foreach (var window in GetTree().Root.GetChildren().OfType().ToList())
+ window.EmitSignal(Window.SignalName.CloseRequested);
+ Next();
+ return;
+ case 9:
+ if (!Answer(ToPark))
+ return;
+ Shot("4_town_choice");
+ Click(ToPark);
+ Next();
+ return;
+ case 10:
+ if (Scenes.CurrentSceneName != Park || GodotSceneService.IsTransitioning || !Answer(ToTown))
+ return;
+ Check(true, "choosing the new answer in the town leads into the park");
+ Shot("5_park");
+ Click(ToTown);
+ Next();
+ return;
+ case 11:
+ if (Scenes.CurrentSceneName != Town || GodotSceneService.IsTransitioning || !Answer(ToPark))
+ return;
+ Check(true, "the park's answer leads back to the town, which offers the park again");
+ Shot("6_back_in_town");
+ Finish();
+ return;
+ }
+ }
+
+ // ── the author's hands ─────────────────────────────────────
+
+ /// The toolbar's tools button opens its menu; the tool is picked by its title.
+ private void OpenToolFromToolbar(string titleKey)
+ {
+ var menuTitle = TranslationServer.Translate("tools/menu_title").ToString();
+ var button = GetTree().Root.FindChildren("*", "", true, false).OfType