Scene editor for logical scenes; scenario editor Save refreshes the running game; novel_flow itest; mod cover
This commit is contained in:
1 parent
2b8eaa5452
commit
972df5ccff
17 files changed
+1692
-3
No files matched your search
@@ -248,3 +248,60 @@ tools/hub/hint = Pick an editor — windows open over this screen. A scenario ca
|
|||||||
tools/hub/editors = Editors
|
tools/hub/editors = Editors
|
||||||
tools/hub/empty = No editors are installed.
|
tools/hub/empty = No editors are installed.
|
||||||
tools/hub/main_menu = Main menu
|
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.
|
||||||
@@ -248,3 +248,60 @@ tools/hub/hint = Выберите редактор — окна открываю
|
|||||||
tools/hub/editors = Редакторы
|
tools/hub/editors = Редакторы
|
||||||
tools/hub/empty = Не установлено ни одного редактора.
|
tools/hub/empty = Не установлено ни одного редактора.
|
||||||
tools/hub/main_menu = Главное меню
|
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 = Новая сцена. Замените эту строку своим текстом.
|
||||||
+3
-2
@@ -3,7 +3,7 @@
|
|||||||
"name": "Editor Tools",
|
"name": "Editor Tools",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"author": "ct",
|
"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",
|
"category": "tools, editor",
|
||||||
"sources": [
|
"sources": [
|
||||||
"src/**/*.cs"
|
"src/**/*.cs"
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1"],
|
"depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1"],
|
||||||
"test_suites": [
|
"test_suites": [
|
||||||
{"id": "windows", "activate": ["--itest=tools_windows"]},
|
{"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"]}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
|
|||||||
private Label _filePathLabel;
|
private Label _filePathLabel;
|
||||||
|
|
||||||
private bool _playActive;
|
private bool _playActive;
|
||||||
|
private string _pendingKey;
|
||||||
private string _returnRoute;
|
private string _returnRoute;
|
||||||
private string _returnScene;
|
private string _returnScene;
|
||||||
private CanvasLayer _stopOverlay;
|
private CanvasLayer _stopOverlay;
|
||||||
@@ -47,10 +48,31 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
|
|||||||
SetHint(ToolUi.Tr("tools/scenario_editor/hint"));
|
SetHint(ToolUi.Tr("tools/scenario_editor/hint"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Opens with the graph view so the editor starts on the cross-scene map.</summary>
|
/// <summary>Opens with the graph view so the editor starts on the cross-scene map, or on the file asked for by <see cref="OpenScenario"/>.</summary>
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
_editTabs.SetViewToGraph();
|
_editTabs.SetViewToGraph();
|
||||||
|
if (_pendingKey != null)
|
||||||
|
OpenScenario(_pendingKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a scenario file (key <c>mod:path</c>) 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 <see cref="_Ready"/>, which would otherwise
|
||||||
|
/// switch the view back to the graph.
|
||||||
|
/// </summary>
|
||||||
|
public void OpenScenario(string key)
|
||||||
|
{
|
||||||
|
if (!IsInsideTree())
|
||||||
|
{
|
||||||
|
_pendingKey = key;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingKey = null;
|
||||||
|
_fileTreePanel.SelectFile(key);
|
||||||
|
_editTabs.OpenFile(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Returns from play-test mode as soon as the in-game test play ends.</summary>
|
/// <summary>Returns from play-test mode as soon as the in-game test play ends.</summary>
|
||||||
@@ -222,6 +244,9 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
|
|||||||
}
|
}
|
||||||
|
|
||||||
_editTabs.SaveCurrentFile();
|
_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);
|
SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), ScenarioFileService.GetDisplayPath(modId, relativePath)), ToolStatusKind.Ok);
|
||||||
_infoPanel.Update(text);
|
_infoPanel.Update(text);
|
||||||
_thumbPanel.Load(text);
|
_thumbPanel.Load(text);
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One background of the edited scene in <see cref="SceneEditorWindow"/>: 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.
|
||||||
|
/// </summary>
|
||||||
|
public partial class SceneBackgroundTile : PanelContainer
|
||||||
|
{
|
||||||
|
private readonly string _background;
|
||||||
|
private readonly Action<string> _picked;
|
||||||
|
private readonly Action<string> _removed;
|
||||||
|
|
||||||
|
/// <summary>Builds the tile; <paramref name="removable"/> shows the ✕ button, <paramref name="selected"/> the accent frame.</summary>
|
||||||
|
public SceneBackgroundTile(string background, string modId, bool removable, bool selected,
|
||||||
|
Action<string> picked, Action<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Cthangover.Core.UI.Tool;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.SceneEditor
|
||||||
|
{
|
||||||
|
/// <summary>Toolbox button that opens the scene editor. Always visible.</summary>
|
||||||
|
public class SceneEditorToolBoxButton : IToolBoxButton
|
||||||
|
{
|
||||||
|
/// <summary>Matches <see cref="SceneEditorToolProvider.Id"/>.</summary>
|
||||||
|
public string ToolId => "scene_editor";
|
||||||
|
/// <summary>No custom icon — uses text label.</summary>
|
||||||
|
public string IconPath => "";
|
||||||
|
/// <summary>Translation key for the button label.</summary>
|
||||||
|
public string LocaleKey => "tools/scene_editor/title";
|
||||||
|
/// <summary>Always visible in the toolbox.</summary>
|
||||||
|
public bool IsVisible() => true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Cthangover.Core.UI.Tool;
|
||||||
|
using Cthangover.Mods.Interface.Tool;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.SceneEditor
|
||||||
|
{
|
||||||
|
/// <summary>Registers the scene editor as a tool window (hub, main menu tools, in-game toolbox).</summary>
|
||||||
|
public class SceneEditorToolProvider : IToolProvider
|
||||||
|
{
|
||||||
|
/// <summary>Unique tool identifier.</summary>
|
||||||
|
public string Id => "scene_editor";
|
||||||
|
/// <summary>Translation key for the tool's display name.</summary>
|
||||||
|
public string LocaleKey => "tools/scene_editor/title";
|
||||||
|
/// <summary>Creates the editor window instance.</summary>
|
||||||
|
public Window CreateWindow() => new SceneEditorWindow();
|
||||||
|
/// <summary>Adds the launcher button to the main menu tool block.</summary>
|
||||||
|
public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// <para/>
|
||||||
|
/// A scene is the merge of the <c>scenes/<name>.json</c> 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 (<see cref="SceneFileService.RefreshGame"/>),
|
||||||
|
/// so a new scene can be entered at once with "Go to scene".
|
||||||
|
/// </summary>
|
||||||
|
public partial class SceneEditorWindow : ToolWindow
|
||||||
|
{
|
||||||
|
private const int ThumbWidth = 160;
|
||||||
|
private const int ThumbHeight = 90;
|
||||||
|
|
||||||
|
private readonly List<SceneFileEntry> _files = new();
|
||||||
|
private readonly List<string> _writableMods = new();
|
||||||
|
private readonly List<string> _sounds = new();
|
||||||
|
private readonly List<string> _scenarioKeys = new();
|
||||||
|
private readonly List<string> _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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Takes the chosen mod's file of the current scene as the edited one (a copy), or a new empty file.</summary>
|
||||||
|
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<string>(own?.Backgrounds ?? new List<string>()),
|
||||||
|
Ambient = own?.Ambient ?? string.Empty,
|
||||||
|
DefaultScenario = own?.DefaultScenario ?? string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<SceneFileEntry> FilesOf(string name) => _files.Where(f => f.Name == name);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private IEnumerable<(string Background, SceneFileEntry File)> Merged()
|
||||||
|
{
|
||||||
|
foreach (var file in MergedFiles())
|
||||||
|
{
|
||||||
|
foreach (var background in file.Backgrounds)
|
||||||
|
yield return (background, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SceneFileEntry> 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<string> 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 ────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Ctrl+S.</summary>
|
||||||
|
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>("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>("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<Node>("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<Node>("AudioService") as IAudioService)?.StopAmbient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stops a playing ambient preview when the window closes.</summary>
|
||||||
|
protected override void Cleanup() => StopAmbientPreview();
|
||||||
|
|
||||||
|
// ── refresh ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>The ambient the game will play: the last non-empty one in load order, and the mod it comes from.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,25 @@ namespace Cthangover.Tools.Services
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Ids of every sound (group <c>sounds</c>, path without extension): the choices for a scene's ambient.</summary>
|
||||||
|
public static List<string> GetSoundIds()
|
||||||
|
{
|
||||||
|
var ids = new List<string>();
|
||||||
|
var exts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var ext in ModConfig.Instance.AudioExtensions)
|
||||||
|
exts.Add(ext);
|
||||||
|
|
||||||
|
foreach (var id in Files.Collect("sounds").Keys)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(id);
|
||||||
|
if (exts.Contains(ext))
|
||||||
|
ids.Add(id.Substring(0, id.Length - ext.Length));
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.Sort(StringComparer.OrdinalIgnoreCase);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
private static HashSet<string> GetExt()
|
private static HashSet<string> GetExt()
|
||||||
{
|
{
|
||||||
var set = new HashSet<string>();
|
var set = new HashSet<string>();
|
||||||
|
|||||||
@@ -277,6 +277,21 @@ namespace Cthangover.Tools.Services
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates <c>scenarios/<scene>[_n].scenario</c> in a folder mod: a header bound to
|
||||||
|
/// <paramref name="scene"/> and one line of text to replace. Returns the file key, or
|
||||||
|
/// null when the mod is not writable.
|
||||||
|
/// </summary>
|
||||||
|
public static string CreateForScene(string modId, string scene, string firstLine)
|
||||||
|
{
|
||||||
|
var path = $"{ScenariosDir}/{scene}{ScenarioExtension}";
|
||||||
|
for (var n = 2; ModManager.Instance.ReadFileText(modId, path) != null; n++)
|
||||||
|
path = $"{ScenariosDir}/{scene}_{n}{ScenarioExtension}";
|
||||||
|
|
||||||
|
var text = $"scene: {scene}\npriority: 10\n---\n{firstLine}\nend\n";
|
||||||
|
return ModFileService.WriteText(modId, path, text) ? MakeKey(modId, path) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Absolute filesystem path of a scenario file, or <c>null</c> for zip-backed mods.</summary>
|
/// <summary>Absolute filesystem path of a scenario file, or <c>null</c> for zip-backed mods.</summary>
|
||||||
public static string GetAbsolutePath(string modId, string relativePath)
|
public static string GetAbsolutePath(string modId, string relativePath)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Cthangover.Core.Scenes;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One mod's file of a logical scene (<c>scenes/<name>.json</c>). The game merges
|
||||||
|
/// the files of all mods into one <see cref="SceneDefinition"/> (backgrounds add up,
|
||||||
|
/// a later non-empty ambient or default scenario wins); the scene editor works on
|
||||||
|
/// the files themselves, because only one of them — the chosen mod's — is written.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SceneFileEntry
|
||||||
|
{
|
||||||
|
/// <summary>Mod that owns the file.</summary>
|
||||||
|
public string ModId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mod-relative path of the file.</summary>
|
||||||
|
public string RelativePath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Scene name (the logical scene id).</summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Backgrounds this file adds; the game shows one of the merged list at random.</summary>
|
||||||
|
public List<string> Backgrounds { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Ambient sound id (group <c>sounds</c>), empty for none.</summary>
|
||||||
|
public string Ambient { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Mod-relative path of the scenario played when nothing else starts, empty for none.</summary>
|
||||||
|
public string DefaultScenario { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Default path of a scene file: <c>scenes/<name>.json</c>.</summary>
|
||||||
|
public static string PathFor(string name) => "scenes/" + name + ".json";
|
||||||
|
|
||||||
|
/// <summary>Reads a scene file with the game's own JSON options; null when it is not a scene.</summary>
|
||||||
|
public static SceneFileEntry Parse(string modId, string relativePath, string json)
|
||||||
|
{
|
||||||
|
SceneDefinition definition;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
definition = JsonSerializer.Deserialize<SceneDefinition>(json ?? string.Empty, JsonOptionsProvider.ReadOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(definition?.Name))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new SceneFileEntry
|
||||||
|
{
|
||||||
|
ModId = modId,
|
||||||
|
RelativePath = relativePath,
|
||||||
|
Name = definition.Name,
|
||||||
|
Backgrounds = definition.DefaultBackground ?? new List<string>(),
|
||||||
|
Ambient = definition.DefaultAmbient ?? string.Empty,
|
||||||
|
DefaultScenario = definition.DefaultScenario ?? string.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file text in the style of the hand-written scene files (two-space indent, LF,
|
||||||
|
/// camelCase keys). Empty fields are left out, so a file of a mod that only adds a
|
||||||
|
/// background does not reset another mod's ambient or default scenario.
|
||||||
|
/// </summary>
|
||||||
|
public string ToJson()
|
||||||
|
{
|
||||||
|
var json = new StringBuilder("{\n \"name\": ").Append(Quote(Name));
|
||||||
|
if (Backgrounds.Count > 0)
|
||||||
|
{
|
||||||
|
json.Append(",\n \"defaultBackground\": [");
|
||||||
|
for (var i = 0; i < Backgrounds.Count; i++)
|
||||||
|
json.Append(i == 0 ? string.Empty : ", ").Append(Quote(Backgrounds[i]));
|
||||||
|
json.Append(']');
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(Ambient))
|
||||||
|
json.Append(",\n \"defaultAmbient\": ").Append(Quote(Ambient));
|
||||||
|
if (!string.IsNullOrEmpty(DefaultScenario))
|
||||||
|
json.Append(",\n \"defaultScenario\": ").Append(Quote(DefaultScenario));
|
||||||
|
return json.Append("\n}\n").ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Quote(string value) => JsonSerializer.Serialize(value ?? string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Cthangover.Core.Bootstrap;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Mods.Resolvers;
|
||||||
|
using Cthangover.Core.Scenes;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scene files (<c>scenes/**.json</c>) of the loaded mods for the scene editor: reading
|
||||||
|
/// them per mod in load order (the order the game merges them in), writing one, and
|
||||||
|
/// making the running game see the change at once. The game caches scene definitions
|
||||||
|
/// and scenario headers; <see cref="RefreshGame"/> drops those caches and lets
|
||||||
|
/// <see cref="SceneManager"/> rescan, the same steps the scenario studio takes after
|
||||||
|
/// creating a scene in its layer, so a new scene can be entered without a mod reload.
|
||||||
|
/// </summary>
|
||||||
|
public static class SceneFileService
|
||||||
|
{
|
||||||
|
private const string ScenesDir = "scenes";
|
||||||
|
private static readonly Regex NamePattern = new("^[a-z][a-z0-9_]*$");
|
||||||
|
|
||||||
|
/// <summary>Every scene file of the loaded mods and of the studio layer, in load order.</summary>
|
||||||
|
public static List<SceneFileEntry> Scan()
|
||||||
|
{
|
||||||
|
var result = new List<SceneFileEntry>();
|
||||||
|
var manager = ModManager.Instance;
|
||||||
|
if (manager?.Mods == null)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
foreach (var modId in ModRegistry.Instance.GetOrderedModIds())
|
||||||
|
{
|
||||||
|
if (manager.Mods.TryGetValue(modId, out var mod) && mod?.FileProvider != null)
|
||||||
|
Collect(mod.FileProvider, modId, ScenesDir, result);
|
||||||
|
}
|
||||||
|
foreach (var overlay in ModOverlays.All)
|
||||||
|
Collect(overlay.Value, overlay.Key, ScenesDir, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The scene name typed by the author, trimmed and lowercased; null when it is not an id (latin letters, digits, _).</summary>
|
||||||
|
public static string ValidName(string name)
|
||||||
|
{
|
||||||
|
name = (name ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
return NamePattern.IsMatch(name) ? name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the entry to its mod; false when the mod is not a folder mod.</summary>
|
||||||
|
public static bool Write(SceneFileEntry entry)
|
||||||
|
{
|
||||||
|
return ModFileService.WriteText(entry.ModId, entry.RelativePath, entry.ToJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes the entry's file; false when the mod is not a folder mod or the file is gone.</summary>
|
||||||
|
public static bool Delete(SceneFileEntry entry)
|
||||||
|
{
|
||||||
|
var path = ModFileService.GetWritablePath(entry.ModId, entry.RelativePath);
|
||||||
|
if (path == null || !File.Exists(path))
|
||||||
|
return false;
|
||||||
|
File.Delete(path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes the running game re-read scene files and scenario headers.</summary>
|
||||||
|
public static void RefreshGame()
|
||||||
|
{
|
||||||
|
ScenarioDefs.Invalidate();
|
||||||
|
ModManager.Instance.GetResolver<SceneDefinitionResolver>()?.ClearCache();
|
||||||
|
AppServices.Current.SceneDefs.Clear();
|
||||||
|
SceneManager.RescanScenes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Collect(IModFileProvider provider, string modId, string directory, List<SceneFileEntry> result)
|
||||||
|
{
|
||||||
|
foreach (var entry in provider.ListFiles(directory))
|
||||||
|
{
|
||||||
|
if (entry.EndsWith("/"))
|
||||||
|
{
|
||||||
|
Collect(provider, modId, entry.TrimEnd('/'), result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.EndsWith(".json", System.StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var scene = SceneFileEntry.Parse(modId, entry, provider.ReadFileText(entry));
|
||||||
|
if (scene != null)
|
||||||
|
result.Add(scene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=<dir> saves screenshots (windowed runs only).",
|
||||||
|
"sources": [
|
||||||
|
"src/*.cs"
|
||||||
|
],
|
||||||
|
"depends": [
|
||||||
|
"my_novel_game>=1"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// An author's session in the running novel template, through the editors' UI only:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>the toolbar's tools menu opens the scene editor;</item>
|
||||||
|
/// <item>New scene, a name, the game's mod, a background, Save;</item>
|
||||||
|
/// <item>New scenario of that scene opens the scenario editor; the scenario (a way back to
|
||||||
|
/// the town) is typed in and saved;</item>
|
||||||
|
/// <item>the town is picked in the scene editor, its scenario opened and a way to the new
|
||||||
|
/// scene typed in and saved;</item>
|
||||||
|
/// <item>Go to scene puts the game into the town, and the player walks town → new scene →
|
||||||
|
/// town by clicking the dialog's answers.</item>
|
||||||
|
/// </list>
|
||||||
|
/// 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: <c>[TEST_RESULT] name=novel_flow passed=N failed=M</c>, 150 s watchdog.
|
||||||
|
/// </summary>
|
||||||
|
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<string> failures = new();
|
||||||
|
private int passed;
|
||||||
|
private int step;
|
||||||
|
private int wait;
|
||||||
|
private double elapsed;
|
||||||
|
private string townOriginal;
|
||||||
|
private SceneEditorWindow sceneEditor;
|
||||||
|
|
||||||
|
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
|
||||||
|
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>("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<SceneEditorWindow>().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<LineEdit>().First(e => e.PlaceholderText == "my_scene");
|
||||||
|
name.Text = Park;
|
||||||
|
name.EmitSignal(LineEdit.SignalName.TextChanged, Park);
|
||||||
|
var mods = sceneEditor.FindChildren("*", "", true, false).OfType<OptionButton>()
|
||||||
|
.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<ItemList>().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<Window>().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 ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>The toolbar's tools button opens its menu; the tool is picked by its title.</summary>
|
||||||
|
private void OpenToolFromToolbar(string titleKey)
|
||||||
|
{
|
||||||
|
var menuTitle = TranslationServer.Translate("tools/menu_title").ToString();
|
||||||
|
var button = GetTree().Root.FindChildren("*", "", true, false).OfType<Button>().First(b => b.TooltipText == menuTitle);
|
||||||
|
button.EmitSignal(BaseButton.SignalName.Pressed);
|
||||||
|
var menu = button.GetChildren().OfType<PopupMenu>().First();
|
||||||
|
var title = TranslationServer.Translate(titleKey).ToString();
|
||||||
|
var index = Enumerable.Range(0, menu.ItemCount).First(i => menu.GetItemText(i) == title);
|
||||||
|
menu.EmitSignal(PopupMenu.SignalName.IndexPressed, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PickScene(string name)
|
||||||
|
{
|
||||||
|
var list = Lists(sceneEditor)[0];
|
||||||
|
var row = Enumerable.Range(0, list.ItemCount).First(i => list.GetItemTooltip(i) == name);
|
||||||
|
list.Select(row);
|
||||||
|
list.EmitSignal(ItemList.SignalName.ItemSelected, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Type(ScenarioEditorWindow editor, string text)
|
||||||
|
{
|
||||||
|
var code = Code(editor);
|
||||||
|
code.Text = text;
|
||||||
|
code.EmitSignal(TextEdit.SignalName.TextChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Press(Node root, string key)
|
||||||
|
{
|
||||||
|
var text = TranslationServer.Translate(key).ToString();
|
||||||
|
var button = root.FindChildren("*", "", true, false).OfType<Button>().FirstOrDefault(b => b.Text == text && b.IsVisibleInTree());
|
||||||
|
Check(button != null && !button.Disabled, $"button '{text}' is there and enabled");
|
||||||
|
button?.EmitSignal(BaseButton.SignalName.Pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the dialog shows an answer with this text (advances the text before it).</summary>
|
||||||
|
private bool Answer(string text)
|
||||||
|
{
|
||||||
|
if (AnswerItems().Any(a => a.Model?.Text == text))
|
||||||
|
return true;
|
||||||
|
if (++wait % 10 == 0)
|
||||||
|
GameLogger.Log("TEST", $"waiting for answer '{text}' in '{Scenes?.CurrentSceneName}', shown: [{string.Join(", ", AnswerItems().Select(a => a.Model?.Text))}]");
|
||||||
|
var box = SceneContextNode.FindNode<Node>("DialogBox") as IDialogBox;
|
||||||
|
if (box != null && box.IsRunning && !box.IsWaitingAnswer)
|
||||||
|
box.NextAction();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Click(string text)
|
||||||
|
{
|
||||||
|
var item = AnswerItems().First(a => a.Model?.Text == text);
|
||||||
|
item.GetNode<Button>("Button").EmitSignal(BaseButton.SignalName.Pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<AnswerItem> AnswerItems() =>
|
||||||
|
GetTree().Root.FindChildren("*", "", true, false).OfType<AnswerItem>().Where(a => a.IsVisibleInTree());
|
||||||
|
|
||||||
|
// ── finding things ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private IEnumerable<T> Windows<T>() where T : Window =>
|
||||||
|
GetTree().Root.GetChildren().OfType<T>().Where(w => !w.IsQueuedForDeletion());
|
||||||
|
|
||||||
|
private ScenarioEditorWindow EditorWith(string header) =>
|
||||||
|
Windows<ScenarioEditorWindow>().FirstOrDefault(w => Code(w)?.Text.StartsWith(header) == true);
|
||||||
|
|
||||||
|
private static CodeEdit Code(Node editor) =>
|
||||||
|
editor.FindChildren("*", "", true, false).OfType<CodeEdit>().FirstOrDefault(c => c.IsVisibleInTree());
|
||||||
|
|
||||||
|
private static List<ItemList> Lists(Node root) =>
|
||||||
|
root.FindChildren("*", "", true, false).OfType<FilteredList>()
|
||||||
|
.Select(f => f.FindChildren("*", "", true, false).OfType<ItemList>().First()).ToList();
|
||||||
|
|
||||||
|
private static int Row(ItemList list, string text) =>
|
||||||
|
Enumerable.Range(0, list.ItemCount).FirstOrDefault(i => list.GetItemText(i) == text, -1);
|
||||||
|
|
||||||
|
// ── bookkeeping ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void RestoreFromBackup()
|
||||||
|
{
|
||||||
|
var path = ProjectSettings.GlobalizePath(Backup);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return;
|
||||||
|
ModFileService.WriteText(Mod, TownScenario, File.ReadAllText(path));
|
||||||
|
File.Delete(path);
|
||||||
|
GameLogger.Log("TEST", "novel_flow: restored the town scenario left by an interrupted run");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteParkFiles()
|
||||||
|
{
|
||||||
|
foreach (var file in new[] { "scenes/" + Park + ".json", ParkScenario })
|
||||||
|
{
|
||||||
|
var path = ModFileService.GetWritablePath(Mod, file);
|
||||||
|
if (path != null && File.Exists(path))
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Next()
|
||||||
|
{
|
||||||
|
step++;
|
||||||
|
wait = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Check(bool condition, string what)
|
||||||
|
{
|
||||||
|
if (condition)
|
||||||
|
{
|
||||||
|
passed++;
|
||||||
|
GameLogger.Log("TEST", $"[PASS] {what}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Fail(what);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Fail(string what)
|
||||||
|
{
|
||||||
|
failures.Add(what);
|
||||||
|
GameLogger.Log("TEST", $"[FAIL] {what}", LogLevel.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shot(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(shotsDir) || DisplayServer.GetName() == "headless")
|
||||||
|
return;
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(shotsDir);
|
||||||
|
GetViewport().GetTexture().GetImage()?.SavePng(shotsDir.TrimEnd('/') + "/novel_flow_" + name + ".png");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Finish()
|
||||||
|
{
|
||||||
|
step = -1;
|
||||||
|
if (townOriginal != null)
|
||||||
|
ModFileService.WriteText(Mod, TownScenario, townOriginal);
|
||||||
|
File.Delete(ProjectSettings.GlobalizePath(Backup));
|
||||||
|
DeleteParkFiles();
|
||||||
|
SceneFileService.RefreshGame();
|
||||||
|
GameLogger.Log("TEST", $"[TEST_RESULT] name=novel_flow passed={passed} failed={failures.Count}");
|
||||||
|
GetTree().Quit(failures.Count == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.Tests.NovelFlow
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Entry point of the novel_flow test: adds the driver to the tree root when the exact
|
||||||
|
/// flag <c>--itest=novel_flow</c> is given (the driver quits the process). Optional
|
||||||
|
/// <c>--shots=<dir></c> makes the driver save a screenshot per step.
|
||||||
|
/// </summary>
|
||||||
|
public class NovelFlowTestInitializer : IModInitializer
|
||||||
|
{
|
||||||
|
private static bool driverQueued;
|
||||||
|
|
||||||
|
/// <summary>Nothing per mod.</summary>
|
||||||
|
public void OnModLoaded(string modId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Queues the driver once.</summary>
|
||||||
|
public void OnModResourcesReady()
|
||||||
|
{
|
||||||
|
var args = OS.GetCmdlineUserArgs();
|
||||||
|
if (driverQueued || !args.Any(a => a == "--itest=novel_flow"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
driverQueued = true;
|
||||||
|
var shots = args.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal))
|
||||||
|
.Select(a => a.Substring("--shots=".Length))
|
||||||
|
.FirstOrDefault();
|
||||||
|
GameLogger.Log("TEST", $"NovelFlowTestInitializer: activating novel_flow, shots={shots ?? "off"}");
|
||||||
|
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new NovelFlowTestDriver(shots));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Scenes;
|
||||||
using Cthangover.Core.UI.Tool.LightEditor;
|
using Cthangover.Core.UI.Tool.LightEditor;
|
||||||
using Cthangover.Core.UI.Tool.ScenarioEditor;
|
using Cthangover.Core.UI.Tool.ScenarioEditor;
|
||||||
using Cthangover.Tools.InteractiveEditor;
|
using Cthangover.Tools.InteractiveEditor;
|
||||||
|
using Cthangover.Tools.Common;
|
||||||
using Cthangover.Tools.MapEditor;
|
using Cthangover.Tools.MapEditor;
|
||||||
|
using Cthangover.Tools.SceneEditor;
|
||||||
|
using Cthangover.Tools.Services;
|
||||||
using Godot;
|
using Godot;
|
||||||
|
|
||||||
namespace Cthangover.Tools.Tests.Windows
|
namespace Cthangover.Tools.Tests.Windows
|
||||||
@@ -31,6 +36,9 @@ namespace Cthangover.Tools.Tests.Windows
|
|||||||
case "light_editor":
|
case "light_editor":
|
||||||
LightAdd(window, check);
|
LightAdd(window, check);
|
||||||
break;
|
break;
|
||||||
|
case "scene_editor":
|
||||||
|
SceneCreate(window, check);
|
||||||
|
break;
|
||||||
case "scenario_editor":
|
case "scenario_editor":
|
||||||
var tree = Find<ScenarioFileTreePanel>(window)?.FindChildren("*", nameof(Tree), true, false).OfType<Tree>().FirstOrDefault();
|
var tree = Find<ScenarioFileTreePanel>(window)?.FindChildren("*", nameof(Tree), true, false).OfType<Tree>().FirstOrDefault();
|
||||||
check(tree?.GetRoot()?.GetChildCount() > 0, "scenario_editor: file tree lists mods with scenarios");
|
check(tree?.GetRoot()?.GetChildCount() > 0, "scenario_editor: file tree lists mods with scenarios");
|
||||||
@@ -104,6 +112,78 @@ namespace Cthangover.Tools.Tests.Windows
|
|||||||
"light_editor: double-click adds a light at the clicked point");
|
"light_editor: double-click adds a light at the clicked point");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a scene the way an author does — New scene, a name, the test fixture mod,
|
||||||
|
/// a double-clicked background, Save — and checks that the running game knows it and
|
||||||
|
/// that no scenario was made for it (a scene may hold only interactives); then removes
|
||||||
|
/// the files again. The scene goes
|
||||||
|
/// into editor_tools itself: it is always loaded with the window, while a test fixture
|
||||||
|
/// mod is not (--all-mods skips fixtures). Skipped when the mod is not a folder mod.
|
||||||
|
/// </summary>
|
||||||
|
private static void SceneCreate(Window window, Action<bool, string> check)
|
||||||
|
{
|
||||||
|
const string mod = "editor_tools";
|
||||||
|
const string name = "zz_tools_test_scene";
|
||||||
|
var lists = window.FindChildren("*", "", true, false).OfType<FilteredList>().ToList();
|
||||||
|
check(lists.Count == 2 && lists[0].Count > 0, $"scene_editor: scene list shows the game's scenes ({(lists.Count > 0 ? lists[0].Count : 0)})");
|
||||||
|
var modSelect = window.FindChildren("*", "", true, false).OfType<OptionButton>()
|
||||||
|
.FirstOrDefault(o => Enumerable.Range(0, o.ItemCount).Any(i => o.GetItemText(i) == mod));
|
||||||
|
if (modSelect == null || lists.Count < 2 || lists[1].Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var scenePath = ModFileService.GetWritablePath(mod, SceneFileEntry.PathFor(name));
|
||||||
|
var scenarioPath = ModFileService.GetWritablePath(mod, "scenarios/" + name + ".scenario");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DeleteLeftovers(scenePath, scenarioPath);
|
||||||
|
PressButton(window, "tools/scene_editor/new_scene");
|
||||||
|
var nameEdit = window.FindChildren("*", "", true, false).OfType<LineEdit>().First(e => e.PlaceholderText == "my_scene");
|
||||||
|
nameEdit.Text = name;
|
||||||
|
nameEdit.EmitSignal(LineEdit.SignalName.TextChanged, name);
|
||||||
|
var index = Enumerable.Range(0, modSelect.ItemCount).First(i => modSelect.GetItemText(i) == mod);
|
||||||
|
modSelect.Select(index);
|
||||||
|
modSelect.EmitSignal(OptionButton.SignalName.ItemSelected, index);
|
||||||
|
var picker = lists[1].FindChildren("*", "", true, false).OfType<ItemList>().First();
|
||||||
|
picker.EmitSignal(ItemList.SignalName.ItemActivated, 0);
|
||||||
|
PressButton(window, "tools/common/save");
|
||||||
|
|
||||||
|
var manager = window.GetTree().Root.GetNodeOrNull<SceneManager>("SceneManager");
|
||||||
|
check(File.Exists(scenePath) && File.ReadAllText(scenePath).Contains("\"defaultBackground\""), "scene_editor: Save writes the scene file with its background");
|
||||||
|
check(!File.Exists(scenarioPath), "scene_editor: a new scene is saved without a scenario");
|
||||||
|
check(manager?.HasScene(name) == true, "scene_editor: the running game knows the new scene without a reload");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteLeftovers(scenePath, scenarioPath);
|
||||||
|
SceneFileService.RefreshGame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes the test scene's files and the folders they alone needed (editor_tools has no scenes of its own).</summary>
|
||||||
|
private static void DeleteLeftovers(string scenePath, string scenarioPath)
|
||||||
|
{
|
||||||
|
foreach (var path in new[] { scenePath, scenarioPath })
|
||||||
|
{
|
||||||
|
if (path != null && File.Exists(path))
|
||||||
|
File.Delete(path);
|
||||||
|
DeleteIfEmpty(Path.GetDirectoryName(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes a folder left empty by the test.</summary>
|
||||||
|
private static void DeleteIfEmpty(string directory)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
|
||||||
|
Directory.Delete(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PressButton(Node root, string key)
|
||||||
|
{
|
||||||
|
var text = TranslationServer.Translate(key).ToString();
|
||||||
|
var button = root.FindChildren("*", "", true, false).OfType<Button>().First(b => b.Text == text);
|
||||||
|
button.EmitSignal(BaseButton.SignalName.Pressed);
|
||||||
|
}
|
||||||
|
|
||||||
private static void Click(Control target, Vector2 local, bool doubleClick)
|
private static void Click(Control target, Vector2 local, bool doubleClick)
|
||||||
{
|
{
|
||||||
var ev = new InputEventMouseButton
|
var ev = new InputEventMouseButton
|
||||||
|
|||||||
Reference in new issue
Block a user