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