From 8e6ad52ee44111baab0376569e2923910d402e11 Mon Sep 17 00:00:00 2001 From: iisaev Date: Thu, 10 Sep 2026 10:59:40 +0300 Subject: [PATCH] init --- locale/ui_en.properties | 127 +++ locale/ui_ru.properties | 128 +++ manifest.json | 11 + scenes/scenario_file_tree_panel.tscn | 12 + scenes/scenario_info_panel.tscn | 47 + scenes/scenario_thumbnail_panel.tscn | 10 + .../InteractiveEditorController.cs | 244 +++++ .../InteractiveEditorHandle.cs | 64 ++ .../InteractiveEditorToolProvider.cs | 36 + .../InteractiveEditorWindow.cs | 853 ++++++++++++++++++ src/LightEditor/LightEditorController.cs | 175 ++++ src/LightEditor/LightEditorHandle.cs | 58 ++ src/LightEditor/LightEditorToolProvider.cs | 29 + src/LightEditor/LightEditorWindow.cs | 374 ++++++++ src/ScenarioEditor/ScenarioEditTabs.cs | 297 ++++++ .../ScenarioEditorToolProvider.cs | 16 + src/ScenarioEditor/ScenarioEditorWindow.cs | 354 ++++++++ src/ScenarioEditor/ScenarioFileTreePanel.cs | 103 +++ src/ScenarioEditor/ScenarioGraphView.cs | 211 +++++ src/ScenarioEditor/ScenarioInfoPanel.cs | 62 ++ src/ScenarioEditor/ScenarioThumbnailPanel.cs | 66 ++ src/SceneBuilder/InspectableProperty.cs | 24 + src/SceneBuilder/InspectorBoolField.cs | 25 + src/SceneBuilder/InspectorColorField.cs | 27 + src/SceneBuilder/InspectorEnumField.cs | 48 + src/SceneBuilder/InspectorNumericField.cs | 37 + src/SceneBuilder/InspectorPanel.cs | 391 ++++++++ src/SceneBuilder/InspectorPropertyCatalog.cs | 155 ++++ src/SceneBuilder/InspectorPropertyKind.cs | 9 + src/SceneBuilder/InspectorTextField.cs | 26 + src/SceneBuilder/InspectorVector2Field.cs | 45 + src/SceneBuilder/SceneBuilderController.cs | 430 +++++++++ src/SceneBuilder/SceneBuilderToolProvider.cs | 16 + src/SceneBuilder/SceneBuilderWindow.cs | 723 +++++++++++++++ src/SceneBuilder/SelectionOverlay.cs | 486 ++++++++++ src/SceneBuilder/TscnFileProps.cs | 58 ++ src/SceneBuilder/TscnLiteralReader.cs | 104 +++ src/SceneBuilder/TscnNodeAddressing.cs | 25 + src/SceneBuilder/TscnTextSurgery.cs | 283 ++++++ src/SceneBuilder/TscnValueFormatter.cs | 82 ++ src/Services/ModResourceService.cs | 73 ++ src/Services/ScenarioFileService.cs | 338 +++++++ src/Services/ScenarioGraphData.cs | 34 + src/Services/ScenarioSyntaxService.cs | 126 +++ 44 files changed, 6842 insertions(+) create mode 100644 locale/ui_en.properties create mode 100644 locale/ui_ru.properties create mode 100644 manifest.json create mode 100644 scenes/scenario_file_tree_panel.tscn create mode 100644 scenes/scenario_info_panel.tscn create mode 100644 scenes/scenario_thumbnail_panel.tscn create mode 100644 src/InteractiveEditor/InteractiveEditorController.cs create mode 100644 src/InteractiveEditor/InteractiveEditorHandle.cs create mode 100644 src/InteractiveEditor/InteractiveEditorToolProvider.cs create mode 100644 src/InteractiveEditor/InteractiveEditorWindow.cs create mode 100644 src/LightEditor/LightEditorController.cs create mode 100644 src/LightEditor/LightEditorHandle.cs create mode 100644 src/LightEditor/LightEditorToolProvider.cs create mode 100644 src/LightEditor/LightEditorWindow.cs create mode 100644 src/ScenarioEditor/ScenarioEditTabs.cs create mode 100644 src/ScenarioEditor/ScenarioEditorToolProvider.cs create mode 100644 src/ScenarioEditor/ScenarioEditorWindow.cs create mode 100644 src/ScenarioEditor/ScenarioFileTreePanel.cs create mode 100644 src/ScenarioEditor/ScenarioGraphView.cs create mode 100644 src/ScenarioEditor/ScenarioInfoPanel.cs create mode 100644 src/ScenarioEditor/ScenarioThumbnailPanel.cs create mode 100644 src/SceneBuilder/InspectableProperty.cs create mode 100644 src/SceneBuilder/InspectorBoolField.cs create mode 100644 src/SceneBuilder/InspectorColorField.cs create mode 100644 src/SceneBuilder/InspectorEnumField.cs create mode 100644 src/SceneBuilder/InspectorNumericField.cs create mode 100644 src/SceneBuilder/InspectorPanel.cs create mode 100644 src/SceneBuilder/InspectorPropertyCatalog.cs create mode 100644 src/SceneBuilder/InspectorPropertyKind.cs create mode 100644 src/SceneBuilder/InspectorTextField.cs create mode 100644 src/SceneBuilder/InspectorVector2Field.cs create mode 100644 src/SceneBuilder/SceneBuilderController.cs create mode 100644 src/SceneBuilder/SceneBuilderToolProvider.cs create mode 100644 src/SceneBuilder/SceneBuilderWindow.cs create mode 100644 src/SceneBuilder/SelectionOverlay.cs create mode 100644 src/SceneBuilder/TscnFileProps.cs create mode 100644 src/SceneBuilder/TscnLiteralReader.cs create mode 100644 src/SceneBuilder/TscnNodeAddressing.cs create mode 100644 src/SceneBuilder/TscnTextSurgery.cs create mode 100644 src/SceneBuilder/TscnValueFormatter.cs create mode 100644 src/Services/ModResourceService.cs create mode 100644 src/Services/ScenarioFileService.cs create mode 100644 src/Services/ScenarioGraphData.cs create mode 100644 src/Services/ScenarioSyntaxService.cs diff --git a/locale/ui_en.properties b/locale/ui_en.properties new file mode 100644 index 0000000..61e3962 --- /dev/null +++ b/locale/ui_en.properties @@ -0,0 +1,127 @@ +mod_tools_name = Tools +mod_tools_desc = Development tools + +tools/title = Tools +tools/select_tool = Select a tool: +tools/launch = Launch +tools/close = Close + +tools/light_editor/title = Light Editor +tools/light_editor/background = Background +tools/light_editor/static_lights = Static lights (max. 10) +tools/light_editor/add_light = + Add +tools/light_editor/selected_props = Selected properties +tools/light_editor/radius = Radius +tools/light_editor/influence = Influence +tools/light_editor/color = Color +tools/light_editor/import_json = Import from JSON +tools/light_editor/norm_coords = Norm. coords. (0-1). Drag markers. +tools/light_editor/light_n = Light {0} ({1:F3},{2:F3}) +tools/light_editor/btn_select = S +tools/light_editor/btn_delete = D + +tools/scene_builder/title = Scene Builder +tools/scene_builder/base_scene = Base scene: +tools/scene_builder/mod = Mod: +tools/scene_builder/scene = Scene: +tools/scene_builder/hierarchy = Hierarchy +tools/scene_builder/tscn = TSCN +tools/scene_builder/output = Output +tools/scene_builder/clear = Clear +tools/scene_builder/refresh = Refresh +tools/scene_builder/reload = Reload +tools/scene_builder/save = Save +tools/scene_builder/select_scene = -- Select a scene -- +tools/scene_builder/select_mod = -- Select a mod -- +tools/scene_builder/empty_scene = (empty scene) +tools/scene_builder/error_no_scene = Error: scene not selected. +tools/scene_builder/error_save = Save error. +tools/scene_builder/saved_ok = Saved. +tools/scene_builder/reloaded = Reloaded. +tools/scene_builder/inspector = Inspector +tools/scene_builder/inspector_select = Select a node of the mod scene to edit its properties +tools/scene_builder/inspector_readonly = Read-only: node belongs to the base scene +tools/scene_builder/inspector_missing_section = Node section not found in tscn (nodes of instanced sub-scenes are not editable yet) +tools/scene_builder/inspector_preset = Anchors preset +tools/scene_builder/inspector_cat_layout = Layout +tools/scene_builder/inspector_cat_rect = Rect +tools/scene_builder/inspector_cat_visibility = Visibility +tools/scene_builder/inspector_cat_misc = Misc +tools/scene_builder/autosaved = Auto-saved. +tools/scene_builder/inspector_bad_value = Value cannot be written to tscn. +tools/scene_builder/inspector_in_file_tooltip = Stored in tscn file +tools/scene_builder/inspector_container_banner = This node is managed by a container: anchors and offsets are recomputed by the parent and will have no effect here. +tools/scene_builder/add_node_tip = Add child node of the selected type (to selection, or scene root) +tools/scene_builder/delete_node_tip = Delete selected mod node +tools/scene_builder/err_add_failed = Error: cannot create node of this type. +tools/scene_builder/err_add_parent_missing = Error: parent section not found in tscn (instanced sub-scene parents are not supported). +tools/scene_builder/err_del_root = Error: the scene root cannot be deleted. +tools/scene_builder/err_del_none = Error: select a mod node to delete. +tools/scene_builder/tab_inspector = Inspector +tools/scene_builder/tab_tscn = TSCN +tools/scene_builder/light_editor = Light Editor +tools/scene_builder/scene_builder = Scene Builder + +tools/scenario_editor/title = Scenario Editor +tools/scenario_editor/save = Save +tools/scenario_editor/open_file = Open file +tools/scenario_editor/tab_files = Files +tools/scenario_editor/tab_info = Info +tools/scenario_editor/tab_graph = Graph +tools/scenario_editor/graph_select_node = Select a node to see details +tools/scenario_editor/info_scene = Scene +tools/scenario_editor/info_priority = Priority +tools/scenario_editor/info_condition = Condition +tools/scenario_editor/info_switch_targets = Scene transitions +tools/scenario_editor/info_locale_keys = Locale keys +tools/scenario_editor/info_quest_refs = Quests +tools/scenario_editor/bg_previews = Background previews +tools/scenario_editor/no_backgrounds = No backgrounds +tools/scenario_editor/status_modified = Modified (unsaved) +tools/scenario_editor/status_saved = Saved +tools/scenario_editor/save_error = Save error: {0} +tools/scenario_editor/unsaved_switch_text = You have unsaved changes. Switch file without saving? + +tools/unsaved_title = Unsaved changes +tools/unsaved_text = You have unsaved changes. Close without saving? + +tools/scenario_editor/play = ▶ Play +tools/scenario_editor/play_tooltip = Run scenario in test mode +tools/scenario_editor/stop = ⏹ Stop +tools/scenario_editor/no_scene_error = Error: scene (scene:) not specified in scenario metadata + +tools/interactive_editor/title = Interactive Editor + +tools/interactive_editor/btn_load_json = Load JSON +tools/interactive_editor/btn_save = Save +tools/interactive_editor/btn_import_mod = Import from mod +tools/interactive_editor/dlg_select_def = Select Definition + +tools/interactive_editor/tab_background = Background +tools/interactive_editor/tab_interactive = Interactive +tools/interactive_editor/tab_scenario = Scenarios + +tools/interactive_editor/label_id = ID: +tools/interactive_editor/label_texture_key = Texture key: +tools/interactive_editor/label_layer = Layer: +tools/interactive_editor/label_bg_preview = Background preview: +tools/interactive_editor/label_collider_type = Collider type: +tools/interactive_editor/label_rect_params = Rect (norm. 0-1): +tools/interactive_editor/label_x = x: +tools/interactive_editor/label_y = y: +tools/interactive_editor/label_w = w: +tools/interactive_editor/label_h = h: +tools/interactive_editor/label_circle_params = Circle (norm. 0-1): +tools/interactive_editor/label_cx = cx: +tools/interactive_editor/label_cy = cy: +tools/interactive_editor/label_r = r: +tools/interactive_editor/label_polygon_vertices = Vertices: +tools/interactive_editor/btn_add_vertex = + Add vertex +tools/interactive_editor/label_highlight_color = Highlight color: +tools/interactive_editor/label_highlight_scale = Highlight scale: +tools/interactive_editor/label_cursor = Cursor: +tools/interactive_editor/cursor_none = (none) +tools/interactive_editor/label_onclick_scenario = OnClick scenario: +tools/interactive_editor/label_onclick_commands = OnClick commands: +tools/interactive_editor/label_hover_enter = OnHoverEnter DSL: +tools/interactive_editor/label_hover_leave = OnHoverLeave DSL: diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties new file mode 100644 index 0000000..8f2c406 --- /dev/null +++ b/locale/ui_ru.properties @@ -0,0 +1,128 @@ +# Русская локализация для инструментов разработки +mod_tools_name = Инструменты +mod_tools_desc = Инструменты разработчика + +tools/title = Инструменты +tools/select_tool = Выберите инструмент: +tools/launch = Запустить +tools/close = Закрыть + +tools/light_editor/title = Редактор освещения +tools/light_editor/background = Фон +tools/light_editor/static_lights = Статичные источники (макс. 10) +tools/light_editor/add_light = + Добавить +tools/light_editor/selected_props = Свойства выбранного +tools/light_editor/radius = Радиус +tools/light_editor/influence = Влияние +tools/light_editor/color = Цвет +tools/light_editor/import_json = Импорт из JSON +tools/light_editor/norm_coords = Норм. коорд. (0-1). Перетащите маркеры. +tools/light_editor/light_n = Источник {0} ({1:F3},{2:F3}) +tools/light_editor/btn_select = В +tools/light_editor/btn_delete = У + +tools/scene_builder/title = Конструктор сцен +tools/scene_builder/base_scene = Базовая сцена: +tools/scene_builder/mod = Мод: +tools/scene_builder/scene = Сцена: +tools/scene_builder/hierarchy = Иерархия +tools/scene_builder/tscn = TSCN +tools/scene_builder/output = Вывод +tools/scene_builder/clear = Очистить +tools/scene_builder/refresh = Обновить +tools/scene_builder/reload = Перезагрузить +tools/scene_builder/save = Сохранить +tools/scene_builder/select_scene = -- Выберите сцену -- +tools/scene_builder/select_mod = -- Выберите мод -- +tools/scene_builder/empty_scene = (пустая сцена) +tools/scene_builder/error_no_scene = Ошибка: сцена не выбрана. +tools/scene_builder/error_save = Ошибка сохранения. +tools/scene_builder/saved_ok = Сохранено. +tools/scene_builder/reloaded = Перезагружено. +tools/scene_builder/inspector = Инспектор +tools/scene_builder/inspector_select = Выберите узел мод-сцены для правки свойств +tools/scene_builder/inspector_readonly = Только чтение: узел принадлежит базовой сцене +tools/scene_builder/inspector_missing_section = Секция узла не найдена в tscn (узлы вложенных сцен пока не редактируются) +tools/scene_builder/inspector_preset = Пресет якорей +tools/scene_builder/inspector_cat_layout = Расположение +tools/scene_builder/inspector_cat_rect = Прямоугольник +tools/scene_builder/inspector_cat_visibility = Видимость +tools/scene_builder/inspector_cat_misc = Прочее +tools/scene_builder/autosaved = Автосохранено. +tools/scene_builder/inspector_bad_value = Значение не может быть записано в tscn. +tools/scene_builder/inspector_in_file_tooltip = Записано в tscn-файле +tools/scene_builder/inspector_container_banner = Узлом управляет контейнер: якоря и отступы пересчитываются родителем и здесь не действуют. +tools/scene_builder/add_node_tip = Добавить дочерний узел выбранного типа (в выделение или корень сцены) +tools/scene_builder/delete_node_tip = Удалить выбранный узел мод-сцены +tools/scene_builder/err_add_failed = Ошибка: не удалось создать узел этого типа. +tools/scene_builder/err_add_parent_missing = Ошибка: родительская секция не найдена в tscn (родители из вложенных сцен не поддерживаются). +tools/scene_builder/err_del_root = Ошибка: корень сцены удалить нельзя. +tools/scene_builder/err_del_none = Ошибка: выберите узел мод-сцены для удаления. +tools/scene_builder/tab_inspector = Инспектор +tools/scene_builder/tab_tscn = TSCN +tools/scene_builder/light_editor = Редактор освещения +tools/scene_builder/scene_builder = Конструктор сцен + +tools/scenario_editor/title = Редактор сценариев +tools/scenario_editor/save = Сохранить +tools/scenario_editor/open_file = Открыть файл +tools/scenario_editor/tab_files = Файлы +tools/scenario_editor/tab_info = Инфо +tools/scenario_editor/tab_graph = Граф +tools/scenario_editor/graph_select_node = Выберите узел для просмотра деталей +tools/scenario_editor/info_scene = Сцена +tools/scenario_editor/info_priority = Приоритет +tools/scenario_editor/info_condition = Условие +tools/scenario_editor/info_switch_targets = Переходы на сцены +tools/scenario_editor/info_locale_keys = Ключи локализации +tools/scenario_editor/info_quest_refs = Квесты +tools/scenario_editor/bg_previews = Превью фонов +tools/scenario_editor/no_backgrounds = Нет фонов +tools/scenario_editor/status_modified = Изменено (не сохранено) +tools/scenario_editor/status_saved = Сохранено +tools/scenario_editor/save_error = Ошибка сохранения: {0} +tools/scenario_editor/unsaved_switch_text = У вас есть несохранённые изменения. Сменить файл без сохранения? + +tools/unsaved_title = Есть изменения +tools/unsaved_text = У вас есть несохранённые изменения. Закрыть без сохранения? + +tools/scenario_editor/play = ▶ Запустить +tools/scenario_editor/play_tooltip = Запустить сценарий в тестовом режиме +tools/scenario_editor/stop = ⏹ Стоп +tools/scenario_editor/no_scene_error = Ошибка: не указана сцена (scene:) в метаданных сценария + +tools/interactive_editor/title = Редактор интерактивов + +tools/interactive_editor/btn_load_json = Загрузить JSON +tools/interactive_editor/btn_save = Сохранить +tools/interactive_editor/btn_import_mod = Импорт из мода +tools/interactive_editor/dlg_select_def = Выбор определения + +tools/interactive_editor/tab_background = Задник +tools/interactive_editor/tab_interactive = Интерактив +tools/interactive_editor/tab_scenario = Сценарии + +tools/interactive_editor/label_id = ID: +tools/interactive_editor/label_texture_key = Ключ текстуры: +tools/interactive_editor/label_layer = Слой: +tools/interactive_editor/label_bg_preview = Превью задника: +tools/interactive_editor/label_collider_type = Тип коллайдера: +tools/interactive_editor/label_rect_params = Прямоугольник (норм. 0-1): +tools/interactive_editor/label_x = x: +tools/interactive_editor/label_y = y: +tools/interactive_editor/label_w = ш: +tools/interactive_editor/label_h = в: +tools/interactive_editor/label_circle_params = Окружность (норм. 0-1): +tools/interactive_editor/label_cx = cx: +tools/interactive_editor/label_cy = cy: +tools/interactive_editor/label_r = r: +tools/interactive_editor/label_polygon_vertices = Вершины: +tools/interactive_editor/btn_add_vertex = + Добавить вершину +tools/interactive_editor/label_highlight_color = Цвет подсветки: +tools/interactive_editor/label_highlight_scale = Масштаб подсветки: +tools/interactive_editor/label_cursor = Курсор: +tools/interactive_editor/cursor_none = (нет) +tools/interactive_editor/label_onclick_scenario = Сценарий по клику: +tools/interactive_editor/label_onclick_commands = Команды по клику: +tools/interactive_editor/label_hover_enter = OnHoverEnter DSL: +tools/interactive_editor/label_hover_leave = OnHoverLeave DSL: diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..0f9ee7e --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "tools", + "name": "Tools", + "version": "1.0.0", + "author": "ct", + "description": "Development tools", + "sources": [ + "src/**/*.cs" + ], + "depends": ["core>=1"] +} diff --git a/scenes/scenario_file_tree_panel.tscn b/scenes/scenario_file_tree_panel.tscn new file mode 100644 index 0000000..d940b45 --- /dev/null +++ b/scenes/scenario_file_tree_panel.tscn @@ -0,0 +1,12 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="src/ScenarioEditor/ScenarioFileTreePanel.cs" id="1_script"] + +[node name="ScenarioFileTreePanel" type="VBoxContainer" script=ExtResource("1_script")] +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="FileTree" type="Tree" parent="."] +size_flags_horizontal = 3 +size_flags_vertical = 3 +hide_root = true diff --git a/scenes/scenario_info_panel.tscn b/scenes/scenario_info_panel.tscn new file mode 100644 index 0000000..a9ba3f5 --- /dev/null +++ b/scenes/scenario_info_panel.tscn @@ -0,0 +1,47 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="src/ScenarioEditor/ScenarioInfoPanel.cs" id="1_script"] + +[node name="ScenarioInfoPanel" type="VBoxContainer" script=ExtResource("1_script")] +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/separation = 6 + +[node name="Scroll" type="ScrollContainer" parent="."] +size_flags_vertical = 3 +horizontal_scroll_mode = 3 + +[node name="VBox" type="VBoxContainer" parent="Scroll"] +theme_override_constants/separation = 8 + +[node name="HintScene" type="Label" parent="Scroll/VBox"] + +[node name="InfoScene" type="Label" parent="Scroll/VBox"] +text = "-" + +[node name="HintPriority" type="Label" parent="Scroll/VBox"] + +[node name="InfoPriority" type="Label" parent="Scroll/VBox"] +text = "-" + +[node name="HintCondition" type="Label" parent="Scroll/VBox"] + +[node name="InfoCondition" type="Label" parent="Scroll/VBox"] +text = "-" + +[node name="HSep" type="HSeparator" parent="Scroll/VBox"] + +[node name="HintSwitchTargets" type="Label" parent="Scroll/VBox"] + +[node name="InfoSwitchTargets" type="Label" parent="Scroll/VBox"] +text = "-" + +[node name="HintLocaleKeys" type="Label" parent="Scroll/VBox"] + +[node name="InfoLocaleKeys" type="Label" parent="Scroll/VBox"] +text = "-" + +[node name="HintQuestRefs" type="Label" parent="Scroll/VBox"] + +[node name="InfoQuestRefs" type="Label" parent="Scroll/VBox"] +text = "-" diff --git a/scenes/scenario_thumbnail_panel.tscn b/scenes/scenario_thumbnail_panel.tscn new file mode 100644 index 0000000..e8a580f --- /dev/null +++ b/scenes/scenario_thumbnail_panel.tscn @@ -0,0 +1,10 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="src/ScenarioEditor/ScenarioThumbnailPanel.cs" id="1_script"] + +[node name="ScenarioThumbnailPanel" type="ScrollContainer" script=ExtResource("1_script")] +custom_minimum_size = Vector2(0, 110) +horizontal_scroll_mode = 1 +vertical_scroll_mode = 3 + +[node name="ThumbContainer" type="HBoxContainer" parent="."] diff --git a/src/InteractiveEditor/InteractiveEditorController.cs b/src/InteractiveEditor/InteractiveEditorController.cs new file mode 100644 index 0000000..1906a27 --- /dev/null +++ b/src/InteractiveEditor/InteractiveEditorController.cs @@ -0,0 +1,244 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Cthangover.Core.Interactive; +using Godot; + +namespace Cthangover.Tools.InteractiveEditor +{ + /// + /// Data model for the interactive editor. Holds interactive definition + /// fields, manages collider vertex list for polygon editing, and produces + /// the output JSON. + /// + public class InteractiveEditorController + { + /// Unique identifier for the interactive object resource. + public string Id { get; set; } = "new_interactive"; + /// Texture key mapped to a sprite resource by the resource system. + public string Texture { get; set; } = ""; + /// Render layer: "foreground", "background", or "ui". + public string Layer { get; set; } = "foreground"; + /// Z-index for draw order within the layer. + public int ZIndex { get; set; } + /// Whether the interactive is initially enabled. + public bool Enabled { get; set; } = true; + /// Whether the interactive is visible. + public bool Visible { get; set; } = true; + /// Mouse cursor override name, e.g. "PointingHand". Empty for default. + public string Cursor { get; set; } = ""; + /// Collider shape type: "rect", "circle", or "polygon". + public string HitType { get; set; } = "rect"; + + /// Normalised X position of the rect collider (0–1). + public float RectX { get; set; } + /// Normalised Y position of the rect collider (0–1). + public float RectY { get; set; } + /// Normalised width of the rect collider (0–1). + public float RectW { get; set; } = 0.1f; + /// Normalised height of the rect collider (0–1). + public float RectH { get; set; } = 0.1f; + + /// Normalised X centre of the circle collider (0–1). + public float CircleX { get; set; } = 0.5f; + /// Normalised Y centre of the circle collider (0–1). + public float CircleY { get; set; } = 0.5f; + /// Normalised radius of the circle collider (0–1). + public float CircleRadius { get; set; } = 0.05f; + + /// List of normalised (0–1) vertices defining the polygon collider. + public List PolygonVertices { get; set; } = new(); + + /// Hex colour string (e.g. "#FFFF0033") for the highlight overlay. + public string HighlightColorHex { get; set; } = "#FFFF0033"; + /// Scale multiplier applied to the sprite when highlighted. + public float HighlightScale { get; set; } = 1.02f; + + /// Scenario file path executed on click. + public string OnClickScenario { get; set; } = ""; + /// Inline DSL commands executed on click, one per line. + public string OnClickCommands { get; set; } = ""; + /// DSL commands executed when the mouse enters the interactive area. + public string OnHoverEnter { get; set; } = ""; + /// DSL commands executed when the mouse leaves the interactive area. + public string OnHoverLeave { get; set; } = ""; + + /// Builds an InteractiveDefinition from current state. + public InteractiveDefinition ToDefinition() + { + var hitArea = new HitAreaDefinition { Type = HitType }; + + switch (HitType) + { + case "rect": + hitArea.X = RectX; + hitArea.Y = RectY; + hitArea.Width = RectW; + hitArea.Height = RectH; + break; + case "circle": + hitArea.X = CircleX; + hitArea.Y = CircleY; + hitArea.Radius = CircleRadius; + break; + case "polygon": + hitArea.Vertices = PolygonVertices.ToArray(); + break; + } + + var clickAction = new ClickAction(); + + if (!string.IsNullOrWhiteSpace(OnClickScenario)) + clickAction.Scenario = OnClickScenario.Trim(); + + if (!string.IsNullOrWhiteSpace(OnClickCommands)) + { + var lines = OnClickCommands.Split('\n', System.StringSplitOptions.RemoveEmptyEntries); + var cleanLines = new List(); + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (!string.IsNullOrEmpty(trimmed)) + cleanLines.Add(trimmed); + } + if (cleanLines.Count > 0) + clickAction.Commands = cleanLines.ToArray(); + } + + return new InteractiveDefinition + { + ID = Id, + Texture = string.IsNullOrWhiteSpace(Texture) ? null : Texture.Trim(), + Layer = Layer, + ZIndex = ZIndex, + Enabled = Enabled, + Visible = Visible, + Cursor = string.IsNullOrWhiteSpace(Cursor) ? null : Cursor.Trim(), + HitArea = hitArea, + Highlight = new HighlightDefinition + { + ColorHex = HighlightColorHex, + Scale = HighlightScale + }, + Actions = new InteractiveActionDefinition + { + OnClick = (clickAction.Scenario != null || clickAction.Commands != null) ? clickAction : null, + OnHoverEnter = string.IsNullOrWhiteSpace(OnHoverEnter) ? null : OnHoverEnter.Trim(), + OnHoverLeave = string.IsNullOrWhiteSpace(OnHoverLeave) ? null : OnHoverLeave.Trim() + } + }; + } + + /// Loads state from an InteractiveDefinition. + public void FromDefinition(InteractiveDefinition def) + { + if (def == null) return; + + Id = def.ID ?? "new_interactive"; + Texture = def.Texture ?? ""; + Layer = def.Layer ?? "foreground"; + ZIndex = def.ZIndex; + Enabled = def.Enabled; + Visible = def.Visible; + Cursor = def.Cursor ?? ""; + + if (def.HitArea != null) + { + HitType = def.HitArea.Type ?? "rect"; + RectX = def.HitArea.X; + RectY = def.HitArea.Y; + RectW = def.HitArea.Width; + RectH = def.HitArea.Height; + CircleX = def.HitArea.X; + CircleY = def.HitArea.Y; + CircleRadius = def.HitArea.Radius; + PolygonVertices = def.HitArea.Vertices != null + ? new List(def.HitArea.Vertices) + : new List(); + } + else + { + HitType = "rect"; + } + + if (def.Highlight != null) + { + HighlightColorHex = def.Highlight.ColorHex ?? "#FFFF0033"; + HighlightScale = def.Highlight.Scale; + } + + OnClickScenario = def.Actions?.OnClick?.Scenario ?? ""; + OnClickCommands = def.Actions?.OnClick?.Commands != null + ? string.Join("\n", def.Actions.OnClick.Commands) + : ""; + OnHoverEnter = def.Actions?.OnHoverEnter ?? ""; + OnHoverLeave = def.Actions?.OnHoverLeave ?? ""; + } + + /// Serializes the definition to formatted JSON (FileData envelope). + public string ToJson() + { + var def = ToDefinition(); + var wrapper = new + { + Items = new[] { def } + }; + + var options = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + return JsonSerializer.Serialize(wrapper, options); + } + + /// Parses JSON back into the controller. Returns false on failure. + public bool FromJson(string json) + { + try + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip }; + var wrapper = JsonSerializer.Deserialize(json, options); + if (wrapper?.Items != null && wrapper.Items.Count > 0) + { + FromDefinition(wrapper.Items[0]); + return true; + } + return false; + } + catch + { + return false; + } + } + + private class FileDataWrapper + { + public List Items { get; set; } + } + + /// Updates a polygon vertex at the given index. + public void SetPolygonVertex(int index, Vector2 value) + { + while (PolygonVertices.Count <= index) + PolygonVertices.Add(Vector2.Zero); + PolygonVertices[index] = value; + } + + /// Removes a polygon vertex at the given index. + public void RemovePolygonVertex(int index) + { + if (index >= 0 && index < PolygonVertices.Count) + PolygonVertices.RemoveAt(index); + } + + /// Adds a new polygon vertex at the default position (center of viewport). + public void AddPolygonVertex() + { + var offset = PolygonVertices.Count * 0.04f; + PolygonVertices.Add(new Vector2(0.5f + offset, 0.5f + offset)); + } + } +} diff --git a/src/InteractiveEditor/InteractiveEditorHandle.cs b/src/InteractiveEditor/InteractiveEditorHandle.cs new file mode 100644 index 0000000..b1f31e8 --- /dev/null +++ b/src/InteractiveEditor/InteractiveEditorHandle.cs @@ -0,0 +1,64 @@ +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Tools.InteractiveEditor +{ + /// + /// Draggable 16x16 handle representing a collider vertex on the preview area. + /// Used for rect corners, circle radius marker, and polygon vertices. + /// Subscribes to the GuiInput C# event rather than overriding _GuiInput + /// for reliable event delivery in mod assemblies. + /// + public partial class InteractiveEditorHandle : ColorRect + { + /// Index of the vertex this handle represents (0-based). + public int VertexIndex; + /// Invoked during drag with the centre position of the handle in global coordinates. + public System.Action DragUpdate; + /// Invoked when the handle is clicked (mouse down). + public System.Action Clicked; + /// Invoked when the drag ends (mouse up). Used to sync sidebar values. + public System.Action DragEnd; + + private bool _dragging; + private Vector2 _dragOffset; + + /// Creates a handle with the given colour, size 16×16, and index for vertex identification. + public InteractiveEditorHandle(Color color, int index) + { + VertexIndex = index; + Size = new Vector2(16, 16); + Color = color; + MouseFilter = Control.MouseFilterEnum.Stop; + MouseDefaultCursorShape = Control.CursorShape.PointingHand; + GuiInput += OnGuiInput; + } + + private void OnGuiInput(InputEvent @event) + { + if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left) + { + if (mb.Pressed) + { + _dragging = true; + _dragOffset = GetGlobalMousePosition() - GlobalPosition; + GameLogger.Log("INTERACTIVE_HANDLE", $"drag start idx={VertexIndex} pos={GlobalPosition}"); + Clicked?.Invoke(); + } + else + { + _dragging = false; + GameLogger.Log("INTERACTIVE_HANDLE", $"drag end idx={VertexIndex}"); + DragEnd?.Invoke(); + } + AcceptEvent(); + } + else if (@event is InputEventMouseMotion && _dragging) + { + var center = GlobalPosition + Size / 2; + GlobalPosition = GetGlobalMousePosition() - _dragOffset; + DragUpdate?.Invoke(center); + } + } + } +} diff --git a/src/InteractiveEditor/InteractiveEditorToolProvider.cs b/src/InteractiveEditor/InteractiveEditorToolProvider.cs new file mode 100644 index 0000000..5dca33e --- /dev/null +++ b/src/InteractiveEditor/InteractiveEditorToolProvider.cs @@ -0,0 +1,36 @@ +using Cthangover.Core.UI.Tool; +using Godot; + +namespace Cthangover.Tools.InteractiveEditor +{ + /// + /// Registers the interactive editor as a tool window in the developer toolbox. + /// Implements so the tool system can instantiate + /// on demand. + /// + public class InteractiveEditorToolProvider : IToolProvider + { + /// Unique tool identifier referenced by the toolbox UI. + public string Id => "interactive_editor"; + /// Translation key for the tool's display name. + public string LocaleKey => "tools/interactive_editor/title"; + /// Creates the editor window instance. + public Window CreateWindow() => new InteractiveEditorWindow(); + } + + /// + /// Provides a button in the developer toolbox that opens the interactive editor. + /// Controls visibility via the policy. + /// + public class InteractiveEditorToolBoxButton : IToolBoxButton + { + /// Matches . + public string ToolId => "interactive_editor"; + /// No custom icon — uses the tool title text. + public string IconPath => ""; + /// Translation key for the button label. + public string LocaleKey => "tools/interactive_editor/title"; + /// Always visible in the toolbox. + public bool IsVisible() => true; + } +} diff --git a/src/InteractiveEditor/InteractiveEditorWindow.cs b/src/InteractiveEditor/InteractiveEditorWindow.cs new file mode 100644 index 0000000..61bcc3f --- /dev/null +++ b/src/InteractiveEditor/InteractiveEditorWindow.cs @@ -0,0 +1,853 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Interactive; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.UI.Tool; +using Cthangover.Core.Utils; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Tools.InteractiveEditor +{ + public partial class InteractiveEditorWindow : ToolWindow + { + private static readonly Color _rectColor = new(0f, 1f, 0f, 0.6f); + private static readonly Color _circleColor = new(0f, 0.5f, 1f, 0.6f); + private static readonly Color _vertexColor = new(1f, 0.8f, 0f, 0.8f); + private static readonly Color _colliderOverlayColor = new(0f, 1f, 0f, 0.15f); + private static readonly Color _colliderCircleOverlayColor = new(0f, 0.5f, 1f, 0.15f); + private static readonly Color _colliderPolyOverlayColor = new(1f, 0.5f, 0f, 0.15f); + private static readonly Color _colliderPolyOutlineColor = new(1f, 0.5f, 0f, 0.6f); + + private InteractiveEditorController _controller = new(); + + private Control _previewContainer; + private TextureRect _preview; + private Control _colliderOverlay; + private string _currentBgId; + + private readonly List _handles = new(); + + private TextEdit _jsonEdit; + + private LineEdit _idEdit, _textureEdit; + private OptionButton _layerSelect; + private OptionButton _hitTypeSelect; + private ItemList _bgList; + + private LineEdit _rectX, _rectY, _rectW, _rectH; + private LineEdit _circleX, _circleY, _circleR; + private VBoxContainer _rectParams, _circleParams, _polyParams; + + private ColorPickerButton _highlightColorBtn; + private LineEdit _highlightScaleEdit; + + private LineEdit _clickScenario, _hoverEnter, _hoverLeave; + private TextEdit _clickCommands; + + private VBoxContainer _vertexList; + private Button _addVertexBtn; + + private bool _suppressingSidebarEvents; + private int _draggingHandleIndex = -1; + + public InteractiveEditorWindow() : base("tools/interactive_editor/title") + { + Title = "Interactive Editor"; + Size = new Vector2I(1280, 820); + + var outer = CreateFillContainer(); + AddChild(outer); + + var toolbar = CreateToolbar(); + outer.AddChild(toolbar); + + var loadBtn = new Button { Text = Tr("tools/interactive_editor/btn_load_json") }; + loadBtn.Pressed += OnLoadJson; + toolbar.AddChild(loadBtn); + + var saveBtn = new Button { Text = Tr("tools/interactive_editor/btn_save") }; + saveBtn.Pressed += OnSave; + toolbar.AddChild(saveBtn); + + var importDefBtn = new Button { Text = Tr("tools/interactive_editor/btn_import_mod") }; + importDefBtn.Pressed += OnImportFromMod; + toolbar.AddChild(importDefBtn); + + var mainHBox = new HBoxContainer(); + mainHBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + mainHBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + outer.AddChild(mainHBox); + + var sidebarVBox = new VBoxContainer(); + var sidebar = CreateSidebar(sidebarVBox, 290); + mainHBox.AddChild(sidebar); + + BuildSidebar(sidebarVBox); + + _previewContainer = new Control + { + MouseFilter = Control.MouseFilterEnum.Stop, + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill + }; + mainHBox.AddChild(_previewContainer); + + _preview = new TextureRect + { + ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize, + StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered, + MouseFilter = Control.MouseFilterEnum.Ignore, + AnchorRight = 1f, + AnchorBottom = 1f + }; + _previewContainer.AddChild(_preview); + + _colliderOverlay = new Control + { + MouseFilter = Control.MouseFilterEnum.Ignore, + Visible = false + }; + _colliderOverlay.Connect("draw", Callable.From((Action)DrawColliderShape)); + _previewContainer.AddChild(_colliderOverlay); + + _previewContainer.Resized += () => + { + RebuildHandles(); + UpdateColliderOverlay(); + }; + + _jsonEdit = new TextEdit { CustomMinimumSize = new Vector2(0, 150) }; + _jsonEdit.AddThemeFontOverride("font", GetMonospaceFont()); + _jsonEdit.TextChanged += () => SetDirty(); + outer.AddChild(_jsonEdit); + + RefreshJson(); + } + + private void BuildSidebar(VBoxContainer sidebar) + { + sidebar.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + + var tabs = new TabContainer(); + tabs.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + sidebar.AddChild(tabs); + + var bgTab = new VBoxContainer(); + tabs.AddChild(bgTab); + tabs.SetTabTitle(0, Tr("tools/interactive_editor/tab_background")); + BuildBackgroundTab(bgTab); + + var interactiveTab = new VBoxContainer(); + tabs.AddChild(interactiveTab); + tabs.SetTabTitle(1, Tr("tools/interactive_editor/tab_interactive")); + BuildInteractiveTab(interactiveTab); + + var scenarioTab = new VBoxContainer(); + tabs.AddChild(scenarioTab); + tabs.SetTabTitle(2, Tr("tools/interactive_editor/tab_scenario")); + BuildScenarioTab(scenarioTab); + } + + private void BuildBackgroundTab(VBoxContainer parent) + { + AddLabel(parent, "tools/interactive_editor/label_id"); + _idEdit = AddLineEdit(parent, "my_interactive"); + _idEdit.TextChanged += OnParamChanged; + + AddLabel(parent, "tools/interactive_editor/label_texture_key"); + _textureEdit = AddLineEdit(parent, "lamp"); + _textureEdit.TextChanged += OnParamChanged; + + AddLabel(parent, "tools/interactive_editor/label_layer"); + _layerSelect = new OptionButton(); + _layerSelect.AddItem("foreground"); + _layerSelect.AddItem("background"); + _layerSelect.AddItem("ui"); + _layerSelect.ItemSelected += _ => OnParamChanged(); + parent.AddChild(_layerSelect); + + AddLabel(parent, "tools/interactive_editor/label_bg_preview"); + _bgList = new ItemList(); + _bgList.CustomMinimumSize = new Vector2(0, 100); + _bgList.ItemSelected += OnBgSelected; + parent.AddChild(_bgList); + LoadBackgroundList(); + } + + private void BuildInteractiveTab(VBoxContainer parent) + { + AddLabel(parent, "tools/interactive_editor/label_collider_type"); + _hitTypeSelect = new OptionButton(); + _hitTypeSelect.AddItem("rect"); + _hitTypeSelect.AddItem("circle"); + _hitTypeSelect.AddItem("polygon"); + _hitTypeSelect.ItemSelected += OnHitTypeChanged; + parent.AddChild(_hitTypeSelect); + + _rectParams = new VBoxContainer(); + AddLabel(_rectParams, "tools/interactive_editor/label_rect_params"); + _rectX = AddLabeledLine(_rectParams, "tools/interactive_editor/label_x"); _rectX.TextChanged += OnParamChanged; + _rectY = AddLabeledLine(_rectParams, "tools/interactive_editor/label_y"); _rectY.TextChanged += OnParamChanged; + _rectW = AddLabeledLine(_rectParams, "tools/interactive_editor/label_w"); _rectW.TextChanged += OnParamChanged; + _rectH = AddLabeledLine(_rectParams, "tools/interactive_editor/label_h"); _rectH.TextChanged += OnParamChanged; + parent.AddChild(_rectParams); + + _circleParams = new VBoxContainer(); + AddLabel(_circleParams, "tools/interactive_editor/label_circle_params"); + _circleX = AddLabeledLine(_circleParams, "tools/interactive_editor/label_cx"); _circleX.TextChanged += OnParamChanged; + _circleY = AddLabeledLine(_circleParams, "tools/interactive_editor/label_cy"); _circleY.TextChanged += OnParamChanged; + _circleR = AddLabeledLine(_circleParams, "tools/interactive_editor/label_r"); _circleR.TextChanged += OnParamChanged; + _circleParams.Visible = false; + parent.AddChild(_circleParams); + + _polyParams = new VBoxContainer(); + AddLabel(_polyParams, "tools/interactive_editor/label_polygon_vertices"); + _addVertexBtn = new Button { Text = Tr("tools/interactive_editor/btn_add_vertex") }; + _addVertexBtn.Pressed += OnAddVertex; + _polyParams.AddChild(_addVertexBtn); + _vertexList = new VBoxContainer(); + _polyParams.AddChild(_vertexList); + _polyParams.Visible = false; + parent.AddChild(_polyParams); + + AddLabel(parent, "tools/interactive_editor/label_highlight_color"); + _highlightColorBtn = new ColorPickerButton + { + Color = new Color(1f, 1f, 0f, 0.2f), + CustomMinimumSize = new Vector2(40, 30) + }; + _highlightColorBtn.ColorChanged += _ => OnParamChanged(); + parent.AddChild(_highlightColorBtn); + + AddLabel(parent, "tools/interactive_editor/label_highlight_scale"); + _highlightScaleEdit = AddLineEdit(parent, "1.02"); + _highlightScaleEdit.TextChanged += OnParamChanged; + } + + private void BuildScenarioTab(VBoxContainer parent) + { + AddLabel(parent, "tools/interactive_editor/label_cursor"); + var cursorSelect = new OptionButton(); + cursorSelect.AddItem(Tr("tools/interactive_editor/cursor_none")); + cursorSelect.AddItem("PointingHand"); + cursorSelect.ItemSelected += idx => + { + _controller.Cursor = idx == 0 ? "" : "PointingHand"; + OnParamChanged(); + }; + parent.AddChild(cursorSelect); + + AddLabel(parent, "tools/interactive_editor/label_onclick_scenario"); + _clickScenario = AddLineEdit(parent, "scenarios/my_click.scenario"); + _clickScenario.TextChanged += OnParamChanged; + + AddLabel(parent, "tools/interactive_editor/label_onclick_commands"); + _clickCommands = new TextEdit { CustomMinimumSize = new Vector2(0, 50) }; + _clickCommands.TextChanged += OnParamChanged; + parent.AddChild(_clickCommands); + + AddLabel(parent, "tools/interactive_editor/label_hover_enter"); + _hoverEnter = AddLineEdit(parent, "set cursor_hint=click_me"); + _hoverEnter.TextChanged += OnParamChanged; + + AddLabel(parent, "tools/interactive_editor/label_hover_leave"); + _hoverLeave = AddLineEdit(parent, "set cursor_hint="); + _hoverLeave.TextChanged += OnParamChanged; + } + + private static string Tr(string key) => TranslationServer.Translate(key); + + private void AddLabel(Control parent, string locKey) + { + var label = new Label { Text = Tr(locKey) }; + label.AddThemeColorOverride("font_color", new Color(0.7f, 0.7f, 0.7f)); + parent.AddChild(label); + } + + private LineEdit AddLineEdit(Control parent, string placeholder) + { + var edit = new LineEdit { PlaceholderText = placeholder }; + parent.AddChild(edit); + return edit; + } + + private LineEdit AddLabeledLine(Control parent, string locKey) + { + var hbox = new HBoxContainer(); + var label = new Label { Text = Tr(locKey) }; + label.CustomMinimumSize = new Vector2(25, 0); + hbox.AddChild(label); + var edit = new LineEdit(); + edit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + hbox.AddChild(edit); + parent.AddChild(hbox); + return edit; + } + + private void LoadBackgroundList() + { + _bgList.Clear(); + var ids = ModResourceService.GetBackgroundIds(); + foreach (var id in ids) + _bgList.AddItem(id); + } + + private void OnBgSelected(long index) + { + var id = _bgList.GetItemText((int)index); + _currentBgId = id; + var tex = ModResourceService.LoadBackgroundTexture(id); + if (tex != null) + { + _preview.Texture = tex; + } + OnParamChanged(); + } + + private void OnHitTypeChanged(long index) + { + _controller.HitType = _hitTypeSelect.GetItemText((int)index); + _rectParams.Visible = _controller.HitType == "rect"; + _circleParams.Visible = _controller.HitType == "circle"; + _polyParams.Visible = _controller.HitType == "polygon"; + OnParamChanged(); + } + + private void OnAddVertex() + { + _controller.AddPolygonVertex(); + OnParamChanged(); + } + + private void OnParamChanged() + { + if (_suppressingSidebarEvents) + { + GameLogger.Log("INTERACTIVE_EDITOR", "OnParamChanged suppressed"); + return; + } + + GameLogger.Log("INTERACTIVE_EDITOR", $"OnParamChanged hitType={_controller.HitType} bgSize=({_previewContainer.Size.X:F0}x{_previewContainer.Size.Y:F0})"); + ReadSidebarToController(); + RebuildHandles(); + UpdateColliderOverlay(); + RefreshJson(); + SetDirty(); + UpdateVertexList(); + } + + private void OnDragUpdate() + { + UpdateNonDraggedHandles(); + UpdateColliderOverlay(); + RefreshJson(); + SetDirty(); + } + + private void ReadSidebarToController() + { + _controller.Id = string.IsNullOrWhiteSpace(_idEdit.Text) ? "new_interactive" : _idEdit.Text.Trim(); + _controller.Texture = _textureEdit.Text.Trim(); + _controller.Layer = _layerSelect.GetItemText(_layerSelect.Selected); + + if (_controller.HitType == "rect") + { + float.TryParse(_rectX.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v); + _controller.RectX = v; + float.TryParse(_rectY.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.RectY = v; + float.TryParse(_rectW.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.RectW = v; + float.TryParse(_rectH.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.RectH = v; + } + else if (_controller.HitType == "circle") + { + float.TryParse(_circleX.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v); + _controller.CircleX = v; + float.TryParse(_circleY.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.CircleY = v; + float.TryParse(_circleR.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.CircleRadius = v; + } + + _controller.HighlightColorHex = "#" + _highlightColorBtn.Color.ToHtml(false); + float.TryParse(_highlightScaleEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var scale); + _controller.HighlightScale = scale; + + _controller.OnClickScenario = _clickScenario.Text.Trim(); + _controller.OnClickCommands = _clickCommands.Text.Trim(); + _controller.OnHoverEnter = _hoverEnter.Text.Trim(); + _controller.OnHoverLeave = _hoverLeave.Text.Trim(); + } + + private Rect2 GetBackgroundRect() + { + var cw = _previewContainer.Size.X; + var ch = _previewContainer.Size.Y; + if (cw <= 0 || ch <= 0) return new Rect2(); + + var texSize = new Vector2(1920, 1024); + var tex = _preview.Texture; + if (tex != null) + { + var ts = tex.GetSize(); + if (ts.X > 0 && ts.Y > 0) texSize = ts; + } + + var scale = Mathf.Min(cw / texSize.X, ch / texSize.Y); + var rw = texSize.X * scale; + var rh = texSize.Y * scale; + var ox = (cw - rw) / 2f; + var oy = (ch - rh) / 2f; + return new Rect2(ox, oy, rw, rh); + } + + private Vector2 NormToPixel(Vector2 norm) + { + var bg = GetBackgroundRect(); + return bg.Position + norm * bg.Size; + } + + private Vector2 PixelToNorm(Vector2 pixel) + { + var bg = GetBackgroundRect(); + if (bg.Size.X <= 0 || bg.Size.Y <= 0) return Vector2.Zero; + return new Vector2( + (pixel.X - bg.Position.X) / bg.Size.X, + (pixel.Y - bg.Position.Y) / bg.Size.Y); + } + + private void WriteControllerToSidebar() + { + _suppressingSidebarEvents = true; + try + { + _idEdit.Text = _controller.Id; + _textureEdit.Text = _controller.Texture; + + for (int i = 0; i < _layerSelect.ItemCount; i++) + if (_layerSelect.GetItemText(i) == _controller.Layer) + _layerSelect.Select(i); + + for (int i = 0; i < _hitTypeSelect.ItemCount; i++) + if (_hitTypeSelect.GetItemText(i) == _controller.HitType) + _hitTypeSelect.Select(i); + + _rectX.Text = _controller.RectX.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); + _rectY.Text = _controller.RectY.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); + _rectW.Text = _controller.RectW.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); + _rectH.Text = _controller.RectH.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); + + _circleX.Text = _controller.CircleX.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); + _circleY.Text = _controller.CircleY.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); + _circleR.Text = _controller.CircleRadius.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); + + _rectParams.Visible = _controller.HitType == "rect"; + _circleParams.Visible = _controller.HitType == "circle"; + _polyParams.Visible = _controller.HitType == "polygon"; + + _highlightColorBtn.Color = new Color(_controller.HighlightColorHex); + _highlightScaleEdit.Text = _controller.HighlightScale.ToString("F2", System.Globalization.CultureInfo.InvariantCulture); + + _clickScenario.Text = _controller.OnClickScenario; + _clickCommands.Text = _controller.OnClickCommands; + _hoverEnter.Text = _controller.OnHoverEnter; + _hoverLeave.Text = _controller.OnHoverLeave; + } + finally + { + _suppressingSidebarEvents = false; + } + } + + private void RebuildHandles() + { + foreach (var h in _handles) + h.QueueFree(); + _handles.Clear(); + + var bg = GetBackgroundRect(); + if (bg.Size.X <= 0 || bg.Size.Y <= 0) + { + GameLogger.Log("INTERACTIVE_EDITOR", "RebuildHandles skipped: bg size <= 0"); + return; + } + + GameLogger.Log("INTERACTIVE_EDITOR", $"RebuildHandles hitType={_controller.HitType} bg=({bg.Position.X:F0},{bg.Position.Y:F0} {bg.Size.X:F0}x{bg.Size.Y:F0})"); + + var globalTopLeft = _previewContainer.GlobalPosition; + + switch (_controller.HitType) + { + case "rect": + { + var topLeft = NormToPixel(new Vector2(_controller.RectX, _controller.RectY)); + var bottomRight = NormToPixel(new Vector2( + _controller.RectX + _controller.RectW, + _controller.RectY + _controller.RectH)); + + AddHandleAt(globalTopLeft + topLeft, _rectColor, 0, pos => + { + var norm = PixelToNorm(pos - globalTopLeft); + _controller.RectX = Mathf.Clamp(norm.X, 0f, 1f); + _controller.RectY = Mathf.Clamp(norm.Y, 0f, 1f); + _draggingHandleIndex = 0; + OnDragUpdate(); + }); + AddHandleAt(globalTopLeft + bottomRight, _rectColor, 1, pos => + { + var norm = PixelToNorm(pos - globalTopLeft); + _controller.RectW = Mathf.Max(0.01f, norm.X - _controller.RectX); + _controller.RectH = Mathf.Max(0.01f, norm.Y - _controller.RectY); + _draggingHandleIndex = 1; + OnDragUpdate(); + }); + break; + } + case "circle": + { + var refDim = Mathf.Min(bg.Size.X, bg.Size.Y); + var center = NormToPixel(new Vector2(_controller.CircleX, _controller.CircleY)); + var radiusPx = _controller.CircleRadius * refDim; + + AddHandleAt(globalTopLeft + center, _circleColor, 0, pos => + { + var norm = PixelToNorm(pos - globalTopLeft); + _controller.CircleX = Mathf.Clamp(norm.X, 0f, 1f); + _controller.CircleY = Mathf.Clamp(norm.Y, 0f, 1f); + _draggingHandleIndex = 0; + OnDragUpdate(); + }); + AddHandleAt(globalTopLeft + new Vector2(center.X + radiusPx, center.Y), _circleColor, 1, pos => + { + var local = pos - globalTopLeft; + var centerPx = NormToPixel(new Vector2(_controller.CircleX, _controller.CircleY)); + var dist = (local - centerPx).Length(); + _controller.CircleRadius = Mathf.Max(0.005f, dist / refDim); + _draggingHandleIndex = 1; + OnDragUpdate(); + }); + break; + } + case "polygon": + { + for (int i = 0; i < _controller.PolygonVertices.Count; i++) + { + var idx = i; + var v = _controller.PolygonVertices[i]; + AddHandleAt(globalTopLeft + NormToPixel(v), _vertexColor, i, pos => + { + var norm = PixelToNorm(pos - globalTopLeft); + _controller.SetPolygonVertex(idx, new Vector2( + Mathf.Clamp(norm.X, 0f, 1f), + Mathf.Clamp(norm.Y, 0f, 1f))); + _draggingHandleIndex = idx; + OnDragUpdate(); + }); + } + break; + } + } + } + + private void UpdateNonDraggedHandles() + { + var bg = GetBackgroundRect(); + if (bg.Size.X <= 0 || bg.Size.Y <= 0) return; + + var globalTopLeft = _previewContainer.GlobalPosition; + + for (int i = 0; i < _handles.Count; i++) + { + if (i == _draggingHandleIndex) continue; + var pos = GetHandleNormPosition(i); + var pixelPos = globalTopLeft + NormToPixel(pos); + var h = _handles[i]; + if (h != null && !h.IsQueuedForDeletion()) + h.GlobalPosition = pixelPos - h.Size / 2; + } + } + + private Vector2 GetHandleNormPosition(int index) + { + switch (_controller.HitType) + { + case "rect": + if (index == 0) + return new Vector2(_controller.RectX, _controller.RectY); + return new Vector2(_controller.RectX + _controller.RectW, _controller.RectY + _controller.RectH); + case "circle": + if (index == 0) + return new Vector2(_controller.CircleX, _controller.CircleY); + return new Vector2(_controller.CircleX + _controller.CircleRadius, _controller.CircleY); + case "polygon": + if (index >= 0 && index < _controller.PolygonVertices.Count) + return _controller.PolygonVertices[index]; + return Vector2.Zero; + default: + return Vector2.Zero; + } + } + + private void UpdateColliderOverlay() + { + var bg = GetBackgroundRect(); + if (bg.Size.X <= 0 || bg.Size.Y <= 0) + { + _colliderOverlay.Visible = false; + return; + } + + _colliderOverlay.Visible = true; + _colliderOverlay.Position = bg.Position; + _colliderOverlay.Size = bg.Size; + _colliderOverlay.QueueRedraw(); + } + + private void DrawColliderShape() + { + var size = _colliderOverlay.Size; + if (size.X <= 0 || size.Y <= 0) return; + + switch (_controller.HitType) + { + case "rect": + { + var pos = NormToPixelLocal(new Vector2(_controller.RectX, _controller.RectY)); + var rectSize = new Vector2(_controller.RectW * size.X, _controller.RectH * size.Y); + _colliderOverlay.DrawRect(new Rect2(pos, rectSize), _colliderOverlayColor); + break; + } + case "circle": + { + var rPx = _controller.CircleRadius * Mathf.Min(size.X, size.Y); + var center = NormToPixelLocal(new Vector2(_controller.CircleX, _controller.CircleY)); + _colliderOverlay.DrawCircle(center, rPx, _colliderCircleOverlayColor); + break; + } + case "polygon": + { + if (_controller.PolygonVertices.Count >= 3) + { + var points = new Vector2[_controller.PolygonVertices.Count]; + for (int i = 0; i < _controller.PolygonVertices.Count; i++) + points[i] = NormToPixelLocal(_controller.PolygonVertices[i]); + + if (PolygonArea(points) > 0.0001f) + _colliderOverlay.DrawColoredPolygon(points, _colliderPolyOverlayColor); + _colliderOverlay.DrawPolyline(points, _colliderPolyOutlineColor); + } + break; + } + } + } + + private Vector2 NormToPixelLocal(Vector2 norm) + { + return norm * _colliderOverlay.Size; + } + + private static float PolygonArea(Vector2[] points) + { + float area = 0; + for (int i = 0; i < points.Length; i++) + { + var j = (i + 1) % points.Length; + area += points[i].X * points[j].Y; + area -= points[j].X * points[i].Y; + } + return Mathf.Abs(area) * 0.5f; + } + + private void AddHandleAt(Vector2 globalPos, Color color, int index, Action onDrag) + { + var handle = new InteractiveEditorHandle(color, index); + handle.DragUpdate = center => + { + onDrag(center); + }; + handle.Clicked = () => + { + SelectHandle(index); + }; + handle.DragEnd = () => + { + GameLogger.Log("INTERACTIVE_EDITOR", $"DragEnd idx={index} syncing sidebar"); + _draggingHandleIndex = -1; + _suppressingSidebarEvents = true; + WriteControllerToSidebar(); + _suppressingSidebarEvents = false; + UpdateVertexList(); + }; + handle.MouseDefaultCursorShape = Control.CursorShape.PointingHand; + _previewContainer.AddChild(handle); + handle.GlobalPosition = globalPos - handle.Size / 2; + _handles.Add(handle); + } + + private void SelectHandle(int index) + { + if (_controller.HitType == "polygon" && index >= 0 && index < _controller.PolygonVertices.Count) + { + var v = _controller.PolygonVertices[index]; + GD.Print($"Selected vertex {index}: ({v.X:F3}, {v.Y:F3})"); + } + } + + private void UpdateVertexList() + { + for (int i = _vertexList.GetChildCount() - 1; i >= 0; i--) + _vertexList.GetChild(i).QueueFree(); + + if (_controller.HitType != "polygon") return; + + for (int i = 0; i < _controller.PolygonVertices.Count; i++) + { + var idx = i; + var v = _controller.PolygonVertices[i]; + var row = new HBoxContainer(); + + var xEdit = new LineEdit(); + xEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + xEdit.Text = v.X.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); + xEdit.TextChanged += txt => + { + if (float.TryParse(xEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var val)) + _controller.SetPolygonVertex(idx, new Vector2(val, _controller.PolygonVertices[idx].Y)); + OnParamChanged(); + }; + row.AddChild(xEdit); + + var yEdit = new LineEdit(); + yEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + yEdit.Text = v.Y.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); + yEdit.TextChanged += txt => + { + if (float.TryParse(yEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var val)) + _controller.SetPolygonVertex(idx, new Vector2(_controller.PolygonVertices[idx].X, val)); + OnParamChanged(); + }; + row.AddChild(yEdit); + + var delBtn = new Button { Text = "X" }; + delBtn.CustomMinimumSize = new Vector2(30, 0); + delBtn.Pressed += () => + { + _controller.RemovePolygonVertex(idx); + OnParamChanged(); + }; + row.AddChild(delBtn); + + _vertexList.AddChild(row); + } + } + + private void RefreshJson() + { + _jsonEdit.Text = _controller.ToJson(); + } + + private void OnLoadJson() + { + var dialog = new FileDialog(); + dialog.FileMode = FileDialog.FileModeEnum.OpenFile; + dialog.AddFilter("*.json", "JSON Files"); + dialog.AddFilter("*.interactive", "Interactive Files"); + dialog.FileSelected += path => + { + var file = FileAccess.Open(path, FileAccess.ModeFlags.Read); + if (file != null) + { + var text = file.GetAsText(); + file.Close(); + if (_controller.FromJson(text)) + { + WriteControllerToSidebar(); + UpdateVertexList(); + RebuildHandles(); + UpdateColliderOverlay(); + RefreshJson(); + MarkClean(); + } + else + { + GD.PrintErr("Failed to parse JSON"); + } + } + dialog.QueueFree(); + }; + dialog.Canceled += () => dialog.QueueFree(); + AddChild(dialog); + dialog.PopupCentered(new Vector2I(800, 600)); + } + + private void OnImportFromMod() + { + var ids = Interactives.Collect(); + if (ids.Count == 0) + { + GD.Print("No interactive definitions found in mods"); + return; + } + + var dialog = new AcceptDialog { Title = Tr("tools/interactive_editor/dlg_select_def") }; + dialog.Size = new Vector2I(400, 300); + + var list = new ItemList(); + list.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + list.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + foreach (var kv in ids) + list.AddItem(kv.Key); + list.ItemActivated += idx => + { + var id = list.GetItemText((int)idx); + var def = InteractiveFactory.Instance.Get(id); + if (def != null) + { + _controller.FromDefinition(def); + WriteControllerToSidebar(); + UpdateVertexList(); + RebuildHandles(); + UpdateColliderOverlay(); + RefreshJson(); + MarkClean(); + } + dialog.QueueFree(); + }; + dialog.AddChild(list); + AddChild(dialog); + dialog.PopupCentered(); + } + + private void OnSave() + { + var dialog = new FileDialog(); + dialog.FileMode = FileDialog.FileModeEnum.SaveFile; + dialog.AddFilter("*.json", "JSON Files"); + dialog.FileSelected += path => + { + var json = _controller.ToJson(); + var file = FileAccess.Open(path, FileAccess.ModeFlags.Write); + if (file != null) + { + file.StoreString(json); + file.Close(); + MarkClean(); + GD.Print($"Saved to {path}"); + } + dialog.QueueFree(); + }; + dialog.Canceled += () => dialog.QueueFree(); + AddChild(dialog); + dialog.PopupCentered(new Vector2I(800, 600)); + } + + private void OnParamChanged(string _ = null) => OnParamChanged(); + + protected override void Cleanup() + { + foreach (var h in _handles) + h.QueueFree(); + _handles.Clear(); + } + } +} diff --git a/src/LightEditor/LightEditorController.cs b/src/LightEditor/LightEditorController.cs new file mode 100644 index 0000000..7c9c56d --- /dev/null +++ b/src/LightEditor/LightEditorController.cs @@ -0,0 +1,175 @@ +using System.Collections.Generic; +using System.Text.Json; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.UI.Lights; +using Godot; + +namespace Cthangover.Core.UI.Tool.LightEditor +{ + /// + /// Data controller for the light editor tool. Maintains a list of + /// objects and feeds up to 10 lights plus one + /// sentinel entry into the "timed_sprite" shader material for + /// real-time preview. Exports lights as JSON () + /// or as a scenario DSL snippet (). + /// Uses a dirty-flag pattern so shader uniform updates only occur when + /// the light list changes. + /// + public class LightEditorController + { + /// The editable list of light definitions. Capped at 10 for shader preview. + public List Lights { get; } = new(); + + private readonly Vector2[] previewPositions = new Vector2[11]; + private readonly Color[] previewColors = new Color[11]; + private readonly float[] previewRadii = new float[11]; + private readonly float[] previewInfluence = new float[11]; + + private bool dirty = true; + + /// Initialises preview arrays with out-of-bounds sentinel values. + public LightEditorController() + { + for (int i = 0; i < 11; i++) + { + previewPositions[i] = new Vector2(-1000f, -1000f); + previewRadii[i] = 0f; + previewInfluence[i] = 0f; + previewColors[i] = Colors.White; + } + } + + /// + /// Creates a using the "timed_sprite" shader + /// with night-time weights applied. Returns null if the shader is not found. + /// + public ShaderMaterial CreatePreviewMaterial() + { + var shader = Shaders.Resolve("timed_sprite"); + if (shader == null) + return null; + + var mat = new ShaderMaterial { Shader = shader }; + var night = new Vector4(0, 0, 0, 1); + mat.SetShaderParameter("time_weights", night); + mat.SetShaderParameter("use_time", true); + return mat; + } + + /// Sets the background texture uniform on the preview shader material. + public void SetBackgroundForPreview(Texture2D texture, ShaderMaterial material) + { + if (material != null) + material.SetShaderParameter("background_map", texture); + } + + /// Sets the depth mask texture uniform on the preview shader material. + public void SetDepthForPreview(Texture2D texture, ShaderMaterial material) + { + if (material != null) + material.SetShaderParameter("depth_mask", texture); + } + + /// Sets the albedo texture uniform on the preview shader material. + public void SetAlbedoForPreview(Texture2D texture, ShaderMaterial material) + { + if (material != null) + material.SetShaderParameter("albedo_map", texture); + } + + /// Appends a to the list and marks the preview dirty. + public void AddLight(LightDef light) + { + Lights.Add(light); + dirty = true; + } + + /// Removes the light at . No-op if out of range. + public void RemoveLight(int index) + { + if (index < 0 || index >= Lights.Count) + return; + Lights.RemoveAt(index); + dirty = true; + } + + /// Removes all lights from the list and marks dirty. + public void Clear() + { + Lights.Clear(); + dirty = true; + } + + /// Forces the preview to be recalculated on the next call. + public void MarkDirty() + { + dirty = true; + } + + /// + /// Packs up to 10 lights plus a sentinel into shader uniform arrays. + /// Only executes if dirty is true. Converts each light's + /// normalised position to pixel coordinates relative to the preview area. + /// + public void UpdatePreview(ShaderMaterial material, Vector2 previewGlobalPos, Vector2 previewSize) + { + if (material == null || !dirty) + return; + + for (int i = 0; i < 11; i++) + { + previewPositions[i] = new Vector2(-1000f, -1000f); + previewRadii[i] = 0f; + previewInfluence[i] = 0f; + previewColors[i] = Colors.White; + } + + int count = Mathf.Min(Lights.Count, 10); + for (int i = 0; i < count; i++) + { + var pixelPos = Lights[i].ToPixelPos(previewSize); + previewPositions[i] = new Vector2( + pixelPos.X + previewGlobalPos.X, + pixelPos.Y + previewGlobalPos.Y); + previewRadii[i] = Lights[i].Radius; + previewInfluence[i] = Lights[i].Influence; + previewColors[i] = Lights[i].ToColor(); + } + + var posArray = new Godot.Collections.Array(); + var colArray = new Godot.Collections.Array(); + var radArray = new Godot.Collections.Array(); + var infArray = new Godot.Collections.Array(); + for (int i = 0; i < 11; i++) + { + posArray.Add(previewPositions[i]); + colArray.Add(previewColors[i]); + radArray.Add(previewRadii[i]); + infArray.Add(previewInfluence[i]); + } + + material.SetShaderParameter("light_count", count); + material.SetShaderParameter("light_positions", posArray); + material.SetShaderParameter("light_colors", colArray); + material.SetShaderParameter("light_radii", radArray); + material.SetShaderParameter("light_influence", infArray); + material.SetShaderParameter("light_radius_scale", 1.0f); + + dirty = false; + } + + /// Serialises the light list as a compact JSON string. + public string ExportLightsJson() + { + return JsonSerializer.Serialize(Lights, Cthangover.Core.Utils.JsonOptionsProvider.WriteCompactOptions); + } + + /// Produces a light_set scenario DSL command with the lights JSON as its argument. + public string ExportScenarioSnippet() + { + var json = ExportLightsJson(); + return $"light_set \"{json.Replace("\"", "\\\"")}\""; + } + } +} diff --git a/src/LightEditor/LightEditorHandle.cs b/src/LightEditor/LightEditorHandle.cs new file mode 100644 index 0000000..8c95884 --- /dev/null +++ b/src/LightEditor/LightEditorHandle.cs @@ -0,0 +1,58 @@ +using Cthangover.Core.UI.Lights; +using Godot; + +namespace Cthangover.Core.UI.Tool.LightEditor +{ + /// + /// Draggable 24×24 coloured handle representing a light source on the preview. + /// Overrides _GuiInput (Godot virtual) for mouse drag support; the colour + /// is taken from . Raises + /// with the handle's centre position in global coordinates for the window to + /// convert back to normalised coordinates. + /// + public partial class LightEditorHandle : ColorRect + { + /// Index of the light in . + public int LightIndex; + /// Invoked during drag with the centre position in global coordinates. + public System.Action DragUpdate; + /// Invoked when the handle is clicked (mouse down). + public System.Action Clicked; + + private bool dragging; + private Vector2 dragOffset; + + /// Creates a 24×24 handle coloured to match the light's value. + public LightEditorHandle(LightDef light, int index) + { + LightIndex = index; + Size = new Vector2(24, 24); + Color = light.ToColor(); + MouseFilter = MouseFilterEnum.Stop; + } + + /// Handles mouse drag and click via Godot's virtual input system (_GuiInput). + public override void _GuiInput(InputEvent @event) + { + if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left) + { + if (mb.Pressed) + { + dragging = true; + dragOffset = GetGlobalMousePosition() - GlobalPosition; + Clicked?.Invoke(); + } + else + { + dragging = false; + } + AcceptEvent(); + } + else if (@event is InputEventMouseMotion && dragging) + { + GlobalPosition = GetGlobalMousePosition() - dragOffset; + DragUpdate?.Invoke(GlobalPosition + Size / 2); + } + } + } +} diff --git a/src/LightEditor/LightEditorToolProvider.cs b/src/LightEditor/LightEditorToolProvider.cs new file mode 100644 index 0000000..a32a267 --- /dev/null +++ b/src/LightEditor/LightEditorToolProvider.cs @@ -0,0 +1,29 @@ +using Cthangover.Core.UI.Tool; +using Godot; + +namespace Cthangover.Core.UI.Tool.LightEditor +{ + /// Registers the light editor as a tool window. Creates instances. + public class LightEditorToolProvider : IToolProvider + { + /// Unique tool identifier. + public string Id => "light_editor"; + /// Translation key for the tool's display name. + public string LocaleKey => "tools/light_editor/title"; + /// Creates the editor window instance. + public Window CreateWindow() => new LightEditorWindow(); + } + + /// Toolbox button that opens the light editor. Always visible. + public class LightEditorToolBoxButton : IToolBoxButton + { + /// Matches . + public string ToolId => "light_editor"; + /// No custom icon — uses text label. + public string IconPath => ""; + /// Translation key for the button label. + public string LocaleKey => "tools/light_editor/title"; + /// Always visible in the toolbox. + public bool IsVisible() => true; + } +} diff --git a/src/LightEditor/LightEditorWindow.cs b/src/LightEditor/LightEditorWindow.cs new file mode 100644 index 0000000..5742956 --- /dev/null +++ b/src/LightEditor/LightEditorWindow.cs @@ -0,0 +1,374 @@ +using System.Collections.Generic; +using System.Text.Json; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.UI.Lights; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Core.UI.Tool.LightEditor +{ + /// + /// Editor window for placing and configuring static lights on a background. + /// Provides a sidebar with background selection, a light list with add/delete/select, + /// per-light property editors (radius, influence, colour), draggable handles on a + /// shader-powered preview, and JSON export. Uses + /// for data management and for background textures. + /// + public partial class LightEditorWindow : ToolWindow + { + private LightEditorController controller; + + private ItemList backgroundList; + private VBoxContainer lightListPanel; + private Button addLightBtn; + private TextureRect preview; + private ShaderMaterial previewMaterial; + + private LineEdit radiusEdit; + private LineEdit influenceEdit; + private ColorPickerButton colorPicker; + private TextEdit jsonEdit; + + private readonly List handles = new(); + private int selectedIndex = -1; + + private readonly List bgIds = new(); + + /// Constructs the window, creates the controller, builds UI, and loads the background list. + public LightEditorWindow() : base("tools/light_editor/title") + { + controller = new LightEditorController(); + BuildUI(); + LoadBackgroundList(); + } + + protected override void Cleanup() + { + foreach (var h in handles) + h.QueueFree(); + handles.Clear(); + } + + private Vector2 PreviewSize() + { + var sz = preview.Size; + if (sz.X <= 0 || sz.Y <= 0) + sz = new Vector2(1920, 1080); + return sz; + } + + private void RefreshAll() + { + RepositionHandles(); + controller.MarkDirty(); + controller.UpdatePreview(previewMaterial, preview.GlobalPosition, PreviewSize()); + UpdateLightList(); + UpdateJsonEdit(); + } + + private void BuildUI() + { + var outerVBox = CreateFillContainer(); + AddChild(outerVBox); + + var mainHBox = new HBoxContainer(); + mainHBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + mainHBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + outerVBox.AddChild(mainHBox); + + var sidebarVBox = new VBoxContainer(); + var sidebar = CreateSidebar(sidebarVBox); + mainHBox.AddChild(sidebar); + + sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/background") }); + + backgroundList = new ItemList(); + backgroundList.CustomMinimumSize = new Vector2(0, 140); + backgroundList.ItemSelected += OnBackgroundSelected; + sidebarVBox.AddChild(backgroundList); + + sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/static_lights") }); + + lightListPanel = new VBoxContainer(); + sidebarVBox.AddChild(lightListPanel); + + addLightBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/add_light") }; + addLightBtn.Pressed += OnAddLight; + sidebarVBox.AddChild(addLightBtn); + + sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/selected_props") }); + + var radiusHBox = new HBoxContainer(); + radiusHBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/radius") }); + radiusEdit = new LineEdit { Text = "300" }; + radiusEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + radiusEdit.TextChanged += OnRadiusChanged; + radiusHBox.AddChild(radiusEdit); + sidebarVBox.AddChild(radiusHBox); + + var infHBox = new HBoxContainer(); + infHBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/influence") }); + influenceEdit = new LineEdit { Text = "1" }; + influenceEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + influenceEdit.TextChanged += OnInfluenceChanged; + infHBox.AddChild(influenceEdit); + sidebarVBox.AddChild(infHBox); + + var colorHBox = new HBoxContainer(); + colorHBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/color") }); + colorPicker = new ColorPickerButton { Color = Colors.Yellow }; + colorPicker.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + colorPicker.ColorChanged += OnColorChanged; + colorHBox.AddChild(colorPicker); + sidebarVBox.AddChild(colorHBox); + + var importBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/import_json") }; + importBtn.Pressed += OnImport; + sidebarVBox.AddChild(importBtn); + + sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/norm_coords") }); + + var previewContainer = new PanelContainer(); + previewContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + previewContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + mainHBox.AddChild(previewContainer); + + preview = new TextureRect(); + preview.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize; + preview.StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered; + preview.MouseFilter = Control.MouseFilterEnum.Stop; + preview.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + preview.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + previewContainer.AddChild(preview); + + previewMaterial = controller.CreatePreviewMaterial(); + preview.Material = previewMaterial; + + jsonEdit = new TextEdit(); + jsonEdit.CustomMinimumSize = new Vector2(0, 70); + jsonEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + jsonEdit.WrapMode = TextEdit.LineWrappingMode.Boundary; + jsonEdit.TextChanged += OnJsonEditChanged; + outerVBox.AddChild(jsonEdit); + } + + private void OnJsonEditChanged() + { + } + + private void UpdateJsonEdit() + { + var json = controller.ExportLightsJson(); + if (jsonEdit.Text != json) + jsonEdit.Text = json; + } + + private void LoadBackgroundList() + { + bgIds.Clear(); + backgroundList.Clear(); + + var ids = ModResourceService.GetBackgroundIds(); + bgIds.AddRange(ids); + foreach (var id in ids) + backgroundList.AddItem(id); + } + + private void OnBackgroundSelected(long index) + { + var bgId = bgIds[(int)index]; + var tex = BackgroundFactory.Instance.Get(bgId); + preview.Texture = tex; + controller.SetBackgroundForPreview(tex, previewMaterial); + + var depthTex = BackgroundFactory.Instance.Get(bgId + "_depth"); + if (depthTex != null) + controller.SetDepthForPreview(depthTex, previewMaterial); + + var albedoTex = BackgroundFactory.Instance.Get(bgId + "_albedo"); + if (albedoTex != null) + controller.SetAlbedoForPreview(albedoTex, previewMaterial); + } + + private void OnAddLight() + { + if (handles.Count >= 10) + return; + + var light = new LightDef + { + X = 0.4f, + Y = 0.3f, + Radius = 300f, + Influence = 1f, + ColorHex = "#ffff00" + }; + + controller.AddLight(light); + AddHandle(light, controller.Lights.Count - 1); + RefreshAll(); + } + + private void AddHandle(LightDef light, int index) + { + var handle = new LightEditorHandle(light, index); + handle.DragUpdate += globalCenter => + { + var localCenter = globalCenter - preview.GlobalPosition; + var currentSize = PreviewSize(); + light.FromPixelPos(localCenter, currentSize); + RefreshAll(); + }; + handle.Clicked += () => SelectHandle(index); + preview.AddChild(handle); + handles.Add(handle); + RepositionHandle(handle, light); + } + + private void RepositionHandle(LightEditorHandle handle, LightDef light) + { + var size = PreviewSize(); + handle.GlobalPosition = preview.GlobalPosition + + new Vector2(light.X * size.X - 12, light.Y * size.Y - 12); + } + + private void RepositionHandles() + { + for (int i = 0; i < handles.Count && i < controller.Lights.Count; i++) + RepositionHandle(handles[i], controller.Lights[i]); + } + + private void SelectHandle(int index) + { + selectedIndex = index; + if (index < 0 || index >= controller.Lights.Count) + { + radiusEdit.Text = "300"; + influenceEdit.Text = "1"; + colorPicker.Color = Colors.Yellow; + return; + } + + var light = controller.Lights[index]; + radiusEdit.Text = light.Radius.ToString("F0"); + influenceEdit.Text = light.Influence.ToString("F2"); + colorPicker.Color = light.ToColor(); + } + + private void OnRadiusChanged(string text) + { + if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count) + return; + if (float.TryParse(text, out var val)) + { + controller.Lights[selectedIndex].Radius = val; + RefreshAll(); + } + } + + private void OnInfluenceChanged(string text) + { + if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count) + return; + if (float.TryParse(text, out var val)) + { + controller.Lights[selectedIndex].Influence = val; + RefreshAll(); + } + } + + private void OnColorChanged(Color color) + { + if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count) + return; + controller.Lights[selectedIndex].ColorHex = color.ToHtml(); + RefreshAll(); + } + + private void UpdateLightList() + { + foreach (var child in lightListPanel.GetChildren()) + child.QueueFree(); + + for (int i = 0; i < controller.Lights.Count; i++) + { + var light = controller.Lights[i]; + var hBox = new HBoxContainer(); + var label = new Label + { + Text = string.Format(TranslationServer.Translate("tools/light_editor/light_n"), i + 1, light.X, light.Y), + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill + }; + hBox.AddChild(label); + + var selBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/btn_select") }; + int idx = i; + selBtn.Pressed += () => SelectHandle(idx); + hBox.AddChild(selBtn); + + var delBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/btn_delete") }; + delBtn.Pressed += () => RemoveLight(idx); + hBox.AddChild(delBtn); + + lightListPanel.AddChild(hBox); + } + + addLightBtn.Disabled = controller.Lights.Count >= 10; + } + + private void RemoveLight(int index) + { + if (index < 0 || index >= controller.Lights.Count) + return; + + controller.RemoveLight(index); + + if (index < handles.Count) + { + handles[index].QueueFree(); + handles.RemoveAt(index); + } + + for (int i = 0; i < handles.Count; i++) + handles[i].LightIndex = i; + + if (selectedIndex == index) + selectedIndex = -1; + else if (selectedIndex > index) + selectedIndex--; + + RefreshAll(); + } + + private void OnImport() + { + var json = jsonEdit.Text; + if (string.IsNullOrWhiteSpace(json)) + return; + + try + { + var lights = JsonSerializer.Deserialize>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions); + if (lights == null || lights.Count == 0) + return; + + foreach (var h in handles) + h.QueueFree(); + handles.Clear(); + controller.Clear(); + + foreach (var light in lights) + { + controller.AddLight(light); + AddHandle(light, controller.Lights.Count - 1); + } + + selectedIndex = -1; + RefreshAll(); + } + catch + { + } + } + } +} diff --git a/src/ScenarioEditor/ScenarioEditTabs.cs b/src/ScenarioEditor/ScenarioEditTabs.cs new file mode 100644 index 0000000..2c976a5 --- /dev/null +++ b/src/ScenarioEditor/ScenarioEditTabs.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Cthangover.Tools.Services; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Core.UI.Tool.ScenarioEditor +{ + public sealed class TabData + { + public string FilePath; + public string FileName; + public string SavedText; + public bool IsDirty; + } + + public partial class ScenarioEditTabs : VBoxContainer + { + private const int GraphTabIndex = 0; + + private TabBar _tabBar; + private TextEdit _textEdit; + private ScenarioGraphView _graphView; + private Font _monospaceFont; + + private readonly List _tabs = new(); + private int _activeTabIndex = -1; + private bool _suppressTextChanged; + + public TextEdit CodeEditor => _textEdit; + public string CurrentText => _textEdit.Text; + public string CurrentAbsolutePath => + _activeTabIndex >= 0 && _activeTabIndex < _tabs.Count ? _tabs[_activeTabIndex].FilePath : null; + public bool HasActiveFile => _activeTabIndex >= 0 && _activeTabIndex < _tabs.Count; + public bool IsCurrentDirty => + _activeTabIndex >= 0 && _activeTabIndex < _tabs.Count && _tabs[_activeTabIndex].IsDirty; + + public event Action TextTabSelected; + public event Action GraphTabSelected; + public event Action DirtyChanged; + public event Action GraphFileRequested; + + public ScenarioEditTabs(ScenarioGraphView graphView, Font monospaceFont) + { + _graphView = graphView; + _monospaceFont = monospaceFont; + + SizeFlagsHorizontal = SizeFlags.ExpandFill; + SizeFlagsVertical = SizeFlags.ExpandFill; + + _tabBar = new TabBar(); + _tabBar.TabCloseDisplayPolicy = TabBar.CloseButtonDisplayPolicy.ShowActiveOnly; + _tabBar.TabSelected += OnTabSelected; + _tabBar.TabClosePressed += OnTabClosePressed; + _tabBar.AddTab(TranslationServer.Translate("tools/scenario_editor/tab_graph")); + AddChild(_tabBar); + + _textEdit = new TextEdit + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + Editable = true, + SyntaxHighlighter = ScenarioSyntaxService.CreateHighlighter(), + WrapMode = TextEdit.LineWrappingMode.None + }; + _textEdit.AddThemeFontOverride("font", _monospaceFont); + _textEdit.TextChanged += OnTextChanged; + AddChild(_textEdit); + + _graphView.ScenarioFileRequested += OnGraphScenarioFileRequested; + AddChild(_graphView); + } + + public void OpenFile(string absolutePath) + { + for (int i = 0; i < _tabs.Count; i++) + { + if (_tabs[i].FilePath == absolutePath) + { + SwitchToTextTab(i); + return; + } + } + + try + { + var text = ScenarioFileService.ReadAllText(absolutePath); + var tab = new TabData + { + FilePath = absolutePath, + FileName = Path.GetFileNameWithoutExtension(absolutePath), + SavedText = text, + IsDirty = false + }; + + _tabs.Add(tab); + _tabBar.AddTab(tab.FileName); + + SwitchToTextTab(_tabs.Count - 1); + } + catch (Exception ex) + { + GameLogger.Log("SCENARIO_EDITOR", $"Failed to load file: {ex.Message}", LogLevel.Error); + } + } + + public void SaveCurrentFile() + { + if (_activeTabIndex < 0 || _activeTabIndex >= _tabs.Count) + return; + + var tab = _tabs[_activeTabIndex]; + tab.SavedText = _textEdit.Text; + if (tab.IsDirty) + { + tab.IsDirty = false; + UpdateTabTitle(_activeTabIndex); + UpdateGlobalDirty(); + } + } + + public void SetTextSilent(string text) + { + _suppressTextChanged = true; + _textEdit.Text = text; + _suppressTextChanged = false; + } + + public void SetViewToGraph() + { + if (_tabs.Count > 0) + return; + + _tabBar.CurrentTab = GraphTabIndex; + _textEdit.Visible = false; + _graphView.Visible = true; + GraphTabSelected?.Invoke(); + } + + public void RefreshGraph() + { + if (_graphView.Visible) + { + var data = ScenarioFileService.BuildSceneGraph(); + _graphView.Populate(data); + } + } + + public bool ConfirmCloseAll() + { + for (int i = _tabs.Count - 1; i >= 0; i--) + { + if (_tabs[i].IsDirty) + return false; + } + return true; + } + + private void OnTabSelected(long index) + { + if (index == GraphTabIndex) + { + SaveActiveTextEditContent(); + _activeTabIndex = -1; + _textEdit.Visible = false; + _graphView.Visible = true; + GraphTabSelected?.Invoke(); + } + else + { + _graphView.Visible = false; + _textEdit.Visible = true; + SwitchToTextTab((int)index - 1); + } + } + + private void OnTabClosePressed(long index) + { + if (index == GraphTabIndex) + return; + CloseTextTab((int)index - 1); + } + + private void SwitchToTextTab(int index) + { + if (index < 0 || index >= _tabs.Count || index == _activeTabIndex) + return; + + if (_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count) + _tabs[_activeTabIndex].SavedText = _textEdit.Text; + + _activeTabIndex = index; + _tabBar.CurrentTab = index + 1; + + var tab = _tabs[index]; + + SetTextSilent(tab.SavedText); + + TextTabSelected?.Invoke(tab.SavedText); + } + + private void CloseTextTab(int index) + { + if (index < 0 || index >= _tabs.Count) + return; + + if (_tabs[index].IsDirty) + { + var dialog = new ConfirmationDialog(); + dialog.Title = TranslationServer.Translate("tools/scenario_editor/unsaved_title"); + dialog.DialogText = TranslationServer.Translate("tools/scenario_editor/unsaved_switch_text"); + int capturedIndex = index; + dialog.Confirmed += () => + { + dialog.QueueFree(); + DoCloseTextTab(capturedIndex); + }; + dialog.Canceled += () => dialog.QueueFree(); + AddChild(dialog); + dialog.PopupCentered(); + return; + } + + DoCloseTextTab(index); + } + + private void DoCloseTextTab(int index) + { + _tabs.RemoveAt(index); + _tabBar.RemoveTab(index + 1); + + if (_tabs.Count == 0) + { + _activeTabIndex = -1; + SetTextSilent(""); + TextTabSelected?.Invoke(""); + UpdateGlobalDirty(); + return; + } + + if (index == _activeTabIndex) + { + var newIndex = Math.Min(index, _tabs.Count - 1); + _activeTabIndex = -1; + SwitchToTextTab(newIndex); + } + else if (index < _activeTabIndex) + { + _activeTabIndex--; + _tabBar.CurrentTab = _activeTabIndex + 1; + } + + UpdateGlobalDirty(); + } + + private void OnTextChanged() + { + if (_suppressTextChanged || _activeTabIndex < 0 || _activeTabIndex >= _tabs.Count) + return; + + var tab = _tabs[_activeTabIndex]; + if (!tab.IsDirty) + { + tab.IsDirty = true; + UpdateTabTitle(_activeTabIndex); + UpdateGlobalDirty(); + } + } + + private void UpdateTabTitle(int index) + { + if (index < 0 || index >= _tabs.Count) + return; + var tab = _tabs[index]; + _tabBar.SetTabTitle(index + 1, tab.IsDirty ? tab.FileName + " *" : tab.FileName); + } + + private void UpdateGlobalDirty() + { + bool anyDirty = _tabs.Any(t => t.IsDirty); + DirtyChanged?.Invoke(anyDirty); + } + + private void SaveActiveTextEditContent() + { + if (_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count) + _tabs[_activeTabIndex].SavedText = _textEdit.Text; + } + + private void OnGraphScenarioFileRequested(string filePath) + { + GraphFileRequested?.Invoke(filePath); + } + } +} diff --git a/src/ScenarioEditor/ScenarioEditorToolProvider.cs b/src/ScenarioEditor/ScenarioEditorToolProvider.cs new file mode 100644 index 0000000..fbd8143 --- /dev/null +++ b/src/ScenarioEditor/ScenarioEditorToolProvider.cs @@ -0,0 +1,16 @@ +using Cthangover.Core.UI.Tool; +using Godot; + +namespace Cthangover.Core.UI.Tool.ScenarioEditor +{ + /// Registers the scenario editor as a tool window. Creates instances. + public class ScenarioEditorToolProvider : IToolProvider + { + /// Unique tool identifier. + public string Id => "scenario_editor"; + /// Translation key for the tool's display name. + public string LocaleKey => "tools/scenario_editor/title"; + /// Creates the editor window instance. + public Window CreateWindow() => new ScenarioEditorWindow(); + } +} diff --git a/src/ScenarioEditor/ScenarioEditorWindow.cs b/src/ScenarioEditor/ScenarioEditorWindow.cs new file mode 100644 index 0000000..d31436d --- /dev/null +++ b/src/ScenarioEditor/ScenarioEditorWindow.cs @@ -0,0 +1,354 @@ +using System; +using Cthangover.Core.Scenes; +using Cthangover.Core.Utils; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Core.UI.Tool.ScenarioEditor +{ + public partial class ScenarioEditorWindow : ToolWindow + { + private Control _editLayout; + private Control _playLayout; + + private ScenarioFileTreePanel _fileTreePanel; + private ScenarioEditTabs _editTabs; + private ScenarioInfoPanel _infoPanel; + private ScenarioThumbnailPanel _thumbPanel; + + private Button _saveBtn; + private Button _openFileBtn; + private Button _playBtn; + private Label _filePathLabel; + private Label _statusLabel; + + private bool _playActive; + private CanvasLayer _stopOverlay; + + public ScenarioEditorWindow() : base("tools/scenario_editor/title") + { + BuildUI(); + } + + public override void _Ready() + { + _editTabs.SetViewToGraph(); + } + + protected override void Cleanup() + { + } + + public override void _Process(double delta) + { + if (_playActive && !SceneManager.IsTestPlayActive) + ReturnFromPlay(); + } + + private void BuildUI() + { + _editLayout = new Control + { + AnchorRight = 1f, + AnchorBottom = 1f, + Visible = true + }; + AddChild(_editLayout); + + var outerVBox = CreateFillContainer(); + _editLayout.AddChild(outerVBox); + + var toolbar = CreateToolbar(); + outerVBox.AddChild(toolbar); + + _saveBtn = new Button + { + Text = TranslationServer.Translate("tools/scenario_editor/save"), + Disabled = true + }; + _saveBtn.Pressed += OnSavePressed; + toolbar.AddChild(_saveBtn); + + _openFileBtn = new Button + { + Text = TranslationServer.Translate("tools/scenario_editor/open_file"), + Disabled = true + }; + _openFileBtn.Pressed += OnOpenFilePressed; + toolbar.AddChild(_openFileBtn); + + _playBtn = new Button + { + Text = TranslationServer.Translate("tools/scenario_editor/play"), + TooltipText = TranslationServer.Translate("tools/scenario_editor/play_tooltip"), + Disabled = true + }; + _playBtn.Pressed += OnPlayPressed; + toolbar.AddChild(_playBtn); + + _filePathLabel = new Label { Text = "" }; + _filePathLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + toolbar.AddChild(_filePathLabel); + + var mainSplit = new HSplitContainer(); + mainSplit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + mainSplit.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + outerVBox.AddChild(mainSplit); + + var leftPanel = new PanelContainer + { + CustomMinimumSize = new Vector2(340, 0), + SizeFlagsVertical = Control.SizeFlags.ExpandFill + }; + mainSplit.AddChild(leftPanel); + + var leftVBox = new VBoxContainer(); + leftPanel.AddChild(leftVBox); + + var tabsContainer = new TabContainer(); + tabsContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + tabsContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + leftVBox.AddChild(tabsContainer); + + _fileTreePanel = (ScenarioFileTreePanel)TscnScenes.LoadAndBuild("scenes/scenario_file_tree_panel.tscn"); + _fileTreePanel.FileSelected += OnFileSelected; + tabsContainer.AddChild(_fileTreePanel); + tabsContainer.SetTabTitle(0, TranslationServer.Translate("tools/scenario_editor/tab_files")); + + _infoPanel = (ScenarioInfoPanel)TscnScenes.LoadAndBuild("scenes/scenario_info_panel.tscn"); + tabsContainer.AddChild(_infoPanel); + tabsContainer.SetTabTitle(1, TranslationServer.Translate("tools/scenario_editor/tab_info")); + + leftVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/scenario_editor/bg_previews") + ":" }); + + _thumbPanel = (ScenarioThumbnailPanel)TscnScenes.LoadAndBuild("scenes/scenario_thumbnail_panel.tscn"); + leftVBox.AddChild(_thumbPanel); + + var rightPanel = new PanelContainer + { + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill + }; + mainSplit.AddChild(rightPanel); + + _editTabs = new ScenarioEditTabs(new ScenarioGraphView(), GetMonospaceFont()); + _editTabs.TextTabSelected += OnTextTabSelected; + _editTabs.GraphTabSelected += OnGraphTabSelected; + _editTabs.DirtyChanged += OnDirtyChanged; + _editTabs.GraphFileRequested += OnGraphFileRequested; + rightPanel.AddChild(_editTabs); + + var statusBar = new HBoxContainer(); + outerVBox.AddChild(statusBar); + + _statusLabel = new Label { Text = "" }; + statusBar.AddChild(_statusLabel); + + _playLayout = new Control + { + AnchorRight = 1f, + AnchorBottom = 1f, + Visible = false + }; + AddChild(_playLayout); + + var stopContainer = new HBoxContainer(); + stopContainer.Alignment = BoxContainer.AlignmentMode.Center; + stopContainer.AnchorRight = 0.5f; + stopContainer.AnchorLeft = 0.5f; + stopContainer.OffsetTop = 8; + _playLayout.AddChild(stopContainer); + + var stopBtn = new Button + { + Text = TranslationServer.Translate("tools/scenario_editor/stop"), + CustomMinimumSize = new Vector2(120, 40) + }; + stopBtn.Pressed += OnStopPressed; + stopContainer.AddChild(stopBtn); + } + + private void OnFileSelected(string filePath) + { + _editTabs.OpenFile(filePath); + } + + private void OnGraphFileRequested(string filePath) + { + _fileTreePanel.SelectFile(filePath); + } + + private void OnTextTabSelected(string text) + { + _filePathLabel.Text = _editTabs.CurrentAbsolutePath != null + ? ScenarioFileService.GetRelativePath(_editTabs.CurrentAbsolutePath) : ""; + _saveBtn.Disabled = false; + _openFileBtn.Disabled = false; + _playBtn.Disabled = false; + _statusLabel.Text = _editTabs.IsCurrentDirty + ? TranslationServer.Translate("tools/scenario_editor/status_modified") : ""; + _infoPanel.Update(text); + _thumbPanel.Load(text); + } + + private void OnGraphTabSelected() + { + _filePathLabel.Text = ""; + _saveBtn.Disabled = true; + _openFileBtn.Disabled = true; + _playBtn.Disabled = true; + _statusLabel.Text = ""; + _infoPanel.Clear(); + _thumbPanel.Clear(); + _editTabs.RefreshGraph(); + } + + private void OnDirtyChanged(bool anyDirty) + { + if (anyDirty) + { + SetDirty(); + _statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/status_modified"); + } + else + { + MarkClean(); + } + } + + private void OnSavePressed() + { + if (!_editTabs.HasActiveFile) + return; + + var path = _editTabs.CurrentAbsolutePath; + var text = _editTabs.CurrentText; + try + { + ScenarioFileService.WriteAllText(path, text); + _editTabs.SaveCurrentFile(); + _statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/status_saved"); + _infoPanel.Update(text); + _thumbPanel.Load(text); + _editTabs.RefreshGraph(); + GameLogger.Log("SCENARIO_EDITOR", $"Saved: {path}"); + } + catch (Exception ex) + { + GameLogger.Log("SCENARIO_EDITOR", $"Save failed: {ex.Message}", LogLevel.Error); + _statusLabel.Text = string.Format( + TranslationServer.Translate("tools/scenario_editor/save_error"), ex.Message); + } + } + + private void OnOpenFilePressed() + { + var path = _editTabs.CurrentAbsolutePath; + if (path == null) + return; + + GD.Print($"[ScenarioEditor] Opening: {path}"); + var err = OS.ShellOpen(path); + if (err != Error.Ok) + GameLogger.Log("SCENARIO_EDITOR", $"ShellOpen error: {err}", LogLevel.Error); + } + + private void OnPlayPressed() + { + if (!_editTabs.HasActiveFile) + return; + + var text = _editTabs.CurrentText; + var metadata = ScenarioFileService.ParseMetadata(text); + + if (metadata.Scene == "-") + { + _statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/no_scene_error"); + return; + } + + var path = _editTabs.CurrentAbsolutePath; + try + { + ScenarioFileService.WriteAllText(path, text); + } + catch (Exception ex) + { + _statusLabel.Text = string.Format( + TranslationServer.Translate("tools/scenario_editor/save_error"), ex.Message); + return; + } + + _editTabs.SaveCurrentFile(); + + _editLayout.Visible = false; + _playLayout.Visible = true; + + SceneManager.IsTestPlayActive = true; + SceneManager.TestScenarioText = text; + + GameLogger.Log("SCENARIO_EDITOR", $"Test play START: scene='{metadata.Scene}', queue={text.Split('\n').Length} lines"); + + Visible = false; + + _stopOverlay = CreateStopOverlay(); + var tree = Godot.Engine.GetMainLoop() as SceneTree; + tree?.Root.AddChild(_stopOverlay); + + var sceneManager = GetNodeOrNull("/root/SceneManager"); + if (sceneManager != null) + { + sceneManager.Initialize(); + sceneManager.PendingSceneName = metadata.Scene; + } + + var sceneService = GetNodeOrNull("/root/GodotSceneService"); + sceneService?.LoadScene("res://scenes/ui/base_scene.tscn"); + + _playActive = true; + } + + private void OnStopPressed() + { + SceneManager.IsTestPlayActive = false; + ReturnFromPlay(); + } + + private void ReturnFromPlay() + { + _playActive = false; + _playLayout.Visible = false; + _editLayout.Visible = true; + Visible = true; + + if (_stopOverlay != null) + { + _stopOverlay.QueueFree(); + _stopOverlay = null; + } + + var sceneService = GetNodeOrNull("/root/GodotSceneService"); + sceneService?.SwitchToMenu(); + } + + private CanvasLayer CreateStopOverlay() + { + var layer = new CanvasLayer { Layer = 128 }; + + var btn = new Button + { + Text = TranslationServer.Translate("tools/scenario_editor/stop"), + CustomMinimumSize = new Vector2(120, 40) + }; + btn.Pressed += OnStopPressed; + + var margin = new MarginContainer(); + margin.AddThemeConstantOverride("margin_top", 8); + margin.AddThemeConstantOverride("margin_left", 8); + margin.AddChild(btn); + + layer.AddChild(margin); + return layer; + } + } +} diff --git a/src/ScenarioEditor/ScenarioFileTreePanel.cs b/src/ScenarioEditor/ScenarioFileTreePanel.cs new file mode 100644 index 0000000..dea93e1 --- /dev/null +++ b/src/ScenarioEditor/ScenarioFileTreePanel.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Core.UI.Tool.ScenarioEditor +{ + public partial class ScenarioFileTreePanel : VBoxContainer + { + private Tree _fileTree; + private readonly Dictionary _fileItemToPath = new(); + private readonly Dictionary _pathToTreeItem = new(); + private bool _isProgrammaticSelection; + + public event Action FileSelected; + + public ScenarioFileTreePanel() + { + _fileTree = GetNode("FileTree"); + _fileTree.CellSelected += OnCellSelected; + + Populate(); + } + + private void Populate() + { + _fileTree.Clear(); + _fileItemToPath.Clear(); + + var root = _fileTree.CreateItem(); + var mods = ScenarioFileService.ScanScenarioFiles(); + + foreach (var mod in mods) + { + var modItem = _fileTree.CreateItem(root); + modItem.SetText(0, $"[{mod.ModId}]"); + modItem.SetSelectable(0, false); + modItem.SetCustomColor(0, new Color(0.5f, 0.75f, 1f)); + + foreach (var group in mod.Groups) + { + TreeItem parentItem; + if (string.IsNullOrEmpty(group.DirectoryPath)) + { + parentItem = modItem; + } + else + { + parentItem = _fileTree.CreateItem(modItem); + parentItem.SetText(0, group.DirectoryPath + "/"); + parentItem.SetSelectable(0, false); + } + + foreach (var file in group.Files) + { + var fileItem = _fileTree.CreateItem(parentItem); + fileItem.SetText(0, file.Name); + fileItem.SetMetadata(0, Variant.From("scenario")); + _fileItemToPath[GetItemKey(fileItem)] = file.AbsolutePath; + _pathToTreeItem[file.AbsolutePath] = fileItem; + } + } + } + } + + private static string GetItemKey(TreeItem item) + { + return item.GetInstanceId().ToString(); + } + + public void SelectFile(string absolutePath) + { + if (!_pathToTreeItem.TryGetValue(absolutePath, out var item)) + return; + + _isProgrammaticSelection = true; + item.Select(0); + _isProgrammaticSelection = false; + + FileSelected?.Invoke(absolutePath); + } + + private void OnCellSelected() + { + if (_isProgrammaticSelection) + return; + + var item = _fileTree.GetSelected(); + if (item == null) + return; + + var meta = item.GetMetadata(0); + if (meta.VariantType != Variant.Type.String || meta.AsString() != "scenario") + return; + + var key = GetItemKey(item); + if (!_fileItemToPath.TryGetValue(key, out var filePath)) + return; + + FileSelected?.Invoke(filePath); + } + } +} diff --git a/src/ScenarioEditor/ScenarioGraphView.cs b/src/ScenarioEditor/ScenarioGraphView.cs new file mode 100644 index 0000000..472428a --- /dev/null +++ b/src/ScenarioEditor/ScenarioGraphView.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Core.UI.Tool.ScenarioEditor +{ + public partial class ScenarioGraphView : HSplitContainer + { + private GraphEdit _graph; + private RichTextLabel _infoLabel; + private Dictionary _nodes; + private Dictionary _lookup; + + public event Action ScenarioFileRequested; + + public ScenarioGraphView() + { + SizeFlagsHorizontal = SizeFlags.ExpandFill; + SizeFlagsVertical = SizeFlags.ExpandFill; + + _graph = new GraphEdit + { + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill, + RightDisconnects = false, + ConnectionLinesThickness = 2f, + ConnectionLinesAntialiased = true, + MinimapEnabled = true, + ShowGrid = true, + SnappingEnabled = true, + SnappingDistance = 20 + }; + _graph.ConnectionRequest += OnConnectionRequest; + _graph.DisconnectionRequest += OnDisconnectionRequest; + _graph.NodeSelected += OnNodeSelected; + _graph.NodeDeselected += OnNodeDeselected; + AddChild(_graph); + + var panel = new VBoxContainer { CustomMinimumSize = new Vector2(250, 0) }; + panel.SizeFlagsVertical = SizeFlags.ExpandFill; + AddChild(panel); + + panel.AddChild(new Label + { + Text = TranslationServer.Translate("tools/scenario_editor/graph_select_node"), + AutowrapMode = TextServer.AutowrapMode.WordSmart + }); + + _infoLabel = new RichTextLabel + { + BbcodeEnabled = true, + FitContent = true, + ScrollFollowing = true, + SizeFlagsVertical = SizeFlags.ExpandFill + }; + _infoLabel.MetaClicked += OnLinkClicked; + panel.AddChild(_infoLabel); + } + + public void Populate(SceneGraphData data) + { + var savedPositions = SaveNodePositions(); + ClearGraph(); + + _nodes = new Dictionary(); + _lookup = new Dictionary(); + + int x = 40, y = 40; + foreach (var pos in savedPositions.Values) + { + int bottom = (int)pos.Y + 160 + 20; + if (bottom > y) + y = bottom; + } + + foreach (var nodeData in data.Nodes) + { + var key = nodeData.SceneName.ToLowerInvariant(); + if (_nodes.ContainsKey(key)) + continue; + + var bgCount = nodeData.Backgrounds?.Count ?? 0; + var firstBg = bgCount > 0 ? nodeData.Backgrounds[0] : ""; + + var position = savedPositions.TryGetValue(key, out var saved) + ? saved + : new Vector2(x, y); + + var gn = new GraphNode + { + Title = nodeData.SceneName, + Size = new Vector2(240, 90), + PositionOffset = position + }; + + gn.AddChild(new Label + { + Text = $"Mod: {nodeData.ModId}\nScenarios: {nodeData.ScenarioCount}" + + (bgCount > 0 ? $"\nBg: {firstBg}" + + (bgCount > 1 ? $" +{bgCount - 1}" : "") : "") + }); + + var color = Colors.White; + if (nodeData.IsStartScene) + { + color = new Color(0.3f, 1f, 0.3f); + gn.AddThemeStyleboxOverride("titlebar", CreateTitlebarStyle(new Color(0.2f, 0.5f, 0.15f))); + } + else if (nodeData.HasErrors) + { + color = new Color(1f, 0.3f, 0.3f); + gn.AddThemeStyleboxOverride("titlebar", CreateTitlebarStyle(new Color(0.5f, 0.1f, 0.1f))); + } + gn.SetSlot(0, true, 0, Colors.White, true, 0, color); + + _graph.AddChild(gn); + _nodes[key] = gn; + _lookup[key] = nodeData; + + if (!savedPositions.ContainsKey(key)) + { + x += 280; + if (x > 1000) { x = 40; y += 160; } + } + } + + foreach (var edge in data.Edges) + { + var srcK = edge.FromScene.ToLowerInvariant(); + var dstK = edge.ToScene.ToLowerInvariant(); + if (!_nodes.ContainsKey(srcK) || !_nodes.ContainsKey(dstK)) + continue; + _graph.ConnectNode(_nodes[srcK].Name, 0, _nodes[dstK].Name, 0); + } + } + + private Dictionary SaveNodePositions() + { + var positions = new Dictionary(); + if (_nodes == null) + return positions; + foreach (var kv in _nodes) + positions[kv.Key] = kv.Value.PositionOffset; + return positions; + } + + private static StyleBoxFlat CreateTitlebarStyle(Color bgColor) + { + return new StyleBoxFlat + { + BgColor = bgColor, + CornerRadiusTopLeft = 6, + CornerRadiusTopRight = 6, + CornerRadiusBottomLeft = 0, + CornerRadiusBottomRight = 0 + }; + } + + private void ClearGraph() + { + foreach (var c in _graph.GetChildren()) + if (c is GraphNode gn) + gn.QueueFree(); + _infoLabel.Text = ""; + _nodes?.Clear(); + _lookup?.Clear(); + } + + private void OnConnectionRequest(StringName fromNode, long fromPort, StringName toNode, long toPort) { } + + private void OnDisconnectionRequest(StringName fromNode, long fromPort, StringName toNode, long toPort) { } + + private void OnNodeSelected(Node n) + { + if (n is not GraphNode gn) + return; + var key = gn.Title.ToString().ToLowerInvariant(); + if (!_lookup.TryGetValue(key, out var nodeData)) + return; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"Scene: [b]{nodeData.SceneName}[/b]"); + sb.AppendLine($"Mod: {nodeData.ModId}"); + if (nodeData.Backgrounds.Count > 0) + sb.AppendLine($"Backgrounds: {string.Join(", ", nodeData.Backgrounds)}"); + sb.AppendLine(); + sb.AppendLine($"Scenarios ({nodeData.Scenarios.Count}):"); + + foreach (var sc in nodeData.Scenarios.OrderBy(s => s.Priority)) + { + var url = Uri.EscapeDataString(sc.FilePath); + sb.AppendLine($" [{sc.Priority}] [url={url}]{sc.Name}[/url]"); + } + + _infoLabel.Text = sb.ToString(); + } + + private void OnNodeDeselected(Node n) + { + _infoLabel.Text = ""; + } + + private void OnLinkClicked(Variant meta) + { + var path = Uri.UnescapeDataString(meta.AsString()); + ScenarioFileRequested?.Invoke(path); + } + } +} diff --git a/src/ScenarioEditor/ScenarioInfoPanel.cs b/src/ScenarioEditor/ScenarioInfoPanel.cs new file mode 100644 index 0000000..20a696c --- /dev/null +++ b/src/ScenarioEditor/ScenarioInfoPanel.cs @@ -0,0 +1,62 @@ +using System.Linq; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Core.UI.Tool.ScenarioEditor +{ + public partial class ScenarioInfoPanel : VBoxContainer + { + private Label _infoScene; + private Label _infoPriority; + private Label _infoCondition; + private Label _infoSwitchTargets; + private Label _infoLocaleKeys; + private Label _infoQuestRefs; + + public ScenarioInfoPanel() + { + GetNode