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;
}
}
}