using System; using Cthangover.Core.Scenes; using Cthangover.Core.UI.Theming; using Cthangover.Core.Utils; using Cthangover.Mods.Interface.Theming; using Cthangover.Mods.Interface.Tool; using Cthangover.Tools.Common; using Cthangover.Tools.Services; using Godot; namespace Cthangover.Core.UI.Tool.ScenarioEditor { /// /// Tool window hosting the scenario editor: a searchable tree of every /// mod's .scenario files, tabbed code/graph editors, a metadata panel that /// follows the text as it is typed, background thumbnails, plus a /// play-test mode that runs the current scenario in-game. /// /// Shortcuts: Ctrl+S saves the active file, F5 play-tests it. /// public partial class ScenarioEditorWindow : ToolWindow { private const double InfoRefreshDelay = 0.4; private Control _editLayout; private ScenarioFileTreePanel _fileTreePanel; private ScenarioEditTabs _editTabs; private ScenarioInfoPanel _infoPanel; private ScenarioThumbnailPanel _thumbPanel; private Timer _infoTimer; private Button _saveBtn; private Button _openFileBtn; private Button _playBtn; private Label _filePathLabel; private bool _playActive; private string _pendingKey; private string _returnRoute; private string _returnScene; private CanvasLayer _stopOverlay; public ScenarioEditorWindow() : base("tools/scenario_editor/title") { CthangoverTheme.Apply(this); BuildUI(); SetHint(ToolUi.Tr("tools/scenario_editor/hint")); } /// 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. public override void _Process(double delta) { if (_playActive && !SceneManager.IsTestPlayActive) ReturnFromPlay(); } private void BuildUI() { _editLayout = new Control { AnchorRight = 1f, AnchorBottom = 1f }; AddChild(_editLayout); var outerVBox = CreateFillContainer(); _editLayout.AddChild(outerVBox); var toolbar = CreateToolbar(); outerVBox.AddChild(toolbar); _saveBtn = ToolUi.Button("tools/common/save", OnSavePressed, ButtonKind.Primary, "tools/common/save_tip"); toolbar.AddChild(_saveBtn); _playBtn = ToolUi.Button("tools/scenario_editor/play", OnPlayPressed, ButtonKind.Secondary, "tools/scenario_editor/play_tooltip"); toolbar.AddChild(_playBtn); _openFileBtn = ToolUi.Button("tools/scenario_editor/open_file", OnOpenFilePressed, ButtonKind.Secondary, "tools/scenario_editor/open_file_tip"); toolbar.AddChild(_openFileBtn); toolbar.AddChild(ToolUi.Divider()); toolbar.AddChild(ToolUi.Button("tools/scenario_editor/rescan", OnRescanPressed, ButtonKind.Secondary, "tools/scenario_editor/rescan_tip")); _filePathLabel = new Label { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center, TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis }; _filePathLabel.AddThemeFontOverride("font", ToolUi.Mono()); _filePathLabel.AddThemeColorOverride("font_color", UiTokens.Muted); _filePathLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall); toolbar.AddChild(_filePathLabel); var mainSplit = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; outerVBox.AddChild(mainSplit); var leftVBox = new VBoxContainer { SizeFlagsVertical = Control.SizeFlags.ExpandFill }; var leftPanel = CreateSidebar(leftVBox, 320); mainSplit.AddChild(leftPanel); var tabsContainer = new TabContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill, CustomMinimumSize = new Vector2(0, 420) }; leftVBox.AddChild(tabsContainer); _fileTreePanel = (ScenarioFileTreePanel)TscnScenes.LoadAndBuild("scenes/scenario_file_tree_panel.tscn"); _fileTreePanel.FileSelected += OnFileSelected; tabsContainer.AddChild(_fileTreePanel); tabsContainer.SetTabTitle(0, ToolUi.Tr("tools/scenario_editor/tab_files")); _infoPanel = (ScenarioInfoPanel)TscnScenes.LoadAndBuild("scenes/scenario_info_panel.tscn"); tabsContainer.AddChild(_infoPanel); tabsContainer.SetTabTitle(1, ToolUi.Tr("tools/scenario_editor/tab_info")); leftVBox.AddChild(ToolUi.Section("tools/scenario_editor/bg_previews")); _thumbPanel = (ScenarioThumbnailPanel)TscnScenes.LoadAndBuild("scenes/scenario_thumbnail_panel.tscn"); leftVBox.AddChild(_thumbPanel); _editTabs = new ScenarioEditTabs(new ScenarioGraphView()); _editTabs.TextTabSelected += OnTextTabSelected; _editTabs.GraphTabSelected += OnGraphTabSelected; _editTabs.DirtyChanged += OnDirtyChanged; _editTabs.GraphFileRequested += OnGraphFileRequested; _editTabs.BufferChanged += () => _infoTimer.Start(); mainSplit.AddChild(_editTabs); _infoTimer = new Timer { OneShot = true, WaitTime = InfoRefreshDelay }; _infoTimer.Timeout += () => _infoPanel.Update(_editTabs.CurrentText); AddChild(_infoTimer); } private void OnFileSelected(string filePath) { _editTabs.OpenFile(filePath); } private void OnGraphFileRequested(string filePath) { _fileTreePanel.SelectFile(filePath); } private void OnRescanPressed() { _fileTreePanel.Reload(); _editTabs.RefreshGraph(); SetStatus(ToolUi.Tr("tools/scenario_editor/rescanned")); } private void OnTextTabSelected(string text) { var hasFile = _editTabs.CurrentKey != null; _filePathLabel.Text = hasFile ? ScenarioFileService.GetDisplayPath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath) : ""; SetFileButtonsEnabled(hasFile); _infoPanel.Update(text); _thumbPanel.Load(text); } private void OnGraphTabSelected() { _filePathLabel.Text = ""; SetFileButtonsEnabled(false); _infoPanel.Clear(); _thumbPanel.Clear(); _editTabs.RefreshGraph(); } private void SetFileButtonsEnabled(bool enabled) { _saveBtn.Disabled = !enabled; _openFileBtn.Disabled = !enabled; _playBtn.Disabled = !enabled; } private void OnDirtyChanged(bool anyDirty) { if (anyDirty) SetDirty(); else MarkClean(); } /// Ctrl+S. protected override void OnSaveShortcut() => OnSavePressed(); /// F5 play-tests the active scenario. protected override bool HandleKey(InputEventKey key) { if (key.Keycode != Key.F5 || !_editTabs.HasActiveFile) return false; OnPlayPressed(); return true; } private void OnSavePressed() { if (!_editTabs.HasActiveFile) return; var modId = _editTabs.CurrentModId; var relativePath = _editTabs.CurrentRelativePath; var text = _editTabs.CurrentText; try { if (!ScenarioFileService.WriteAllText(modId, relativePath, text)) { SetStatus(ToolUi.Tr("tools/scenario_editor/readonly_zip"), ToolStatusKind.Error); return; } _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. SceneManager.ReloadContent(); SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), ScenarioFileService.GetDisplayPath(modId, relativePath)), ToolStatusKind.Ok); _infoPanel.Update(text); _thumbPanel.Load(text); GameLogger.Log("SCENARIO_EDITOR", $"Saved: {modId}/{relativePath}"); } catch (Exception ex) { GameLogger.Log("SCENARIO_EDITOR", $"Save failed: {ex.Message}", LogLevel.Error); SetStatus(string.Format(ToolUi.Tr("tools/scenario_editor/save_error"), ex.Message), ToolStatusKind.Error); } } private void OnOpenFilePressed() { if (_editTabs.CurrentKey == null) return; var path = ScenarioFileService.GetAbsolutePath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath); if (string.IsNullOrEmpty(path)) { SetStatus(ToolUi.Tr("tools/scenario_editor/readonly_zip"), ToolStatusKind.Error); return; } var err = OS.ShellOpen(path); if (err != Error.Ok) { GameLogger.Log("SCENARIO_EDITOR", $"ShellOpen '{path}' failed: {err}", LogLevel.Error); SetStatus($"{ToolUi.Tr("tools/scenario_editor/open_file_error")}: {err}", ToolStatusKind.Error); } } private void OnPlayPressed() { if (!_editTabs.HasActiveFile) return; var text = _editTabs.CurrentText; var metadata = ScenarioFileService.ParseMetadata(text); if (metadata.Scene == "-") { SetStatus(ToolUi.Tr("tools/scenario_editor/no_scene_error"), ToolStatusKind.Error); return; } var modId = _editTabs.CurrentModId; var relativePath = _editTabs.CurrentRelativePath; try { if (ScenarioFileService.WriteAllText(modId, relativePath, text)) { _editTabs.SaveCurrentFile(); } else { GameLogger.Log("SCENARIO_EDITOR", $"Test play: '{modId}/{relativePath}' is read-only, playing from the editor buffer", LogLevel.Warning); } } catch (Exception ex) { SetStatus(string.Format(ToolUi.Tr("tools/scenario_editor/save_error"), ex.Message), ToolStatusKind.Error); return; } SceneManager.IsTestPlayActive = true; SceneManager.TestScenarioText = text; GameLogger.Log("SCENARIO_EDITOR", $"Test play START: scene='{metadata.Scene}', queue={text.Split('\n').Length} lines"); Visible = false; // The screen the editor was opened from (a mod menu, a mod // route) is freed by the shell load below; remember its route so // the play-test returns there instead of to the engine's main menu. var sceneService = GetNodeOrNull("/root/GodotSceneService"); var current = sceneService?.CurrentGodotSceneName; _returnRoute = !string.IsNullOrEmpty(current) && SceneRouteRegistry.TryGet(current, out _) ? current : null; // Opened inside a running game: come back into the scene the // player was in, not to the main menu (there is no way back to the // editor from there). var sceneManager = GetNodeOrNull("/root/SceneManager"); var gameScene = sceneManager?.CurrentSceneName; _returnScene = _returnRoute == null && current != "mainmenu" && !string.IsNullOrEmpty(gameScene) && sceneManager.HasScene(gameScene) ? gameScene : null; _stopOverlay = CreateStopOverlay(); var tree = Godot.Engine.GetMainLoop() as SceneTree; tree?.Root.AddChild(_stopOverlay); if (sceneManager != null) { sceneManager.Initialize(); sceneManager.PendingSceneName = metadata.Scene; } sceneService?.LoadShell(); _playActive = true; } private void OnStopPressed() { SceneManager.IsTestPlayActive = false; ReturnFromPlay(); } private void ReturnFromPlay() { _playActive = false; Visible = true; if (_stopOverlay != null) { _stopOverlay.QueueFree(); _stopOverlay = null; } var sceneService = GetNodeOrNull("/root/GodotSceneService"); var sceneManager = GetNodeOrNull("/root/SceneManager"); if (_returnRoute != null && SceneRouteRegistry.TryGet(_returnRoute, out var route)) { sceneService?.SwitchRoute(route); } else if (_returnScene != null && sceneManager != null) { sceneManager.PendingSceneName = _returnScene; sceneService?.LoadShell(); } else { sceneService?.SwitchToMenu(); } _returnRoute = null; _returnScene = null; SetStatus(ToolUi.Tr("tools/scenario_editor/play_stopped")); } /// Stop button shown over the running game while the scenario is play-tested. private CanvasLayer CreateStopOverlay() { var layer = new CanvasLayer { Layer = 128 }; var btn = ToolUi.Button("tools/scenario_editor/stop", OnStopPressed, ButtonKind.Danger); btn.Theme = ToolTheme.Get(); btn.CustomMinimumSize = new Vector2(120, 36); var margin = new MarginContainer(); margin.AddThemeConstantOverride("margin_top", UiTokens.Space3); margin.AddThemeConstantOverride("margin_left", UiTokens.Space3); margin.AddChild(btn); layer.AddChild(margin); return layer; } } }