using System; using System.Collections.Generic; using System.Linq; using Cthangover.Core.Interactive; 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.Tools.InteractiveEditor { /// /// Editor of interactive object definitions (interactives/*.json). /// The author opens a file of a writable mod, sees every object of the /// file over its scene background, picks one in the list or on the canvas /// and edits its collider by dragging handles or typing numbers, plus its /// look (texture, layer, highlight) and actions (scenario, commands, hover /// commands). Saving writes the whole file back, other objects included. /// /// The background is chosen automatically from the scene whose scenario /// adds the selected object (interactive_add), when that is known. /// public partial class InteractiveEditorWindow : ToolWindow { private const string InteractivesDir = "interactives"; private readonly InteractiveEditorController _controller = new(); private readonly List _backgroundIds = new(); private Dictionary _interactiveScenes; private string _modId; private string _path; private bool _suppress; private ModFileSelector _fileSelector; private FilteredList _itemList; private FilteredList _bgList; private InteractiveEditorCanvas _canvas; private CodeEdit _jsonEdit; private Label _propsEmpty; private VBoxContainer _propsBody; private LineEdit _idEdit; private LineEdit _textureEdit; private OptionButton _layerSelect; private SpinBox _zIndex; private CheckBox _enabledCheck; private CheckBox _visibleCheck; private OptionButton _cursorSelect; private OptionButton _hitTypeSelect; private VBoxContainer _rectParams; private VBoxContainer _circleParams; private VBoxContainer _polyParams; private SpinBox _rectX, _rectY, _rectW, _rectH; private SpinBox _circleX, _circleY, _circleR; private VBoxContainer _vertexList; private ColorPickerButton _highlightColor; private SpinBox _highlightScale; private LineEdit _clickScenario; private CodeEdit _clickCommands; private LineEdit _hoverEnter; private LineEdit _hoverLeave; public InteractiveEditorWindow() : base("tools/interactive_editor/title") { CthangoverTheme.Apply(this); BuildUI(); LoadBackgrounds(); SetHint(ToolUi.Tr("tools/interactive_editor/hint")); _fileSelector.Refresh(); } // ── UI construction ──────────────────────────────────────── private void BuildUI() { var outer = CreateFillContainer(); AddChild(outer); var toolbar = CreateToolbar(); outer.AddChild(toolbar); _fileSelector = new ModFileSelector(InteractivesDir, ".json"); _fileSelector.FileRequested += OnFileRequested; toolbar.AddChild(_fileSelector); toolbar.AddChild(ToolUi.Button("tools/common/save", Save, ButtonKind.Primary, "tools/common/save_tip")); toolbar.AddChild(UiKit.Spacer()); var jsonToggle = ToolUi.Button("tools/common/json", null, ButtonKind.Tab, "tools/common/json_tip"); jsonToggle.ToggleMode = true; jsonToggle.Toggled += on => _jsonEdit.Visible = on; toolbar.AddChild(jsonToggle); var main = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; outer.AddChild(main); var left = new VBoxContainer(); main.AddChild(CreateSidebar(left, 260)); BuildLeft(left); var centerSplit = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; main.AddChild(centerSplit); var canvasColumn = new VSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; centerSplit.AddChild(canvasColumn); var canvasPanel = new PanelContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; canvasPanel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line)); canvasColumn.AddChild(canvasPanel); _canvas = new InteractiveEditorCanvas { Controller = _controller }; _canvas.Changed += OnCanvasChanged; _canvas.ItemPicked += _ => RefreshAll(); canvasPanel.AddChild(_canvas); _jsonEdit = ToolUi.Code(editable: false); _jsonEdit.CustomMinimumSize = new Vector2(0, 160); _jsonEdit.Visible = false; canvasColumn.AddChild(_jsonEdit); var right = new VBoxContainer(); centerSplit.AddChild(CreateSidebar(right, 330)); BuildProperties(right); } private void BuildLeft(VBoxContainer column) { column.AddChild(ToolUi.Section("tools/interactive_editor/items")); _itemList = new FilteredList(180); _itemList.Chosen += OnItemChosen; column.AddChild(_itemList); var row = new HBoxContainer(); row.AddThemeConstantOverride("separation", UiTokens.Space1); column.AddChild(row); AddExpanding(row, ToolUi.Button("tools/interactive_editor/add", OnAdd)); AddExpanding(row, ToolUi.Button("tools/interactive_editor/duplicate", OnDuplicate)); column.AddChild(ToolUi.Button("tools/interactive_editor/delete", OnDelete, ButtonKind.Danger)); column.AddChild(ToolUi.Section("tools/interactive_editor/background")); _bgList = new FilteredList(160); _bgList.SizeFlagsVertical = Control.SizeFlags.ExpandFill; _bgList.Chosen += ShowBackground; column.AddChild(_bgList); } private static void AddExpanding(Container parent, Control control) { control.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; parent.AddChild(control); } private void BuildProperties(VBoxContainer column) { column.AddChild(ToolUi.Section("tools/interactive_editor/props")); _propsEmpty = ToolUi.Hint(ToolUi.Tr("tools/interactive_editor/none_selected")); column.AddChild(_propsEmpty); var body = new VBoxContainer(); body.AddThemeConstantOverride("separation", UiTokens.Space2); column.AddChild(body); _propsBody = body; _idEdit = ToolUi.Field(body, "tools/interactive_editor/label_id", ToolUi.Text("crypt_spot", _ => OnIdChanged())); _idEdit.AddThemeFontOverride("font", ToolUi.Mono()); _textureEdit = ToolUi.Field(body, "tools/interactive_editor/label_texture_key", ToolUi.Text(ToolUi.Tr("tools/interactive_editor/texture_placeholder"), _ => OnFieldsChanged()), "tools/interactive_editor/texture_tip"); _layerSelect = ToolUi.Field(body, "tools/interactive_editor/label_layer", new OptionButton()); foreach (var layer in InteractiveEditorController.Layers) _layerSelect.AddItem(layer); _layerSelect.ItemSelected += _ => OnFieldsChanged(); _zIndex = ToolUi.Field(body, "tools/interactive_editor/label_z", ToolUi.Number(-100, 100, 1, _ => OnFieldsChanged())); _cursorSelect = ToolUi.Field(body, "tools/interactive_editor/label_cursor", new OptionButton()); _cursorSelect.AddItem(ToolUi.Tr("tools/interactive_editor/cursor_none")); _cursorSelect.AddItem("PointingHand"); _cursorSelect.ItemSelected += _ => OnFieldsChanged(); _enabledCheck = new CheckBox { Text = ToolUi.Tr("tools/interactive_editor/enabled") }; _enabledCheck.Toggled += _ => OnFieldsChanged(); body.AddChild(_enabledCheck); _visibleCheck = new CheckBox { Text = ToolUi.Tr("tools/interactive_editor/visible") }; _visibleCheck.Toggled += _ => OnFieldsChanged(); body.AddChild(_visibleCheck); body.AddChild(ToolUi.Section("tools/interactive_editor/collider")); _hitTypeSelect = ToolUi.Field(body, "tools/interactive_editor/label_collider_type", new OptionButton()); foreach (var type in InteractiveEditorController.HitTypes) _hitTypeSelect.AddItem(HitTypeLabel(type)); _hitTypeSelect.ItemSelected += OnHitTypeChanged; _rectParams = new VBoxContainer(); body.AddChild(_rectParams); _rectX = ToolUi.Field(_rectParams, "tools/interactive_editor/label_x", ToolUi.Number(0, 1, 0.001, _ => OnGeometryTyped())); _rectY = ToolUi.Field(_rectParams, "tools/interactive_editor/label_y", ToolUi.Number(0, 1, 0.001, _ => OnGeometryTyped())); _rectW = ToolUi.Field(_rectParams, "tools/interactive_editor/label_w", ToolUi.Number(0.001, 1, 0.001, _ => OnGeometryTyped())); _rectH = ToolUi.Field(_rectParams, "tools/interactive_editor/label_h", ToolUi.Number(0.001, 1, 0.001, _ => OnGeometryTyped())); _circleParams = new VBoxContainer(); body.AddChild(_circleParams); _circleX = ToolUi.Field(_circleParams, "tools/interactive_editor/label_cx", ToolUi.Number(0, 1, 0.001, _ => OnGeometryTyped())); _circleY = ToolUi.Field(_circleParams, "tools/interactive_editor/label_cy", ToolUi.Number(0, 1, 0.001, _ => OnGeometryTyped())); _circleR = ToolUi.Field(_circleParams, "tools/interactive_editor/label_r", ToolUi.Number(0.001, 1, 0.001, _ => OnGeometryTyped())); _polyParams = new VBoxContainer(); body.AddChild(_polyParams); _polyParams.AddChild(ToolUi.Hint(ToolUi.Tr("tools/interactive_editor/polygon_hint"))); _vertexList = new VBoxContainer(); _vertexList.AddThemeConstantOverride("separation", UiTokens.Space1); _polyParams.AddChild(_vertexList); body.AddChild(ToolUi.Section("tools/interactive_editor/highlight")); _highlightColor = ToolUi.Field(body, "tools/interactive_editor/label_highlight_color", new ColorPickerButton { CustomMinimumSize = new Vector2(0, 28) }); _highlightColor.ColorChanged += _ => OnFieldsChanged(); _highlightScale = ToolUi.Field(body, "tools/interactive_editor/label_highlight_scale", ToolUi.Number(0.5, 2, 0.01, _ => OnFieldsChanged())); body.AddChild(ToolUi.Section("tools/interactive_editor/actions")); _clickScenario = ToolUi.Field(body, "tools/interactive_editor/label_onclick_scenario", ToolUi.Text("scenarios/…/file.scenario", _ => OnFieldsChanged())); body.AddChild(UiKit.Label(ToolUi.Tr("tools/interactive_editor/label_onclick_commands"), UiTokens.FontBodySmall, UiTokens.Muted)); _clickCommands = ToolUi.Code(); _clickCommands.GuttersDrawLineNumbers = false; _clickCommands.CustomMinimumSize = new Vector2(0, 70); _clickCommands.SyntaxHighlighter = ScenarioSyntaxService.CreateHighlighter(); _clickCommands.TextChanged += OnFieldsChanged; body.AddChild(_clickCommands); _hoverEnter = ToolUi.Field(body, "tools/interactive_editor/label_hover_enter", ToolUi.Text("", _ => OnFieldsChanged())); _hoverLeave = ToolUi.Field(body, "tools/interactive_editor/label_hover_leave", ToolUi.Text("", _ => OnFieldsChanged())); } // ── loading & saving ─────────────────────────────────────── private void LoadBackgrounds() { _backgroundIds.AddRange(ModResourceService.GetBackgroundIds()); _bgList.SetItems(_backgroundIds); } private void ShowBackground(string id) { if (string.IsNullOrEmpty(id)) return; _bgList.Select(id); _canvas.SetBackground(ModResourceService.LoadBackgroundTexture(id)); } /// Shows the background of the scene that adds the selected object, when known. private void ShowSceneBackground() { var id = _controller.Selected?.ID; if (id == null) return; _interactiveScenes ??= SceneBackgroundService.MapInteractivesToScenes(); if (!_interactiveScenes.TryGetValue(id, out var scene)) return; var background = SceneBackgroundService.ForScene(scene); if (background == null || background == _bgList.SelectedId) return; if (ModResourceService.LoadBackgroundTexture(background) == null) SetStatus(string.Format(ToolUi.Tr("tools/common/background_missing"), background, scene), ToolStatusKind.Error); else ShowBackground(background); } private void OnFileRequested(string modId, string path) { RunDiscardingChanges(() => Load(modId, path), () => _fileSelector.Show(_modId, _path)); } private void Load(string modId, string path) { _modId = modId; _path = path; _controller.Clear(); MarkClean(); if (modId == null) SetStatus(ToolUi.Tr("tools/common/no_writable_mods"), ToolStatusKind.Error); else if (path == null) SetStatus(string.Format(ToolUi.Tr("tools/common/no_files"), modId)); else { try { var text = ModFileService.ReadText(modId, path); _controller.FromJson(text); SetStatus(string.Format(ToolUi.Tr(string.IsNullOrEmpty(text) ? "tools/common/new_file_status" : "tools/common/loaded"), modId + "/" + path)); } catch (Exception ex) { SetStatus($"{ToolUi.Tr("tools/common/load_error")}: {ex.Message}", ToolStatusKind.Error); GameLogger.Log("INTERACTIVE_EDITOR", $"load failed for '{modId}/{path}': {ex.Message}", LogLevel.Error); } } _fileSelector.Show(_modId, _path); RefreshAll(); ShowSceneBackground(); } /// Ctrl+S. protected override void OnSaveShortcut() => Save(); private void Save() { if (_path == null) { SetStatus(ToolUi.Tr("tools/common/no_file_to_save"), ToolStatusKind.Error); return; } var problem = Validate(); if (problem != null) { SetStatus(problem, ToolStatusKind.Error); return; } if (!ModFileService.WriteText(_modId, _path, _controller.ToJson())) { SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error); return; } MarkClean(); _fileSelector.Show(_modId, _path); SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), _modId + "/" + _path), ToolStatusKind.Ok); } /// First blocking problem of the file (empty/duplicate ID, polygon with < 3 vertices), or null. private string Validate() { foreach (var item in _controller.Items) { if (string.IsNullOrWhiteSpace(item.ID)) return ToolUi.Tr("tools/interactive_editor/id_empty"); if (item.HitArea.Type == "polygon" && (item.HitArea.Vertices?.Length ?? 0) < 3) return string.Format(ToolUi.Tr("tools/interactive_editor/polygon_too_small"), item.ID); } var duplicate = _controller.Items.GroupBy(i => i.ID, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1); return duplicate != null ? $"{ToolUi.Tr("tools/interactive_editor/duplicate_id")} {duplicate.Key}" : null; } // ── list actions ─────────────────────────────────────────── private void OnItemChosen(string indexText) { if (_suppress || !int.TryParse(indexText, out var index)) return; _controller.Select(index); RefreshAll(); ShowSceneBackground(); } private void OnAdd() { if (_path == null) { SetStatus(ToolUi.Tr("tools/common/no_file_to_save"), ToolStatusKind.Error); return; } _controller.Add(); Changed(); RefreshAll(); } private void OnDuplicate() { if (_controller.DuplicateSelected() == null) return; Changed(); RefreshAll(); } private void OnDelete() { var item = _controller.Selected; if (item == null) return; ShowConfirm("tools/interactive_editor/delete", "tools/interactive_editor/delete_confirm", () => { _controller.RemoveSelected(); Changed(); RefreshAll(); SetStatus(string.Format(ToolUi.Tr("tools/interactive_editor/deleted"), item.ID)); }); } /// Delete removes the selected object (with confirmation). protected override bool HandleKey(InputEventKey key) { if (key.Keycode != Key.Delete || _controller.Selected == null) return false; OnDelete(); return true; } // ── property editing ─────────────────────────────────────── private void OnIdChanged() { var item = _controller.Selected; if (_suppress || item == null) return; item.ID = _idEdit.Text.Trim(); Changed(); RefreshItemList(); } private void OnFieldsChanged() { var item = _controller.Selected; if (_suppress || item == null) return; item.Texture = NullIfEmpty(_textureEdit.Text); item.Layer = InteractiveEditorController.Layers[Math.Max(0, _layerSelect.Selected)]; item.ZIndex = (int)_zIndex.Value; item.Enabled = _enabledCheck.ButtonPressed; item.Visible = _visibleCheck.ButtonPressed; item.Cursor = _cursorSelect.Selected == 1 ? "PointingHand" : null; item.Highlight.ColorHex = "#" + _highlightColor.Color.ToHtml(true); item.Highlight.Scale = (float)_highlightScale.Value; var commands = _clickCommands.Text.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray(); var scenario = NullIfEmpty(_clickScenario.Text); item.Actions.OnClick = scenario != null || commands.Length > 0 ? new ClickAction { Scenario = scenario, Commands = commands.Length > 0 ? commands : null } : null; item.Actions.OnHoverEnter = NullIfEmpty(_hoverEnter.Text); item.Actions.OnHoverLeave = NullIfEmpty(_hoverLeave.Text); Changed(); } private void OnHitTypeChanged(long index) { var item = _controller.Selected; if (_suppress || item == null) return; InteractiveEditorController.SetHitType(item, InteractiveEditorController.HitTypes[index]); Changed(); RefreshAll(); } private void OnGeometryTyped() { var hit = _controller.Selected?.HitArea; if (_suppress || hit == null) return; if (hit.Type == "circle") { hit.X = (float)_circleX.Value; hit.Y = (float)_circleY.Value; hit.Radius = (float)_circleR.Value; } else if (hit.Type == "rect") { hit.X = (float)_rectX.Value; hit.Y = (float)_rectY.Value; hit.Width = (float)_rectW.Value; hit.Height = (float)_rectH.Value; } Changed(); _canvas.RebuildHandles(); _canvas.QueueRedraw(); } private void OnCanvasChanged() { Changed(); _suppress = true; WriteGeometry(_controller.Selected?.HitArea); _suppress = false; } private void Changed() { SetDirty(); if (_jsonEdit.Visible) _jsonEdit.Text = _controller.ToJson(); } // ── refresh ──────────────────────────────────────────────── private void RefreshAll() { RefreshItemList(); _suppress = true; WriteSidebar(); _suppress = false; _jsonEdit.Text = _controller.ToJson(); _canvas.RebuildHandles(); _canvas.QueueRedraw(); } private void RefreshItemList() { var items = _controller.Items .Select((item, i) => (i.ToString(), (string.IsNullOrEmpty(item.ID) ? "?" : item.ID) + " · " + HitTypeLabel(item.HitArea.Type), (Texture2D)null)); _itemList.SetItems(items); _itemList.Select(_controller.SelectedIndex >= 0 ? _controller.SelectedIndex.ToString() : null); } private void WriteSidebar() { var item = _controller.Selected; _propsEmpty.Visible = item == null; _propsBody.Visible = item != null; if (item == null) return; _idEdit.Text = item.ID ?? string.Empty; _textureEdit.Text = item.Texture ?? string.Empty; _layerSelect.Select(Math.Max(0, Array.IndexOf(InteractiveEditorController.Layers, item.Layer))); _zIndex.Value = item.ZIndex; _enabledCheck.ButtonPressed = item.Enabled; _visibleCheck.ButtonPressed = item.Visible; _cursorSelect.Select(string.IsNullOrEmpty(item.Cursor) ? 0 : 1); _hitTypeSelect.Select(Math.Max(0, Array.IndexOf(InteractiveEditorController.HitTypes, item.HitArea.Type))); _highlightColor.Color = item.Highlight.Color; _highlightScale.Value = item.Highlight.Scale; _clickScenario.Text = item.Actions.OnClick?.Scenario ?? string.Empty; _clickCommands.Text = item.Actions.OnClick?.Commands != null ? string.Join("\n", item.Actions.OnClick.Commands) : string.Empty; _hoverEnter.Text = item.Actions.OnHoverEnter ?? string.Empty; _hoverLeave.Text = item.Actions.OnHoverLeave ?? string.Empty; WriteGeometry(item.HitArea); } private void WriteGeometry(HitAreaDefinition hit) { if (hit == null) return; _rectParams.Visible = hit.Type == "rect"; _circleParams.Visible = hit.Type == "circle"; _polyParams.Visible = hit.Type == "polygon"; _rectX.Value = hit.X; _rectY.Value = hit.Y; _rectW.Value = hit.Width; _rectH.Value = hit.Height; _circleX.Value = hit.X; _circleY.Value = hit.Y; _circleR.Value = hit.Radius; RefreshVertexList(hit); } /// X/Y spin rows per polygon vertex; handlers read the vertex at change time. private void RefreshVertexList(HitAreaDefinition hit) { var vertices = hit.Type == "polygon" ? hit.Vertices ?? Array.Empty() : Array.Empty(); var rows = _vertexList.GetChildren(); // Reuse rows while dragging so focus and scroll are not lost on every motion. if (rows.Count == vertices.Length) { for (var i = 0; i < vertices.Length; i++) { var row = rows[i]; ((SpinBox)row.GetChild(1)).SetValueNoSignal(vertices[i].X); ((SpinBox)row.GetChild(2)).SetValueNoSignal(vertices[i].Y); } return; } foreach (var child in rows) child.QueueFree(); for (var i = 0; i < vertices.Length; i++) { var index = i; var row = new HBoxContainer(); row.AddThemeConstantOverride("separation", UiTokens.Space1); var number = UiKit.Label((i + 1).ToString(), UiTokens.FontSmall, UiTokens.Faint); number.CustomMinimumSize = new Vector2(20, 0); row.AddChild(number); var x = ToolUi.Number(0, 1, 0.001, null); x.Value = vertices[i].X; x.ValueChanged += v => OnVertexTyped(index, (float)v, null); row.AddChild(x); var y = ToolUi.Number(0, 1, 0.001, null); y.Value = vertices[i].Y; y.ValueChanged += v => OnVertexTyped(index, null, (float)v); row.AddChild(y); _vertexList.AddChild(row); } } private void OnVertexTyped(int index, float? x, float? y) { var hit = _controller.Selected?.HitArea; if (_suppress || hit?.Vertices == null || index >= hit.Vertices.Length) return; var v = hit.Vertices[index]; hit.Vertices[index] = new Vector2(x ?? v.X, y ?? v.Y); Changed(); _canvas.RebuildHandles(); _canvas.QueueRedraw(); } private static string HitTypeLabel(string type) => ToolUi.Tr(type switch { "circle" => "tools/interactive_editor/hit_circle", "polygon" => "tools/interactive_editor/hit_polygon", _ => "tools/interactive_editor/hit_rect" }); private static string NullIfEmpty(string text) { var trimmed = text?.Trim(); return string.IsNullOrEmpty(trimmed) ? null : trimmed; } } }