init
This commit is contained in:
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "tools",
|
||||||
|
"name": "Tools",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "ct",
|
||||||
|
"description": "Development tools",
|
||||||
|
"sources": [
|
||||||
|
"src/**/*.cs"
|
||||||
|
],
|
||||||
|
"depends": ["core>=1"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 = "-"
|
||||||
@@ -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="."]
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data model for the interactive editor. Holds interactive definition
|
||||||
|
/// fields, manages collider vertex list for polygon editing, and produces
|
||||||
|
/// the output JSON.
|
||||||
|
/// </summary>
|
||||||
|
public class InteractiveEditorController
|
||||||
|
{
|
||||||
|
/// <summary>Unique identifier for the interactive object resource.</summary>
|
||||||
|
public string Id { get; set; } = "new_interactive";
|
||||||
|
/// <summary>Texture key mapped to a sprite resource by the resource system.</summary>
|
||||||
|
public string Texture { get; set; } = "";
|
||||||
|
/// <summary>Render layer: <c>"foreground"</c>, <c>"background"</c>, or <c>"ui"</c>.</summary>
|
||||||
|
public string Layer { get; set; } = "foreground";
|
||||||
|
/// <summary>Z-index for draw order within the layer.</summary>
|
||||||
|
public int ZIndex { get; set; }
|
||||||
|
/// <summary>Whether the interactive is initially enabled.</summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
/// <summary>Whether the interactive is visible.</summary>
|
||||||
|
public bool Visible { get; set; } = true;
|
||||||
|
/// <summary>Mouse cursor override name, e.g. <c>"PointingHand"</c>. Empty for default.</summary>
|
||||||
|
public string Cursor { get; set; } = "";
|
||||||
|
/// <summary>Collider shape type: <c>"rect"</c>, <c>"circle"</c>, or <c>"polygon"</c>.</summary>
|
||||||
|
public string HitType { get; set; } = "rect";
|
||||||
|
|
||||||
|
/// <summary>Normalised X position of the rect collider (0–1).</summary>
|
||||||
|
public float RectX { get; set; }
|
||||||
|
/// <summary>Normalised Y position of the rect collider (0–1).</summary>
|
||||||
|
public float RectY { get; set; }
|
||||||
|
/// <summary>Normalised width of the rect collider (0–1).</summary>
|
||||||
|
public float RectW { get; set; } = 0.1f;
|
||||||
|
/// <summary>Normalised height of the rect collider (0–1).</summary>
|
||||||
|
public float RectH { get; set; } = 0.1f;
|
||||||
|
|
||||||
|
/// <summary>Normalised X centre of the circle collider (0–1).</summary>
|
||||||
|
public float CircleX { get; set; } = 0.5f;
|
||||||
|
/// <summary>Normalised Y centre of the circle collider (0–1).</summary>
|
||||||
|
public float CircleY { get; set; } = 0.5f;
|
||||||
|
/// <summary>Normalised radius of the circle collider (0–1).</summary>
|
||||||
|
public float CircleRadius { get; set; } = 0.05f;
|
||||||
|
|
||||||
|
/// <summary>List of normalised (0–1) vertices defining the polygon collider.</summary>
|
||||||
|
public List<Vector2> PolygonVertices { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Hex colour string (e.g. <c>"#FFFF0033"</c>) for the highlight overlay.</summary>
|
||||||
|
public string HighlightColorHex { get; set; } = "#FFFF0033";
|
||||||
|
/// <summary>Scale multiplier applied to the sprite when highlighted.</summary>
|
||||||
|
public float HighlightScale { get; set; } = 1.02f;
|
||||||
|
|
||||||
|
/// <summary>Scenario file path executed on click.</summary>
|
||||||
|
public string OnClickScenario { get; set; } = "";
|
||||||
|
/// <summary>Inline DSL commands executed on click, one per line.</summary>
|
||||||
|
public string OnClickCommands { get; set; } = "";
|
||||||
|
/// <summary>DSL commands executed when the mouse enters the interactive area.</summary>
|
||||||
|
public string OnHoverEnter { get; set; } = "";
|
||||||
|
/// <summary>DSL commands executed when the mouse leaves the interactive area.</summary>
|
||||||
|
public string OnHoverLeave { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Builds an InteractiveDefinition from current state.</summary>
|
||||||
|
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<string>();
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Loads state from an InteractiveDefinition.</summary>
|
||||||
|
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<Vector2>(def.HitArea.Vertices)
|
||||||
|
: new List<Vector2>();
|
||||||
|
}
|
||||||
|
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 ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the definition to formatted JSON (FileData envelope).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses JSON back into the controller. Returns false on failure.</summary>
|
||||||
|
public bool FromJson(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip };
|
||||||
|
var wrapper = JsonSerializer.Deserialize<FileDataWrapper>(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<InteractiveDefinition> Items { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates a polygon vertex at the given index.</summary>
|
||||||
|
public void SetPolygonVertex(int index, Vector2 value)
|
||||||
|
{
|
||||||
|
while (PolygonVertices.Count <= index)
|
||||||
|
PolygonVertices.Add(Vector2.Zero);
|
||||||
|
PolygonVertices[index] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes a polygon vertex at the given index.</summary>
|
||||||
|
public void RemovePolygonVertex(int index)
|
||||||
|
{
|
||||||
|
if (index >= 0 && index < PolygonVertices.Count)
|
||||||
|
PolygonVertices.RemoveAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds a new polygon vertex at the default position (center of viewport).</summary>
|
||||||
|
public void AddPolygonVertex()
|
||||||
|
{
|
||||||
|
var offset = PolygonVertices.Count * 0.04f;
|
||||||
|
PolygonVertices.Add(new Vector2(0.5f + offset, 0.5f + offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.InteractiveEditor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public partial class InteractiveEditorHandle : ColorRect
|
||||||
|
{
|
||||||
|
/// <summary>Index of the vertex this handle represents (0-based).</summary>
|
||||||
|
public int VertexIndex;
|
||||||
|
/// <summary>Invoked during drag with the centre position of the handle in global coordinates.</summary>
|
||||||
|
public System.Action<Vector2> DragUpdate;
|
||||||
|
/// <summary>Invoked when the handle is clicked (mouse down).</summary>
|
||||||
|
public System.Action Clicked;
|
||||||
|
/// <summary>Invoked when the drag ends (mouse up). Used to sync sidebar values.</summary>
|
||||||
|
public System.Action DragEnd;
|
||||||
|
|
||||||
|
private bool _dragging;
|
||||||
|
private Vector2 _dragOffset;
|
||||||
|
|
||||||
|
/// <summary>Creates a handle with the given colour, size 16×16, and index for vertex identification.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Cthangover.Core.UI.Tool;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.InteractiveEditor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the interactive editor as a tool window in the developer toolbox.
|
||||||
|
/// Implements <see cref="IToolProvider"/> so the tool system can instantiate
|
||||||
|
/// <see cref="InteractiveEditorWindow"/> on demand.
|
||||||
|
/// </summary>
|
||||||
|
public class InteractiveEditorToolProvider : IToolProvider
|
||||||
|
{
|
||||||
|
/// <summary>Unique tool identifier referenced by the toolbox UI.</summary>
|
||||||
|
public string Id => "interactive_editor";
|
||||||
|
/// <summary>Translation key for the tool's display name.</summary>
|
||||||
|
public string LocaleKey => "tools/interactive_editor/title";
|
||||||
|
/// <summary>Creates the editor window instance.</summary>
|
||||||
|
public Window CreateWindow() => new InteractiveEditorWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides a button in the developer toolbox that opens the interactive editor.
|
||||||
|
/// Controls visibility via the <see cref="IsVisible"/> policy.
|
||||||
|
/// </summary>
|
||||||
|
public class InteractiveEditorToolBoxButton : IToolBoxButton
|
||||||
|
{
|
||||||
|
/// <summary>Matches <see cref="InteractiveEditorToolProvider.Id"/>.</summary>
|
||||||
|
public string ToolId => "interactive_editor";
|
||||||
|
/// <summary>No custom icon — uses the tool title text.</summary>
|
||||||
|
public string IconPath => "";
|
||||||
|
/// <summary>Translation key for the button label.</summary>
|
||||||
|
public string LocaleKey => "tools/interactive_editor/title";
|
||||||
|
/// <summary>Always visible in the toolbox.</summary>
|
||||||
|
public bool IsVisible() => true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<InteractiveEditorHandle> _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<Vector2> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data controller for the light editor tool. Maintains a list of
|
||||||
|
/// <see cref="LightDef"/> objects and feeds up to 10 lights plus one
|
||||||
|
/// sentinel entry into the <c>"timed_sprite"</c> shader material for
|
||||||
|
/// real-time preview. Exports lights as JSON (<see cref="ExportLightsJson"/>)
|
||||||
|
/// or as a scenario DSL snippet (<see cref="ExportScenarioSnippet"/>).
|
||||||
|
/// Uses a dirty-flag pattern so shader uniform updates only occur when
|
||||||
|
/// the light list changes.
|
||||||
|
/// </summary>
|
||||||
|
public class LightEditorController
|
||||||
|
{
|
||||||
|
/// <summary>The editable list of light definitions. Capped at 10 for shader preview.</summary>
|
||||||
|
public List<LightDef> 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;
|
||||||
|
|
||||||
|
/// <summary>Initialises preview arrays with out-of-bounds sentinel values.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a <see cref="ShaderMaterial"/> using the <c>"timed_sprite"</c> shader
|
||||||
|
/// with night-time weights applied. Returns <c>null</c> if the shader is not found.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets the background texture uniform on the preview shader material.</summary>
|
||||||
|
public void SetBackgroundForPreview(Texture2D texture, ShaderMaterial material)
|
||||||
|
{
|
||||||
|
if (material != null)
|
||||||
|
material.SetShaderParameter("background_map", texture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets the depth mask texture uniform on the preview shader material.</summary>
|
||||||
|
public void SetDepthForPreview(Texture2D texture, ShaderMaterial material)
|
||||||
|
{
|
||||||
|
if (material != null)
|
||||||
|
material.SetShaderParameter("depth_mask", texture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets the albedo texture uniform on the preview shader material.</summary>
|
||||||
|
public void SetAlbedoForPreview(Texture2D texture, ShaderMaterial material)
|
||||||
|
{
|
||||||
|
if (material != null)
|
||||||
|
material.SetShaderParameter("albedo_map", texture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Appends a <see cref="LightDef"/> to the list and marks the preview dirty.</summary>
|
||||||
|
public void AddLight(LightDef light)
|
||||||
|
{
|
||||||
|
Lights.Add(light);
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes the light at <paramref name="index"/>. No-op if out of range.</summary>
|
||||||
|
public void RemoveLight(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= Lights.Count)
|
||||||
|
return;
|
||||||
|
Lights.RemoveAt(index);
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes all lights from the list and marks dirty.</summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
Lights.Clear();
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forces the preview to be recalculated on the next <see cref="UpdatePreview"/> call.</summary>
|
||||||
|
public void MarkDirty()
|
||||||
|
{
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Packs up to 10 lights plus a sentinel into shader uniform arrays.
|
||||||
|
/// Only executes if <c>dirty</c> is <c>true</c>. Converts each light's
|
||||||
|
/// normalised position to pixel coordinates relative to the preview area.
|
||||||
|
/// </summary>
|
||||||
|
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<Vector2>();
|
||||||
|
var colArray = new Godot.Collections.Array<Color>();
|
||||||
|
var radArray = new Godot.Collections.Array<float>();
|
||||||
|
var infArray = new Godot.Collections.Array<float>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serialises the light list as a compact JSON string.</summary>
|
||||||
|
public string ExportLightsJson()
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(Lights, Cthangover.Core.Utils.JsonOptionsProvider.WriteCompactOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Produces a <c>light_set</c> scenario DSL command with the lights JSON as its argument.</summary>
|
||||||
|
public string ExportScenarioSnippet()
|
||||||
|
{
|
||||||
|
var json = ExportLightsJson();
|
||||||
|
return $"light_set \"{json.Replace("\"", "\\\"")}\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Cthangover.Core.UI.Lights;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.LightEditor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Draggable 24×24 coloured handle representing a light source on the preview.
|
||||||
|
/// Overrides <c>_GuiInput</c> (Godot virtual) for mouse drag support; the colour
|
||||||
|
/// is taken from <see cref="LightDef.ToColor"/>. Raises <see cref="DragUpdate"/>
|
||||||
|
/// with the handle's centre position in global coordinates for the window to
|
||||||
|
/// convert back to normalised coordinates.
|
||||||
|
/// </summary>
|
||||||
|
public partial class LightEditorHandle : ColorRect
|
||||||
|
{
|
||||||
|
/// <summary>Index of the light in <see cref="LightEditorController.Lights"/>.</summary>
|
||||||
|
public int LightIndex;
|
||||||
|
/// <summary>Invoked during drag with the centre position in global coordinates.</summary>
|
||||||
|
public System.Action<Vector2> DragUpdate;
|
||||||
|
/// <summary>Invoked when the handle is clicked (mouse down).</summary>
|
||||||
|
public System.Action Clicked;
|
||||||
|
|
||||||
|
private bool dragging;
|
||||||
|
private Vector2 dragOffset;
|
||||||
|
|
||||||
|
/// <summary>Creates a 24×24 handle coloured to match the light's <see cref="LightDef.ToColor"/> value.</summary>
|
||||||
|
public LightEditorHandle(LightDef light, int index)
|
||||||
|
{
|
||||||
|
LightIndex = index;
|
||||||
|
Size = new Vector2(24, 24);
|
||||||
|
Color = light.ToColor();
|
||||||
|
MouseFilter = MouseFilterEnum.Stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Handles mouse drag and click via Godot's virtual input system (<c>_GuiInput</c>).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Cthangover.Core.UI.Tool;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.LightEditor
|
||||||
|
{
|
||||||
|
/// <summary>Registers the light editor as a tool window. Creates <see cref="LightEditorWindow"/> instances.</summary>
|
||||||
|
public class LightEditorToolProvider : IToolProvider
|
||||||
|
{
|
||||||
|
/// <summary>Unique tool identifier.</summary>
|
||||||
|
public string Id => "light_editor";
|
||||||
|
/// <summary>Translation key for the tool's display name.</summary>
|
||||||
|
public string LocaleKey => "tools/light_editor/title";
|
||||||
|
/// <summary>Creates the editor window instance.</summary>
|
||||||
|
public Window CreateWindow() => new LightEditorWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Toolbox button that opens the light editor. Always visible.</summary>
|
||||||
|
public class LightEditorToolBoxButton : IToolBoxButton
|
||||||
|
{
|
||||||
|
/// <summary>Matches <see cref="LightEditorToolProvider.Id"/>.</summary>
|
||||||
|
public string ToolId => "light_editor";
|
||||||
|
/// <summary>No custom icon — uses text label.</summary>
|
||||||
|
public string IconPath => "";
|
||||||
|
/// <summary>Translation key for the button label.</summary>
|
||||||
|
public string LocaleKey => "tools/light_editor/title";
|
||||||
|
/// <summary>Always visible in the toolbox.</summary>
|
||||||
|
public bool IsVisible() => true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="LightEditorController"/>
|
||||||
|
/// for data management and <see cref="ModResourceService"/> for background textures.
|
||||||
|
/// </summary>
|
||||||
|
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<LightEditorHandle> handles = new();
|
||||||
|
private int selectedIndex = -1;
|
||||||
|
|
||||||
|
private readonly List<string> bgIds = new();
|
||||||
|
|
||||||
|
/// <summary>Constructs the window, creates the controller, builds UI, and loads the background list.</summary>
|
||||||
|
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<List<LightDef>>(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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TabData> _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<string> TextTabSelected;
|
||||||
|
public event Action GraphTabSelected;
|
||||||
|
public event Action<bool> DirtyChanged;
|
||||||
|
public event Action<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Cthangover.Core.UI.Tool;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.ScenarioEditor
|
||||||
|
{
|
||||||
|
/// <summary>Registers the scenario editor as a tool window. Creates <see cref="ScenarioEditorWindow"/> instances.</summary>
|
||||||
|
public class ScenarioEditorToolProvider : IToolProvider
|
||||||
|
{
|
||||||
|
/// <summary>Unique tool identifier.</summary>
|
||||||
|
public string Id => "scenario_editor";
|
||||||
|
/// <summary>Translation key for the tool's display name.</summary>
|
||||||
|
public string LocaleKey => "tools/scenario_editor/title";
|
||||||
|
/// <summary>Creates the editor window instance.</summary>
|
||||||
|
public Window CreateWindow() => new ScenarioEditorWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SceneManager>("/root/SceneManager");
|
||||||
|
if (sceneManager != null)
|
||||||
|
{
|
||||||
|
sceneManager.Initialize();
|
||||||
|
sceneManager.PendingSceneName = metadata.Scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sceneService = GetNodeOrNull<GodotSceneService>("/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<GodotSceneService>("/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string> _fileItemToPath = new();
|
||||||
|
private readonly Dictionary<string, TreeItem> _pathToTreeItem = new();
|
||||||
|
private bool _isProgrammaticSelection;
|
||||||
|
|
||||||
|
public event Action<string> FileSelected;
|
||||||
|
|
||||||
|
public ScenarioFileTreePanel()
|
||||||
|
{
|
||||||
|
_fileTree = GetNode<Tree>("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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, GraphNode> _nodes;
|
||||||
|
private Dictionary<string, SceneGraphNode> _lookup;
|
||||||
|
|
||||||
|
public event Action<string> 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<string, GraphNode>();
|
||||||
|
_lookup = new Dictionary<string, SceneGraphNode>();
|
||||||
|
|
||||||
|
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<string, Vector2> SaveNodePositions()
|
||||||
|
{
|
||||||
|
var positions = new Dictionary<string, Vector2>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Label>("Scroll/VBox/HintScene").Text = TranslationServer.Translate("tools/scenario_editor/info_scene") + ":";
|
||||||
|
GetNode<Label>("Scroll/VBox/HintPriority").Text = TranslationServer.Translate("tools/scenario_editor/info_priority") + ":";
|
||||||
|
GetNode<Label>("Scroll/VBox/HintCondition").Text = TranslationServer.Translate("tools/scenario_editor/info_condition") + ":";
|
||||||
|
GetNode<Label>("Scroll/VBox/HintSwitchTargets").Text = TranslationServer.Translate("tools/scenario_editor/info_switch_targets") + ":";
|
||||||
|
GetNode<Label>("Scroll/VBox/HintLocaleKeys").Text = TranslationServer.Translate("tools/scenario_editor/info_locale_keys") + ":";
|
||||||
|
GetNode<Label>("Scroll/VBox/HintQuestRefs").Text = TranslationServer.Translate("tools/scenario_editor/info_quest_refs") + ":";
|
||||||
|
|
||||||
|
_infoScene = GetNode<Label>("Scroll/VBox/InfoScene");
|
||||||
|
_infoPriority = GetNode<Label>("Scroll/VBox/InfoPriority");
|
||||||
|
_infoCondition = GetNode<Label>("Scroll/VBox/InfoCondition");
|
||||||
|
_infoSwitchTargets = GetNode<Label>("Scroll/VBox/InfoSwitchTargets");
|
||||||
|
_infoLocaleKeys = GetNode<Label>("Scroll/VBox/InfoLocaleKeys");
|
||||||
|
_infoQuestRefs = GetNode<Label>("Scroll/VBox/InfoQuestRefs");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Update(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = ScenarioFileService.ParseMetadata(text);
|
||||||
|
var refs = ScenarioFileService.ExtractReferences(text);
|
||||||
|
|
||||||
|
_infoScene.Text = metadata.Scene;
|
||||||
|
_infoPriority.Text = metadata.Priority;
|
||||||
|
_infoCondition.Text = metadata.Condition;
|
||||||
|
_infoSwitchTargets.Text = refs.SwitchTargets.Count > 0 ? string.Join(", ", refs.SwitchTargets.Distinct()) : "-";
|
||||||
|
_infoLocaleKeys.Text = refs.LocaleKeys.Count > 0 ? string.Join(", ", refs.LocaleKeys.Distinct()) : "-";
|
||||||
|
_infoQuestRefs.Text = refs.QuestRefs.Count > 0 ? string.Join(", ", refs.QuestRefs.Distinct()) : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_infoScene.Text = "-";
|
||||||
|
_infoPriority.Text = "-";
|
||||||
|
_infoCondition.Text = "-";
|
||||||
|
_infoSwitchTargets.Text = "-";
|
||||||
|
_infoLocaleKeys.Text = "-";
|
||||||
|
_infoQuestRefs.Text = "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Tools.Services;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.ScenarioEditor
|
||||||
|
{
|
||||||
|
public partial class ScenarioThumbnailPanel : ScrollContainer
|
||||||
|
{
|
||||||
|
private HBoxContainer _thumbContainer;
|
||||||
|
|
||||||
|
public ScenarioThumbnailPanel()
|
||||||
|
{
|
||||||
|
_thumbContainer = GetNode<HBoxContainer>("ThumbContainer");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Load(string text)
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var refs = ScenarioFileService.ExtractReferences(text);
|
||||||
|
|
||||||
|
if (refs.BackgroundRefs.Count == 0)
|
||||||
|
{
|
||||||
|
_thumbContainer.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = TranslationServer.Translate("tools/scenario_editor/no_backgrounds")
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var id in refs.BackgroundRefs.Distinct())
|
||||||
|
{
|
||||||
|
var tex = ModResourceService.LoadBackgroundTexture(id);
|
||||||
|
|
||||||
|
if (tex != null)
|
||||||
|
{
|
||||||
|
_thumbContainer.AddChild(new TextureRect
|
||||||
|
{
|
||||||
|
Texture = tex,
|
||||||
|
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
|
||||||
|
StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered,
|
||||||
|
CustomMinimumSize = new Vector2(120, 90),
|
||||||
|
TooltipText = id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_thumbContainer.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = $"[? {id}]",
|
||||||
|
TooltipText = $"Not found: {id}"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public new void Clear()
|
||||||
|
{
|
||||||
|
foreach (var c in _thumbContainer.GetChildren())
|
||||||
|
c.QueueFree();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Declarative description of a single editable node property in the scene
|
||||||
|
/// builder inspector. Instances come from <see cref="InspectorPropertyCatalog"/>
|
||||||
|
/// and are consumed by <see cref="InspectorPanel"/> to build row editors.
|
||||||
|
/// <see cref="Key"/> doubles as the .tscn property name written back by
|
||||||
|
/// <see cref="TscnTextSurgery"/>, so only keys Godot's <c>Set()</c> understands
|
||||||
|
/// may appear here — the runtime builder applies unknown keys the same way,
|
||||||
|
/// which keeps live preview and saved file consistent.
|
||||||
|
/// </summary>
|
||||||
|
public class InspectableProperty
|
||||||
|
{
|
||||||
|
public string Key { get; set; }
|
||||||
|
public InspectorPropertyKind Kind { get; set; }
|
||||||
|
/// <summary>Whole-number mode: committed values are rounded and serialized as .tscn ints.</summary>
|
||||||
|
public bool IsInteger { get; set; }
|
||||||
|
public double Min { get; set; } = -100000d;
|
||||||
|
public double Max { get; set; } = 100000d;
|
||||||
|
public double Step { get; set; } = 1d;
|
||||||
|
public long[] EnumValues { get; set; }
|
||||||
|
public string[] EnumLabels { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>CheckBox editor for bool properties; commits the pressed state under the bound .tscn key.</summary>
|
||||||
|
public partial class InspectorBoolField : CheckBox
|
||||||
|
{
|
||||||
|
public event Action<string, bool> Committed;
|
||||||
|
|
||||||
|
private string _key;
|
||||||
|
|
||||||
|
public void Setup(InspectableProperty property, bool value)
|
||||||
|
{
|
||||||
|
_key = property.Key;
|
||||||
|
ButtonPressed = value;
|
||||||
|
Toggled += OnToggled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnToggled(bool pressed)
|
||||||
|
{
|
||||||
|
Committed?.Invoke(_key, pressed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>ColorPickerButton editor for color properties (modulate, self_modulate); commits RGBA including alpha edits.</summary>
|
||||||
|
public partial class InspectorColorField : ColorPickerButton
|
||||||
|
{
|
||||||
|
public event Action<string, Color> Committed;
|
||||||
|
|
||||||
|
private string _key;
|
||||||
|
|
||||||
|
public void Setup(InspectableProperty property, Color value)
|
||||||
|
{
|
||||||
|
_key = property.Key;
|
||||||
|
Color = value;
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
CustomMinimumSize = new Vector2(60f, 24f);
|
||||||
|
ColorChanged += OnColorChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnColorChanged(Color color)
|
||||||
|
{
|
||||||
|
Committed?.Invoke(_key, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// OptionButton-based editor for enum-valued properties (grow direction,
|
||||||
|
/// mouse filter, ...). Maps the selected list index back to the stored
|
||||||
|
/// numeric value from <see cref="InspectableProperty.EnumValues"/>; unknown
|
||||||
|
/// current values leave the dropdown unselected instead of lying about state.
|
||||||
|
/// </summary>
|
||||||
|
public partial class InspectorEnumField : OptionButton
|
||||||
|
{
|
||||||
|
public event Action<string, long> Committed;
|
||||||
|
|
||||||
|
private InspectableProperty _property;
|
||||||
|
|
||||||
|
public void Setup(InspectableProperty property, long currentValue)
|
||||||
|
{
|
||||||
|
_property = property;
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
|
||||||
|
for (var i = 0; i < property.EnumLabels.Length; i++)
|
||||||
|
AddItem(property.EnumLabels[i]);
|
||||||
|
|
||||||
|
Selected = IndexOfValue(currentValue);
|
||||||
|
ItemSelected += OnItemSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int IndexOfValue(long value)
|
||||||
|
{
|
||||||
|
if (_property.EnumValues == null)
|
||||||
|
return -1;
|
||||||
|
for (var i = 0; i < _property.EnumValues.Length; i++)
|
||||||
|
{
|
||||||
|
if (_property.EnumValues[i] == value)
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnItemSelected(long index)
|
||||||
|
{
|
||||||
|
if (_property.EnumValues != null && index >= 0 && index < _property.EnumValues.Length)
|
||||||
|
Committed?.Invoke(_property.Key, _property.EnumValues[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SpinBox-based inspector editor for scalar properties. Carries the .tscn
|
||||||
|
/// property key and re-emits committed values through <see cref="Committed"/>,
|
||||||
|
/// so the panel needs no per-widget closures (mod assemblies cannot rely on
|
||||||
|
/// Godot's source-generated signal wiring). Integer mode rounds away the
|
||||||
|
/// fractional part so enum-ish ints survive serialization.
|
||||||
|
/// </summary>
|
||||||
|
public partial class InspectorNumericField : SpinBox
|
||||||
|
{
|
||||||
|
public event Action<string, double> Committed;
|
||||||
|
|
||||||
|
private string _key;
|
||||||
|
private bool _integer;
|
||||||
|
|
||||||
|
public void Setup(InspectableProperty property, double initialValue)
|
||||||
|
{
|
||||||
|
_key = property.Key;
|
||||||
|
_integer = property.IsInteger;
|
||||||
|
MinValue = property.Min;
|
||||||
|
MaxValue = property.Max;
|
||||||
|
Step = property.Step;
|
||||||
|
Value = System.Math.Clamp(initialValue, property.Min, property.Max);
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
ValueChanged += OnValueChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnValueChanged(double value)
|
||||||
|
{
|
||||||
|
Committed?.Invoke(_key, _integer ? System.Math.Round(value) : value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Godot-editor-style property inspector for the scene builder tool. Shows
|
||||||
|
/// curated rows from <see cref="InspectorPropertyCatalog"/> for the selected
|
||||||
|
/// mod-scene node, applies every edit to the live node via <c>Set()</c> (so
|
||||||
|
/// the preview reacts immediately) and reports it through
|
||||||
|
/// <see cref="PropertyEdited"/> so the host window can patch the .tscn text.
|
||||||
|
/// Initial widget values come from <paramref name="fileValues"/> — the raw
|
||||||
|
/// literals stored in the file — because post-layout live values would show
|
||||||
|
/// computed sizes instead of authored anchors; keys present in the file are
|
||||||
|
/// marked with "*". Controls managed by a Container get a warning banner and
|
||||||
|
/// container-relevant rows only (anchors/offsets would be recomputed by the
|
||||||
|
/// parent every frame anyway). For Control nodes an anchors-preset dropdown
|
||||||
|
/// applies Godot's preset math, then rebuilds all rows so widgets reflect
|
||||||
|
/// the resulting anchor/offset values; the rebuild is deferred because it
|
||||||
|
/// frees widgets mid-signal. Host must call <see cref="ModWidget.EnsureConstructed"/>
|
||||||
|
/// after adding the panel to the tree (ModWidget contract).
|
||||||
|
/// </summary>
|
||||||
|
public partial class InspectorPanel : ModWidget
|
||||||
|
{
|
||||||
|
/// <summary>Raised after a live edit: (node, tscn property key, new value).</summary>
|
||||||
|
public event Action<Node, string, Variant> PropertyEdited;
|
||||||
|
|
||||||
|
private static readonly string[] PresetNames =
|
||||||
|
{
|
||||||
|
"Top Left", "Top Right", "Bottom Left", "Bottom Right",
|
||||||
|
"Center Left", "Center Top", "Center Right", "Center Bottom",
|
||||||
|
"Center", "Left Wide", "Top Wide", "Right Wide",
|
||||||
|
"Bottom Wide", "VCenter Wide", "HCenter Wide", "Full Rect"
|
||||||
|
};
|
||||||
|
|
||||||
|
private Node _node;
|
||||||
|
private bool _suppress;
|
||||||
|
private readonly Dictionary<string, InspectableProperty> _propertiesByKey = new();
|
||||||
|
|
||||||
|
private Label _header;
|
||||||
|
private Label _hint;
|
||||||
|
private Label _banner;
|
||||||
|
private HBoxContainer _presetRow;
|
||||||
|
private OptionButton _presetDropdown;
|
||||||
|
private VBoxContainer _rows;
|
||||||
|
|
||||||
|
protected override void Construct()
|
||||||
|
{
|
||||||
|
var root = new VBoxContainer();
|
||||||
|
root.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
AddChild(root);
|
||||||
|
|
||||||
|
_hint = new Label();
|
||||||
|
_hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||||
|
_hint.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
_hint.Visible = false;
|
||||||
|
root.AddChild(_hint);
|
||||||
|
|
||||||
|
_header = new Label();
|
||||||
|
_header.Visible = false;
|
||||||
|
root.AddChild(_header);
|
||||||
|
|
||||||
|
_banner = new Label();
|
||||||
|
_banner.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||||
|
_banner.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
_banner.Modulate = new Color(1f, 0.75f, 0.3f);
|
||||||
|
_banner.Visible = false;
|
||||||
|
root.AddChild(_banner);
|
||||||
|
|
||||||
|
_presetRow = new HBoxContainer();
|
||||||
|
_presetRow.Visible = false;
|
||||||
|
root.AddChild(_presetRow);
|
||||||
|
|
||||||
|
var presetLabel = new Label
|
||||||
|
{
|
||||||
|
Text = TranslationServer.Translate("tools/scene_builder/inspector_preset"),
|
||||||
|
TooltipText = "anchors_preset"
|
||||||
|
};
|
||||||
|
presetLabel.CustomMinimumSize = new Vector2(110f, 0f);
|
||||||
|
_presetRow.AddChild(presetLabel);
|
||||||
|
|
||||||
|
_presetDropdown = new OptionButton();
|
||||||
|
_presetDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
for (var i = 0; i < PresetNames.Length; i++)
|
||||||
|
_presetDropdown.AddItem(PresetNames[i]);
|
||||||
|
_presetDropdown.ItemSelected += OnPresetSelected;
|
||||||
|
_presetRow.AddChild(_presetDropdown);
|
||||||
|
|
||||||
|
_rows = new VBoxContainer();
|
||||||
|
_rows.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
_rows.AddThemeConstantOverride("separation", 2);
|
||||||
|
root.AddChild(_rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows editable rows for a mod-scene node. <paramref name="fileValues"/>
|
||||||
|
/// carries the raw tscn literals of this node's section (null when the
|
||||||
|
/// section is not in the file yet); they seed widget values and mark
|
||||||
|
/// file-stored keys.
|
||||||
|
/// </summary>
|
||||||
|
public void ShowNode(Node node, IReadOnlyDictionary<string, string> fileValues)
|
||||||
|
{
|
||||||
|
EnsureConstructed();
|
||||||
|
_suppress = true;
|
||||||
|
_node = node;
|
||||||
|
|
||||||
|
ClearBody();
|
||||||
|
_propertiesByKey.Clear();
|
||||||
|
|
||||||
|
_hint.Visible = false;
|
||||||
|
_header.Visible = true;
|
||||||
|
_header.Text = node.Name + " (" + node.GetClass() + ")";
|
||||||
|
|
||||||
|
var insideContainer = node is Control && node.GetParent() is Container;
|
||||||
|
_banner.Visible = insideContainer;
|
||||||
|
if (insideContainer)
|
||||||
|
_banner.Text = TranslationServer.Translate("tools/scene_builder/inspector_container_banner");
|
||||||
|
|
||||||
|
var isControl = node is Control;
|
||||||
|
_presetRow.Visible = isControl && !insideContainer;
|
||||||
|
_presetDropdown.Selected = -1;
|
||||||
|
|
||||||
|
foreach (var entry in InspectorPropertyCatalog.Build(node, insideContainer))
|
||||||
|
{
|
||||||
|
var categoryLabel = new Label { Text = TranslationServer.Translate(entry.CategoryKey) };
|
||||||
|
categoryLabel.Modulate = new Color(1f, 0.85f, 0.4f);
|
||||||
|
_rows.AddChild(categoryLabel);
|
||||||
|
|
||||||
|
foreach (var property in entry.Properties)
|
||||||
|
{
|
||||||
|
_propertiesByKey[property.Key] = property;
|
||||||
|
AddPropertyRow(property, fileValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_suppress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Placeholder view (hint/read-only notice) when no editable node is selected.</summary>
|
||||||
|
public void ShowMessage(string message)
|
||||||
|
{
|
||||||
|
EnsureConstructed();
|
||||||
|
_suppress = true;
|
||||||
|
_node = null;
|
||||||
|
_propertiesByKey.Clear();
|
||||||
|
|
||||||
|
ClearBody();
|
||||||
|
_header.Visible = false;
|
||||||
|
_presetRow.Visible = false;
|
||||||
|
_banner.Visible = false;
|
||||||
|
_hint.Visible = true;
|
||||||
|
_hint.Text = message;
|
||||||
|
_suppress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddPropertyRow(InspectableProperty property, IReadOnlyDictionary<string, string> fileValues)
|
||||||
|
{
|
||||||
|
var storedInFile = fileValues != null && fileValues.ContainsKey(property.Key);
|
||||||
|
string rawValue = null;
|
||||||
|
if (storedInFile)
|
||||||
|
fileValues.TryGetValue(property.Key, out rawValue);
|
||||||
|
|
||||||
|
var row = new HBoxContainer();
|
||||||
|
|
||||||
|
var labelText = Humanize(property.Key);
|
||||||
|
if (storedInFile)
|
||||||
|
labelText += " *";
|
||||||
|
var label = new Label { Text = labelText, TooltipText = BuildTooltip(property.Key, storedInFile) };
|
||||||
|
label.CustomMinimumSize = new Vector2(110f, 0f);
|
||||||
|
label.SizeFlagsVertical = Control.SizeFlags.ShrinkCenter;
|
||||||
|
row.AddChild(label);
|
||||||
|
|
||||||
|
switch (property.Kind)
|
||||||
|
{
|
||||||
|
case InspectorPropertyKind.Numeric:
|
||||||
|
{
|
||||||
|
var initial = rawValue != null && TscnLiteralReader.TryReadFloat(rawValue, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: ReadDouble(property.Key);
|
||||||
|
var field = new InspectorNumericField();
|
||||||
|
field.Setup(property, initial);
|
||||||
|
field.Committed += OnNumericCommitted;
|
||||||
|
row.AddChild(field);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case InspectorPropertyKind.Vector2:
|
||||||
|
{
|
||||||
|
var initial = rawValue != null && TscnLiteralReader.TryReadVector2(rawValue, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: ReadVector2(property.Key);
|
||||||
|
var field = new InspectorVector2Field();
|
||||||
|
field.Setup(property, initial);
|
||||||
|
field.Committed += OnVectorCommitted;
|
||||||
|
row.AddChild(field);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case InspectorPropertyKind.Bool:
|
||||||
|
{
|
||||||
|
var initial = rawValue != null && TscnLiteralReader.TryReadBool(rawValue, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: ReadBool(property.Key);
|
||||||
|
var field = new InspectorBoolField();
|
||||||
|
field.Setup(property, initial);
|
||||||
|
field.Committed += OnBoolCommitted;
|
||||||
|
row.AddChild(field);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case InspectorPropertyKind.String:
|
||||||
|
{
|
||||||
|
var initial = rawValue != null ? TscnLiteralReader.ReadString(rawValue) : null;
|
||||||
|
if (initial == null)
|
||||||
|
initial = ReadString(property.Key);
|
||||||
|
var field = new InspectorTextField();
|
||||||
|
field.Setup(property, initial);
|
||||||
|
field.Committed += OnTextCommitted;
|
||||||
|
row.AddChild(field);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case InspectorPropertyKind.Color:
|
||||||
|
{
|
||||||
|
var initial = rawValue != null && TscnLiteralReader.TryReadColor(rawValue, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: ReadColor(property.Key);
|
||||||
|
var field = new InspectorColorField();
|
||||||
|
field.Setup(property, initial);
|
||||||
|
field.Committed += OnColorCommitted;
|
||||||
|
row.AddChild(field);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case InspectorPropertyKind.Enum:
|
||||||
|
{
|
||||||
|
var initial = rawValue != null && TscnLiteralReader.TryReadLong(rawValue, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: ReadLong(property.Key);
|
||||||
|
var field = new InspectorEnumField();
|
||||||
|
field.Setup(property, initial);
|
||||||
|
field.Committed += OnEnumCommitted;
|
||||||
|
row.AddChild(field);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_rows.AddChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTooltip(string key, bool storedInFile)
|
||||||
|
{
|
||||||
|
return storedInFile
|
||||||
|
? key + "\n" + TranslationServer.Translate("tools/scene_builder/inspector_in_file_tooltip")
|
||||||
|
: key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPresetSelected(long index)
|
||||||
|
{
|
||||||
|
if (_suppress || !(_node is Control control))
|
||||||
|
return;
|
||||||
|
|
||||||
|
control.SetAnchorsAndOffsetsPreset((Control.LayoutPreset)index, Control.LayoutPresetMode.KeepSize);
|
||||||
|
|
||||||
|
if (ReadLong("layout_mode") != 1)
|
||||||
|
EmitChange("layout_mode", Variant.From(1L));
|
||||||
|
|
||||||
|
EmitChange("anchor_left", Variant.From(control.AnchorLeft));
|
||||||
|
EmitChange("anchor_top", Variant.From(control.AnchorTop));
|
||||||
|
EmitChange("anchor_right", Variant.From(control.AnchorRight));
|
||||||
|
EmitChange("anchor_bottom", Variant.From(control.AnchorBottom));
|
||||||
|
EmitChange("offset_left", Variant.From(control.OffsetLeft));
|
||||||
|
EmitChange("offset_top", Variant.From(control.OffsetTop));
|
||||||
|
EmitChange("offset_right", Variant.From(control.OffsetRight));
|
||||||
|
EmitChange("offset_bottom", Variant.From(control.OffsetBottom));
|
||||||
|
EmitChange("grow_horizontal", Variant.From((long)(int)control.GrowHorizontal));
|
||||||
|
EmitChange("grow_vertical", Variant.From((long)(int)control.GrowVertical));
|
||||||
|
|
||||||
|
// Deferred: this handler runs inside the dropdown's own signal and the
|
||||||
|
// resync frees the widget tree being iterated.
|
||||||
|
CallDeferred(nameof(ResyncRows));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResyncRows()
|
||||||
|
{
|
||||||
|
if (_node == null || !GodotObject.IsInstanceValid(_node))
|
||||||
|
return;
|
||||||
|
// File values may be stale here; the host refreshes rows through
|
||||||
|
// ShowNode after committing text, so a plain rebuild suffices.
|
||||||
|
ShowNode(_node, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitChange(string key, Variant value)
|
||||||
|
{
|
||||||
|
if (_suppress || _node == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_node.Set(key, value);
|
||||||
|
PropertyEdited?.Invoke(_node, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNumericCommitted(string key, double value)
|
||||||
|
{
|
||||||
|
var isInteger = _propertiesByKey.TryGetValue(key, out var prop) && prop.IsInteger;
|
||||||
|
EmitChange(key, isInteger ? Variant.From((long)System.Math.Round(value)) : Variant.From(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnVectorCommitted(string key, Vector2 value)
|
||||||
|
{
|
||||||
|
EmitChange(key, Variant.From(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBoolCommitted(string key, bool pressed)
|
||||||
|
{
|
||||||
|
EmitChange(key, Variant.From(pressed));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTextCommitted(string key, string text)
|
||||||
|
{
|
||||||
|
EmitChange(key, Variant.From(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnColorCommitted(string key, Color color)
|
||||||
|
{
|
||||||
|
EmitChange(key, Variant.From(color));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnumCommitted(string key, long value)
|
||||||
|
{
|
||||||
|
EmitChange(key, Variant.From(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearBody()
|
||||||
|
{
|
||||||
|
foreach (var child in _rows.GetChildren())
|
||||||
|
{
|
||||||
|
_rows.RemoveChild(child);
|
||||||
|
child.Free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ReadDouble(string key)
|
||||||
|
{
|
||||||
|
var v = _node.Get(key);
|
||||||
|
return v.VariantType == Variant.Type.Nil ? 0d : v.AsDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long ReadLong(string key)
|
||||||
|
{
|
||||||
|
var v = _node.Get(key);
|
||||||
|
if (v.VariantType == Variant.Type.Int)
|
||||||
|
return v.AsInt64();
|
||||||
|
if (v.VariantType == Variant.Type.Float)
|
||||||
|
return (long)v.AsDouble();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ReadBool(string key)
|
||||||
|
{
|
||||||
|
var v = _node.Get(key);
|
||||||
|
return v.VariantType != Variant.Type.Nil && v.AsBool();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ReadString(string key)
|
||||||
|
{
|
||||||
|
var v = _node.Get(key);
|
||||||
|
return v.VariantType == Variant.Type.Nil ? "" : v.AsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Color ReadColor(string key)
|
||||||
|
{
|
||||||
|
var v = _node.Get(key);
|
||||||
|
return v.VariantType == Variant.Type.Nil ? Colors.White : v.AsColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Vector2 ReadVector2(string key)
|
||||||
|
{
|
||||||
|
var v = _node.Get(key);
|
||||||
|
return v.VariantType == Variant.Type.Nil ? Vector2.Zero : v.AsVector2();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Humanize(string key)
|
||||||
|
{
|
||||||
|
var parts = key.Split('_');
|
||||||
|
for (var i = 0; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
if (parts[i].Length == 0)
|
||||||
|
continue;
|
||||||
|
parts[i] = char.ToUpperInvariant(parts[i][0]) + parts[i].Substring(1);
|
||||||
|
}
|
||||||
|
return string.Join(" ", parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Curated property lists shown by the scene builder inspector, mirroring the
|
||||||
|
/// Godot editor's most-used entries. Deliberately hardcoded instead of derived
|
||||||
|
/// from <c>ClassDB</c>: the runtime tscn builder ignores theme overrides and
|
||||||
|
/// metadata, so a fixed allow-list guarantees every visible property is both
|
||||||
|
/// live-applied via <c>Set()</c> and faithfully written back to the file.
|
||||||
|
/// Two shapes: a free Control gets the layout/rect/visibility/misc set; a
|
||||||
|
/// Control managed by a Container ignores anchors/offsets entirely (the parent
|
||||||
|
/// recomputes them every frame), so it gets container-relevant fields instead —
|
||||||
|
/// otherwise the inspector would show editable-looking values that do nothing,
|
||||||
|
/// which is exactly how anchored scenes looked "wrong" before file-truth init.
|
||||||
|
/// </summary>
|
||||||
|
public static class InspectorPropertyCatalog
|
||||||
|
{
|
||||||
|
/// <summary>Builds the ordered (categoryKey, properties) groups for the given node.</summary>
|
||||||
|
public static List<(string CategoryKey, List<InspectableProperty> Properties)> Build(Node node, bool insideContainer)
|
||||||
|
{
|
||||||
|
var result = new List<(string, List<InspectableProperty>)>();
|
||||||
|
|
||||||
|
if (node is Control)
|
||||||
|
{
|
||||||
|
if (insideContainer)
|
||||||
|
{
|
||||||
|
result.Add(("tools/scene_builder/inspector_cat_container", BuildContainer()));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Add(("tools/scene_builder/inspector_cat_layout", BuildLayout()));
|
||||||
|
result.Add(("tools/scene_builder/inspector_cat_rect", BuildRect()));
|
||||||
|
}
|
||||||
|
result.Add(("tools/scene_builder/inspector_cat_visibility", BuildControlVisibility()));
|
||||||
|
result.Add(("tools/scene_builder/inspector_cat_misc", BuildMisc()));
|
||||||
|
}
|
||||||
|
else if (node is CanvasItem)
|
||||||
|
{
|
||||||
|
result.Add(("tools/scene_builder/inspector_cat_visibility", BuildCanvasItemVisibility()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<InspectableProperty> BuildContainer()
|
||||||
|
{
|
||||||
|
return new List<InspectableProperty>
|
||||||
|
{
|
||||||
|
Int("size_flags_horizontal", 0, 15),
|
||||||
|
Int("size_flags_vertical", 0, 15),
|
||||||
|
Vector2("custom_minimum_size", 0d, 16384d, 1d)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<InspectableProperty> BuildLayout()
|
||||||
|
{
|
||||||
|
return new List<InspectableProperty>
|
||||||
|
{
|
||||||
|
Enum("layout_mode", new long[] { 0, 1, 2, 3 },
|
||||||
|
new[] { "Position", "Anchors", "Container", "Uncontrolled" }),
|
||||||
|
Float("anchor_left", -8d, 8d, 0.01d),
|
||||||
|
Float("anchor_top", -8d, 8d, 0.01d),
|
||||||
|
Float("anchor_right", -8d, 8d, 0.01d),
|
||||||
|
Float("anchor_bottom", -8d, 8d, 0.01d),
|
||||||
|
Enum("grow_horizontal", new long[] { 0, 1, 2 }, new[] { "Begin", "End", "Both" }),
|
||||||
|
Enum("grow_vertical", new long[] { 0, 1, 2 }, new[] { "Begin", "End", "Both" }),
|
||||||
|
Float("offset_left", -100000d, 100000d, 1d),
|
||||||
|
Float("offset_top", -100000d, 100000d, 1d),
|
||||||
|
Float("offset_right", -100000d, 100000d, 1d),
|
||||||
|
Float("offset_bottom", -100000d, 100000d, 1d)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<InspectableProperty> BuildRect()
|
||||||
|
{
|
||||||
|
return new List<InspectableProperty>
|
||||||
|
{
|
||||||
|
Vector2("custom_minimum_size", 0d, 16384d, 1d),
|
||||||
|
Vector2("position", -100000d, 100000d, 1d),
|
||||||
|
Vector2("size", 0d, 100000d, 1d),
|
||||||
|
Float("rotation", -36000d, 36000d, 1d),
|
||||||
|
Vector2("scale", -64d, 64d, 0.05d),
|
||||||
|
Vector2("pivot_offset", -100000d, 100000d, 1d)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<InspectableProperty> BuildControlVisibility()
|
||||||
|
{
|
||||||
|
return new List<InspectableProperty>
|
||||||
|
{
|
||||||
|
Bool("visible"),
|
||||||
|
Color("modulate"),
|
||||||
|
Color("self_modulate"),
|
||||||
|
Bool("clip_contents")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<InspectableProperty> BuildCanvasItemVisibility()
|
||||||
|
{
|
||||||
|
return new List<InspectableProperty>
|
||||||
|
{
|
||||||
|
Bool("visible"),
|
||||||
|
Color("modulate"),
|
||||||
|
Color("self_modulate")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<InspectableProperty> BuildMisc()
|
||||||
|
{
|
||||||
|
return new List<InspectableProperty>
|
||||||
|
{
|
||||||
|
Enum("mouse_filter", new long[] { 0, 1, 2 }, new[] { "Stop", "Pass", "Ignore" }),
|
||||||
|
Enum("focus_mode", new long[] { 0, 1, 2 }, new[] { "None", "Click", "All" }),
|
||||||
|
Text("tooltip_text")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InspectableProperty Float(string key, double min, double max, double step)
|
||||||
|
{
|
||||||
|
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.Numeric, Min = min, Max = max, Step = step };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InspectableProperty Int(string key, int min, int max)
|
||||||
|
{
|
||||||
|
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.Numeric, IsInteger = true, Min = min, Max = max, Step = 1d };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InspectableProperty Vector2(string key, double min, double max, double step)
|
||||||
|
{
|
||||||
|
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.Vector2, Min = min, Max = max, Step = step };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InspectableProperty Bool(string key)
|
||||||
|
{
|
||||||
|
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.Bool };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InspectableProperty Text(string key)
|
||||||
|
{
|
||||||
|
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.String };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InspectableProperty Color(string key)
|
||||||
|
{
|
||||||
|
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.Color };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InspectableProperty Enum(string key, long[] values, string[] labels)
|
||||||
|
{
|
||||||
|
return new InspectableProperty { Key = key, Kind = InspectorPropertyKind.Enum, IsInteger = true, EnumValues = values, EnumLabels = labels };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Editing widget category for one inspector property. Drives which concrete
|
||||||
|
/// field control <see cref="InspectorPanel"/> instantiates and which Variant
|
||||||
|
/// shape is emitted back through <see cref="InspectorPanel.PropertyEdited"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum InspectorPropertyKind { Numeric, Vector2, Bool, String, Color, Enum }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>LineEdit editor for string properties (tooltip_text); every keystroke commits, matching the live-preview editing model.</summary>
|
||||||
|
public partial class InspectorTextField : LineEdit
|
||||||
|
{
|
||||||
|
public event Action<string, string> Committed;
|
||||||
|
|
||||||
|
private string _key;
|
||||||
|
|
||||||
|
public void Setup(InspectableProperty property, string value)
|
||||||
|
{
|
||||||
|
_key = property.Key;
|
||||||
|
Text = value ?? "";
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
TextChanged += OnTextChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTextChanged(string text)
|
||||||
|
{
|
||||||
|
Committed?.Invoke(_key, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Paired x/y SpinBox row for Vector2 properties. Both axes share one commit
|
||||||
|
/// channel: any axis change emits the full current vector, which keeps the
|
||||||
|
/// live node, the preview and the .tscn text in sync on every keystroke.
|
||||||
|
/// </summary>
|
||||||
|
public partial class InspectorVector2Field : HBoxContainer
|
||||||
|
{
|
||||||
|
public event Action<string, Vector2> Committed;
|
||||||
|
|
||||||
|
private string _key;
|
||||||
|
private SpinBox _x;
|
||||||
|
private SpinBox _y;
|
||||||
|
|
||||||
|
public void Setup(InspectableProperty property, Vector2 initialValue)
|
||||||
|
{
|
||||||
|
_key = property.Key;
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
|
||||||
|
AddChild(new Label { Text = "x", CustomMinimumSize = new Vector2(14f, 0f) });
|
||||||
|
_x = CreateAxis(property, initialValue.X);
|
||||||
|
AddChild(new Label { Text = "y", CustomMinimumSize = new Vector2(14f, 0f) });
|
||||||
|
_y = CreateAxis(property, initialValue.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpinBox CreateAxis(InspectableProperty property, double initial)
|
||||||
|
{
|
||||||
|
var box = new InspectorNumericField();
|
||||||
|
box.Setup(property, initial);
|
||||||
|
box.CustomMinimumSize = new Vector2(60f, 0f);
|
||||||
|
box.Committed += OnAxisChanged;
|
||||||
|
AddChild(box);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAxisChanged(string key, double value)
|
||||||
|
{
|
||||||
|
Committed?.Invoke(_key, new Vector2((float)_x.Value, (float)_y.Value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Scenes;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Controller for the scene builder tool. Composes a preview from a base
|
||||||
|
/// scene (a native Godot scene from <c>res://scenes</c>, or an empty root)
|
||||||
|
/// and a mod-provided <c>.tscn</c> scene layered on top of it via the
|
||||||
|
/// runtime tscn pipeline (<see cref="TscnSceneResolver"/>). Builds an
|
||||||
|
/// interactive node hierarchy tree, tracks selection for the highlight
|
||||||
|
/// overlay, and reads/writes the raw tscn source so the tool can edit and
|
||||||
|
/// save mod scenes in place. Mod scene listing is sourced from
|
||||||
|
/// <see cref="TscnSceneResolver.Collect"/> (deduplicated by mod load order),
|
||||||
|
/// while base scenes come from <see cref="ModScenes.CollectTscnFiles"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class SceneBuilderController
|
||||||
|
{
|
||||||
|
private const string ResScenesPrefix = "res://scenes/";
|
||||||
|
|
||||||
|
private Node _loadedRoot;
|
||||||
|
private Node _modRoot;
|
||||||
|
private ColorRect _highlight;
|
||||||
|
private readonly Dictionary<Node, TreeItem> _nodeToItem = new();
|
||||||
|
private readonly Dictionary<TreeItem, Node> _itemToNode = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the native base scenes available under <c>res://scenes</c> as
|
||||||
|
/// (Display, Path) tuples. Display is the path relative to
|
||||||
|
/// <c>res://scenes/</c> so nested scenes remain distinguishable.
|
||||||
|
/// </summary>
|
||||||
|
public List<(string Display, string Path)> GetBaseSceneList()
|
||||||
|
{
|
||||||
|
var result = new List<(string, string)>();
|
||||||
|
foreach (var (_, path) in ModScenes.CollectTscnFiles())
|
||||||
|
{
|
||||||
|
var display = path;
|
||||||
|
if (display.StartsWith(ResScenesPrefix, StringComparison.Ordinal))
|
||||||
|
display = display.Substring(ResScenesPrefix.Length);
|
||||||
|
result.Add((display, path));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns every mod-provided tscn scene as (ModId, Name, RelativePath)
|
||||||
|
/// tuples, deduplicated by mod load order. RelativePath is the key used
|
||||||
|
/// by <see cref="TscnSceneResolver.LoadAndBuild"/> (e.g. "scenes/foo.tscn").
|
||||||
|
/// </summary>
|
||||||
|
public List<(string ModId, string Name, string RelativePath)> GetModSceneIndex()
|
||||||
|
{
|
||||||
|
var result = new List<(string, string, string)>();
|
||||||
|
var resolver = ModManager.Instance.GetResolver<TscnSceneResolver>();
|
||||||
|
var index = resolver?.Collect();
|
||||||
|
if (index == null)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
foreach (var kvp in index)
|
||||||
|
{
|
||||||
|
var relativePath = kvp.Key;
|
||||||
|
var modId = kvp.Value.ModId;
|
||||||
|
result.Add((modId, Path.GetFileName(relativePath), relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
var modCompare = string.Compare(a.Item1, b.Item1, StringComparison.OrdinalIgnoreCase);
|
||||||
|
return modCompare != 0 ? modCompare : string.Compare(a.Item3, b.Item3, StringComparison.OrdinalIgnoreCase);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a native base scene into <paramref name="viewport"/> as the root.
|
||||||
|
/// Replaces any previously loaded scene and attaches the selection
|
||||||
|
/// highlight overlay.
|
||||||
|
/// </summary>
|
||||||
|
public Node LoadBaseScene(string path, SubViewport viewport)
|
||||||
|
{
|
||||||
|
UnloadScene();
|
||||||
|
|
||||||
|
var packed = GD.Load<PackedScene>(path);
|
||||||
|
if (packed == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var instance = packed.Instantiate();
|
||||||
|
viewport.AddChild(instance);
|
||||||
|
AttachHighlight(instance);
|
||||||
|
|
||||||
|
_loadedRoot = instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads an empty full-rect <see cref="Control"/> as the preview root,
|
||||||
|
/// used when no base scene is selected so mod scenes still have a parent.
|
||||||
|
/// </summary>
|
||||||
|
public Node LoadEmptyScene(SubViewport viewport)
|
||||||
|
{
|
||||||
|
UnloadScene();
|
||||||
|
|
||||||
|
var root = new Control { Name = "EmptyScene" };
|
||||||
|
root.AnchorRight = 1f;
|
||||||
|
root.AnchorBottom = 1f;
|
||||||
|
viewport.AddChild(root);
|
||||||
|
AttachHighlight(root);
|
||||||
|
|
||||||
|
_loadedRoot = root;
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a mod scene via the runtime tscn pipeline and adds it as a child
|
||||||
|
/// of the current base root. Returns the built node, or null if there is
|
||||||
|
/// no base root or the scene could not be resolved.
|
||||||
|
/// </summary>
|
||||||
|
public Node LoadModScene(string relativePath)
|
||||||
|
{
|
||||||
|
UnloadModScene();
|
||||||
|
if (_loadedRoot == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var node = TscnScenes.LoadAndBuild(relativePath);
|
||||||
|
if (node == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
_loadedRoot.AddChild(node);
|
||||||
|
_modRoot = node;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the raw tscn source of a mod scene through its file provider.</summary>
|
||||||
|
public string ReadSceneText(string modId, string relativePath)
|
||||||
|
{
|
||||||
|
return ModManager.Instance.ReadFileText(modId, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the edited tscn source back to the mod on disk. Returns false
|
||||||
|
/// when the mod has no filesystem backing (e.g. zip mods).
|
||||||
|
/// </summary>
|
||||||
|
public bool SaveSceneText(string modId, string relativePath, string text)
|
||||||
|
{
|
||||||
|
var absolutePath = ModManager.Instance.GetFileSystemPath(modId, relativePath);
|
||||||
|
if (string.IsNullOrEmpty(absolutePath))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
File.WriteAllText(absolutePath, text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates the resolver cache for one scene so a subsequent reload
|
||||||
|
/// re-parses the edited file instead of returning the cached build.
|
||||||
|
/// </summary>
|
||||||
|
public void InvalidateScene(string relativePath)
|
||||||
|
{
|
||||||
|
ModManager.Instance.GetResolver<TscnSceneResolver>()?.Invalidate(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Root node of the loaded mod scene; null when no mod scene is loaded.</summary>
|
||||||
|
public Node GetModRoot()
|
||||||
|
{
|
||||||
|
return _modRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a live instance of the given Godot class under the parent with
|
||||||
|
/// a sibling-unique name and returns it, or null when ClassDB cannot
|
||||||
|
/// instantiate the type. The caller is responsible for the matching tscn
|
||||||
|
/// write-back (and for rolling the live node back if that fails).
|
||||||
|
/// </summary>
|
||||||
|
public Node CreateUniqueChild(Node parentNode, string typeName)
|
||||||
|
{
|
||||||
|
if (parentNode == null || string.IsNullOrEmpty(typeName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var instance = ClassDB.Instantiate(typeName).As<Node>();
|
||||||
|
if (instance == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var name = typeName;
|
||||||
|
var suffix = 2;
|
||||||
|
while (parentNode.HasNode(name))
|
||||||
|
name = typeName + suffix++;
|
||||||
|
|
||||||
|
instance.Name = name;
|
||||||
|
parentNode.AddChild(instance);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detaches the node from the preview tree immediately (so hierarchy
|
||||||
|
/// rebuilds right away don't show a ghost) and queues it for deletion.
|
||||||
|
/// Returns false for nodes outside the mod subtree or the mod root itself.
|
||||||
|
/// </summary>
|
||||||
|
public bool DeleteModNode(Node node)
|
||||||
|
{
|
||||||
|
if (!IsEditableModNode(node) || node == _modRoot)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var parent = node.GetParent();
|
||||||
|
parent?.RemoveChild(node);
|
||||||
|
node.QueueFree();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Frees the loaded scene root (and its mod scene child) and clears mappings.</summary>
|
||||||
|
public void UnloadScene()
|
||||||
|
{
|
||||||
|
_nodeToItem.Clear();
|
||||||
|
_itemToNode.Clear();
|
||||||
|
_highlight = null;
|
||||||
|
_modRoot = null;
|
||||||
|
|
||||||
|
if (_loadedRoot != null && GodotObject.IsInstanceValid(_loadedRoot))
|
||||||
|
_loadedRoot.QueueFree();
|
||||||
|
|
||||||
|
_loadedRoot = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnloadModScene()
|
||||||
|
{
|
||||||
|
if (_modRoot == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_loadedRoot != null && GodotObject.IsInstanceValid(_loadedRoot) && _modRoot.GetParent() == _loadedRoot)
|
||||||
|
_loadedRoot.RemoveChild(_modRoot);
|
||||||
|
|
||||||
|
if (GodotObject.IsInstanceValid(_modRoot))
|
||||||
|
_modRoot.QueueFree();
|
||||||
|
|
||||||
|
_modRoot = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AttachHighlight(Node root)
|
||||||
|
{
|
||||||
|
_highlight = new ColorRect();
|
||||||
|
_highlight.Color = new Color(1f, 0.7f, 0f, 0.35f);
|
||||||
|
_highlight.Visible = false;
|
||||||
|
_highlight.MouseFilter = Control.MouseFilterEnum.Ignore;
|
||||||
|
_highlight.ZIndex = 4096;
|
||||||
|
root.AddChild(_highlight);
|
||||||
|
_highlight.Owner = root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recursively walks the loaded scene tree and populates the given
|
||||||
|
/// <see cref="Tree"/> control with nodes labelled <c>Name (Type)</c>.
|
||||||
|
/// Skips the internal highlight rect.
|
||||||
|
/// </summary>
|
||||||
|
public void BuildHierarchy(Tree tree)
|
||||||
|
{
|
||||||
|
tree.Clear();
|
||||||
|
_nodeToItem.Clear();
|
||||||
|
_itemToNode.Clear();
|
||||||
|
|
||||||
|
if (_loadedRoot == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var treeRoot = tree.CreateItem();
|
||||||
|
tree.HideRoot = true;
|
||||||
|
AddNodeToTree(tree, treeRoot, _loadedRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddNodeToTree(Tree tree, TreeItem parent, Node node)
|
||||||
|
{
|
||||||
|
var item = tree.CreateItem(parent);
|
||||||
|
var typeName = node.GetType().Name;
|
||||||
|
item.SetText(0, $"{node.Name} ({typeName})");
|
||||||
|
item.SetSelectable(0, true);
|
||||||
|
|
||||||
|
_nodeToItem[node] = item;
|
||||||
|
_itemToNode[item] = node;
|
||||||
|
|
||||||
|
for (int i = 0; i < node.GetChildCount(); i++)
|
||||||
|
{
|
||||||
|
var child = node.GetChild(i);
|
||||||
|
if (child != _highlight)
|
||||||
|
AddNodeToTree(tree, item, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Highlights the selected node by positioning the highlight rect over it.
|
||||||
|
/// For <see cref="Control"/> nodes, uses <c>GetGlobalRect()</c>; for others,
|
||||||
|
/// approximates the position via parent chain traversal. Hides the highlight
|
||||||
|
/// if the root node itself is selected.
|
||||||
|
/// </summary>
|
||||||
|
public void SelectNode(TreeItem item)
|
||||||
|
{
|
||||||
|
if (_highlight == null || item == null)
|
||||||
|
{
|
||||||
|
HideHighlight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_itemToNode.TryGetValue(item, out var node))
|
||||||
|
{
|
||||||
|
HideHighlight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node == _loadedRoot)
|
||||||
|
{
|
||||||
|
HideHighlight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var control = node as Control;
|
||||||
|
if (control != null)
|
||||||
|
{
|
||||||
|
var rect = control.GetGlobalRect();
|
||||||
|
_highlight.Position = rect.Position;
|
||||||
|
_highlight.Size = rect.Size;
|
||||||
|
_highlight.Visible = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var pos = new Vector2(0, 0);
|
||||||
|
var current = node;
|
||||||
|
while (current != _loadedRoot && current != null)
|
||||||
|
{
|
||||||
|
if (current is Control c)
|
||||||
|
{
|
||||||
|
pos += c.GetGlobalRect().Position;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = current.GetParent();
|
||||||
|
}
|
||||||
|
|
||||||
|
_highlight.Position = pos;
|
||||||
|
_highlight.Size = new Vector2(20, 20);
|
||||||
|
_highlight.Color = new Color(1f, 0.3f, 0.3f, 0.5f);
|
||||||
|
_highlight.Visible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public TreeItem GetItemForNode(Node node)
|
||||||
|
{
|
||||||
|
_nodeToItem.TryGetValue(node, out var item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reverse lookup of the hierarchy item for a live node; null when unmapped.</summary>
|
||||||
|
public Node GetNodeForItem(TreeItem item)
|
||||||
|
{
|
||||||
|
return _itemToNode.TryGetValue(item, out var node) ? node : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the node belongs to the loaded mod scene subtree (including
|
||||||
|
/// the mod root itself) and therefore may be edited by the inspector.
|
||||||
|
/// Base-scene nodes and tool internals are excluded so edits never leak
|
||||||
|
/// into files this tool cannot save.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEditableModNode(Node node)
|
||||||
|
{
|
||||||
|
if (node == null || _modRoot == null || node == _highlight)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (Node current = node; current != null; current = current.GetParent())
|
||||||
|
{
|
||||||
|
if (current == _modRoot)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Address path of a mod node in .tscn terms, relative to the mod scene
|
||||||
|
/// root ("", "Panel", "Panel/Btn") — exactly what TscnTextSurgery needs to
|
||||||
|
/// locate the matching <c>[node]</c> section. Returns "" for the root node
|
||||||
|
/// itself (its section has no parent attribute) and null for non-editable nodes.
|
||||||
|
/// </summary>
|
||||||
|
public string GetModNodePath(Node node)
|
||||||
|
{
|
||||||
|
if (!IsEditableModNode(node))
|
||||||
|
return null;
|
||||||
|
if (node == _modRoot)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
var segments = new List<string>();
|
||||||
|
for (Node current = node; current != null && current != _modRoot; current = current.GetParent())
|
||||||
|
segments.Add(current.Name);
|
||||||
|
segments.Reverse();
|
||||||
|
return string.Join("/", segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Node FindNodeAtPosition(Vector2 position)
|
||||||
|
{
|
||||||
|
if (_loadedRoot == null)
|
||||||
|
return null;
|
||||||
|
return FindNodeAtPositionRecursive(_loadedRoot, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Node FindNodeAtPositionRecursive(Node node, Vector2 position)
|
||||||
|
{
|
||||||
|
for (int i = node.GetChildCount() - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var child = node.GetChild(i);
|
||||||
|
if (child == _highlight)
|
||||||
|
continue;
|
||||||
|
var hit = FindNodeAtPositionRecursive(child, position);
|
||||||
|
if (hit != null)
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node == _loadedRoot)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (node is Control control && control.GetGlobalRect().HasPoint(position))
|
||||||
|
return node;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hides the node highlight overlay and resets its colour.</summary>
|
||||||
|
public void HideHighlight()
|
||||||
|
{
|
||||||
|
if (_highlight != null)
|
||||||
|
_highlight.Visible = false;
|
||||||
|
_highlight.Color = new Color(1f, 0.7f, 0f, 0.35f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Cthangover.Core.UI.Tool;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>Registers the scene builder as a tool window. Creates <see cref="SceneBuilderWindow"/> instances.</summary>
|
||||||
|
public class SceneBuilderToolProvider : IToolProvider
|
||||||
|
{
|
||||||
|
/// <summary>Unique tool identifier.</summary>
|
||||||
|
public string Id => "scene_builder";
|
||||||
|
/// <summary>Translation key for the tool's display name.</summary>
|
||||||
|
public string LocaleKey => "tools/scene_builder/title";
|
||||||
|
/// <summary>Creates the editor window instance.</summary>
|
||||||
|
public Window CreateWindow() => new SceneBuilderWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Developer tool window for interactively previewing a mod <c>.tscn</c> scene
|
||||||
|
/// layered on top of a base game scene. Features three chained selectors
|
||||||
|
/// (base scene → mod → scene-in-mod), a live viewport with a selectable node
|
||||||
|
/// hierarchy tree, draggable resize/anchor handles over the preview, and one
|
||||||
|
/// compact tabbed sidebar holding the property inspector and the tscn source
|
||||||
|
/// editor (edits applied live, debounced auto-save), plus an always-visible
|
||||||
|
/// output strip for status and errors. All logic lives in
|
||||||
|
/// <see cref="SceneBuilderController"/>.
|
||||||
|
/// </summary>
|
||||||
|
public partial class SceneBuilderWindow : ToolWindow
|
||||||
|
{
|
||||||
|
private SceneBuilderController _controller;
|
||||||
|
|
||||||
|
private OptionButton _baseDropdown;
|
||||||
|
private OptionButton _modDropdown;
|
||||||
|
private OptionButton _sceneDropdown;
|
||||||
|
private Tree _hierarchyTree;
|
||||||
|
private SubViewport _viewport;
|
||||||
|
private TabContainer _sideTabs;
|
||||||
|
private InspectorPanel _inspector;
|
||||||
|
private SelectionOverlay _selectionOverlay;
|
||||||
|
private Timer _autosaveTimer;
|
||||||
|
private Node _selectedNode;
|
||||||
|
private TscnFileProps _fileProps = TscnFileProps.Build(null);
|
||||||
|
private TextEdit _tscnEditor;
|
||||||
|
private Button _reloadBtn;
|
||||||
|
private Button _saveBtn;
|
||||||
|
private Button _refreshBtn;
|
||||||
|
private OptionButton _newNodeTypeDropdown;
|
||||||
|
private Button _addNodeBtn;
|
||||||
|
private Button _deleteNodeBtn;
|
||||||
|
private RichTextLabel _outputLabel;
|
||||||
|
|
||||||
|
/// <summary>Curated palette for node creation — common Control classes the runtime builder instantiates via ClassDB.</summary>
|
||||||
|
private static readonly string[] CreatableTypes =
|
||||||
|
{
|
||||||
|
"Control", "Label", "Button", "Panel", "ColorRect", "TextureRect",
|
||||||
|
"RichTextLabel", "LineEdit", "VBoxContainer", "HBoxContainer",
|
||||||
|
"GridContainer", "MarginContainer", "CenterContainer", "ScrollContainer"
|
||||||
|
};
|
||||||
|
|
||||||
|
private List<(string Display, string Path)> _baseScenes;
|
||||||
|
private List<(string ModId, string Name, string RelativePath)> _modScenes;
|
||||||
|
private List<string> _mods;
|
||||||
|
private List<(string ModId, string Name, string RelativePath)> _currentModScenes = new();
|
||||||
|
|
||||||
|
/// <summary>Constructs the window, creates the controller, builds UI, and populates selectors.</summary>
|
||||||
|
public SceneBuilderWindow() : base("tools/scene_builder/title")
|
||||||
|
{
|
||||||
|
_controller = new SceneBuilderController();
|
||||||
|
BuildUI();
|
||||||
|
PopulateBaseList();
|
||||||
|
PopulateModList();
|
||||||
|
RebuildPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildUI()
|
||||||
|
{
|
||||||
|
var outerVBox = CreateFillContainer();
|
||||||
|
AddChild(outerVBox);
|
||||||
|
|
||||||
|
var toolbar = CreateToolbar();
|
||||||
|
outerVBox.AddChild(toolbar);
|
||||||
|
|
||||||
|
toolbar.AddChild(new Label { Text = TranslationServer.Translate("tools/scene_builder/base_scene") });
|
||||||
|
|
||||||
|
_baseDropdown = new OptionButton();
|
||||||
|
_baseDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
_baseDropdown.ItemSelected += OnBaseSelected;
|
||||||
|
toolbar.AddChild(_baseDropdown);
|
||||||
|
|
||||||
|
toolbar.AddChild(new Label { Text = TranslationServer.Translate("tools/scene_builder/mod") });
|
||||||
|
|
||||||
|
_modDropdown = new OptionButton();
|
||||||
|
_modDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
_modDropdown.ItemSelected += OnModSelected;
|
||||||
|
toolbar.AddChild(_modDropdown);
|
||||||
|
|
||||||
|
toolbar.AddChild(new Label { Text = TranslationServer.Translate("tools/scene_builder/scene") });
|
||||||
|
|
||||||
|
_sceneDropdown = new OptionButton();
|
||||||
|
_sceneDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
_sceneDropdown.ItemSelected += OnSceneSelected;
|
||||||
|
toolbar.AddChild(_sceneDropdown);
|
||||||
|
|
||||||
|
_reloadBtn = new Button { Text = TranslationServer.Translate("tools/scene_builder/reload") };
|
||||||
|
_reloadBtn.Pressed += OnReloadPressed;
|
||||||
|
toolbar.AddChild(_reloadBtn);
|
||||||
|
|
||||||
|
var mainHBox = new HBoxContainer();
|
||||||
|
mainHBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
mainHBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||||
|
outerVBox.AddChild(mainHBox);
|
||||||
|
|
||||||
|
var leftVBox = new VBoxContainer();
|
||||||
|
leftVBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
leftVBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||||
|
var leftPanel = CreateSidebar(leftVBox);
|
||||||
|
mainHBox.AddChild(leftPanel);
|
||||||
|
|
||||||
|
var hierarchyHeader = new HBoxContainer();
|
||||||
|
var hierarchyLabel = new Label { Text = TranslationServer.Translate("tools/scene_builder/hierarchy") };
|
||||||
|
hierarchyLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
hierarchyHeader.AddChild(hierarchyLabel);
|
||||||
|
|
||||||
|
_newNodeTypeDropdown = new OptionButton();
|
||||||
|
_newNodeTypeDropdown.TooltipText = TranslationServer.Translate("tools/scene_builder/add_node_tip");
|
||||||
|
foreach (var t in CreatableTypes)
|
||||||
|
_newNodeTypeDropdown.AddItem(t);
|
||||||
|
_newNodeTypeDropdown.Selected = 0;
|
||||||
|
hierarchyHeader.AddChild(_newNodeTypeDropdown);
|
||||||
|
|
||||||
|
_addNodeBtn = new Button { Text = "+" };
|
||||||
|
_addNodeBtn.TooltipText = TranslationServer.Translate("tools/scene_builder/add_node_tip");
|
||||||
|
_addNodeBtn.Pressed += OnAddNodePressed;
|
||||||
|
hierarchyHeader.AddChild(_addNodeBtn);
|
||||||
|
|
||||||
|
_deleteNodeBtn = new Button { Text = "-" };
|
||||||
|
_deleteNodeBtn.TooltipText = TranslationServer.Translate("tools/scene_builder/delete_node_tip");
|
||||||
|
_deleteNodeBtn.Pressed += OnDeleteNodePressed;
|
||||||
|
hierarchyHeader.AddChild(_deleteNodeBtn);
|
||||||
|
|
||||||
|
_refreshBtn = new Button();
|
||||||
|
_refreshBtn.Text = TranslationServer.Translate("tools/scene_builder/refresh");
|
||||||
|
_refreshBtn.Pressed += OnRefreshPressed;
|
||||||
|
hierarchyHeader.AddChild(_refreshBtn);
|
||||||
|
|
||||||
|
leftVBox.AddChild(hierarchyHeader);
|
||||||
|
|
||||||
|
_hierarchyTree = new Tree();
|
||||||
|
_hierarchyTree.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||||
|
_hierarchyTree.HideRoot = true;
|
||||||
|
_hierarchyTree.CellSelected += OnTreeCellSelected;
|
||||||
|
leftVBox.AddChild(_hierarchyTree);
|
||||||
|
|
||||||
|
var previewContainer = new PanelContainer();
|
||||||
|
previewContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
previewContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||||
|
mainHBox.AddChild(previewContainer);
|
||||||
|
|
||||||
|
_viewport = new SubViewport();
|
||||||
|
_viewport.Size = new Vector2I(1920, 1080);
|
||||||
|
|
||||||
|
var clickCatcher = new Control();
|
||||||
|
clickCatcher.MouseFilter = Control.MouseFilterEnum.Stop;
|
||||||
|
clickCatcher.ZIndex = 4095;
|
||||||
|
clickCatcher.AnchorRight = 1f;
|
||||||
|
clickCatcher.AnchorBottom = 1f;
|
||||||
|
clickCatcher.GuiInput += OnViewportClick;
|
||||||
|
_viewport.AddChild(clickCatcher);
|
||||||
|
|
||||||
|
var viewportContainer = new SubViewportContainer();
|
||||||
|
viewportContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
viewportContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||||
|
viewportContainer.Stretch = true;
|
||||||
|
viewportContainer.StretchShrink = 1;
|
||||||
|
viewportContainer.AddChild(_viewport);
|
||||||
|
previewContainer.AddChild(viewportContainer);
|
||||||
|
|
||||||
|
_selectionOverlay = new SelectionOverlay();
|
||||||
|
previewContainer.AddChild(_selectionOverlay);
|
||||||
|
_selectionOverlay.Setup(_viewport, viewportContainer);
|
||||||
|
_selectionOverlay.PickRequested += OnOverlayPick;
|
||||||
|
_selectionOverlay.BatchCommitted += OnOverlayBatchCommitted;
|
||||||
|
_selectionOverlay.EnsureConstructed();
|
||||||
|
|
||||||
|
_autosaveTimer = new Timer { OneShot = true, WaitTime = 0.4 };
|
||||||
|
_autosaveTimer.Timeout += OnAutosaveTimeout;
|
||||||
|
AddChild(_autosaveTimer);
|
||||||
|
|
||||||
|
// Compact tabbed sidebar: inspector and tscn source share one column,
|
||||||
|
// the output strip stays below the tabs so errors are always visible.
|
||||||
|
var rightVBox = new VBoxContainer();
|
||||||
|
rightVBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
rightVBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||||
|
var rightPanel = CreateSidebar(rightVBox, 360);
|
||||||
|
mainHBox.AddChild(rightPanel);
|
||||||
|
|
||||||
|
_sideTabs = new TabContainer();
|
||||||
|
_sideTabs.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
_sideTabs.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||||
|
rightVBox.AddChild(_sideTabs);
|
||||||
|
|
||||||
|
var inspectorTab = new VBoxContainer
|
||||||
|
{
|
||||||
|
Name = TranslationServer.Translate("tools/scene_builder/tab_inspector"),
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||||
|
SizeFlagsVertical = Control.SizeFlags.ExpandFill
|
||||||
|
};
|
||||||
|
_sideTabs.AddChild(inspectorTab);
|
||||||
|
|
||||||
|
_inspector = new InspectorPanel();
|
||||||
|
inspectorTab.AddChild(_inspector);
|
||||||
|
_inspector.PropertyEdited += OnInspectorPropertyEdited;
|
||||||
|
_inspector.EnsureConstructed();
|
||||||
|
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_select"));
|
||||||
|
|
||||||
|
var tscnTab = new VBoxContainer
|
||||||
|
{
|
||||||
|
Name = TranslationServer.Translate("tools/scene_builder/tab_tscn"),
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||||
|
SizeFlagsVertical = Control.SizeFlags.ExpandFill
|
||||||
|
};
|
||||||
|
_sideTabs.AddChild(tscnTab);
|
||||||
|
|
||||||
|
_tscnEditor = new TextEdit();
|
||||||
|
_tscnEditor.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
_tscnEditor.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||||
|
_tscnEditor.WrapMode = TextEdit.LineWrappingMode.None;
|
||||||
|
_tscnEditor.AddThemeFontOverride("font", GetMonospaceFont());
|
||||||
|
_tscnEditor.TextChanged += () => SetDirty();
|
||||||
|
tscnTab.AddChild(_tscnEditor);
|
||||||
|
|
||||||
|
_saveBtn = new Button();
|
||||||
|
_saveBtn.Text = TranslationServer.Translate("tools/scene_builder/save");
|
||||||
|
_saveBtn.Pressed += OnSavePressed;
|
||||||
|
tscnTab.AddChild(_saveBtn);
|
||||||
|
|
||||||
|
var outputHeader = new HBoxContainer();
|
||||||
|
var outputLabel = new Label { Text = TranslationServer.Translate("tools/scene_builder/output") };
|
||||||
|
outputLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||||
|
outputHeader.AddChild(outputLabel);
|
||||||
|
|
||||||
|
var clearOutputBtn = new Button { Text = TranslationServer.Translate("tools/scene_builder/clear") };
|
||||||
|
clearOutputBtn.Pressed += () => _outputLabel.Text = "";
|
||||||
|
outputHeader.AddChild(clearOutputBtn);
|
||||||
|
|
||||||
|
rightVBox.AddChild(outputHeader);
|
||||||
|
|
||||||
|
_outputLabel = new RichTextLabel();
|
||||||
|
_outputLabel.BbcodeEnabled = true;
|
||||||
|
_outputLabel.FitContent = false;
|
||||||
|
_outputLabel.ScrollActive = true;
|
||||||
|
_outputLabel.ScrollFollowing = true;
|
||||||
|
_outputLabel.CustomMinimumSize = new Vector2(0, 70);
|
||||||
|
rightVBox.AddChild(_outputLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateBaseList()
|
||||||
|
{
|
||||||
|
_baseDropdown.Clear();
|
||||||
|
_baseDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/empty_scene"));
|
||||||
|
_baseScenes = _controller.GetBaseSceneList();
|
||||||
|
foreach (var s in _baseScenes)
|
||||||
|
_baseDropdown.AddItem(s.Display);
|
||||||
|
_baseDropdown.Selected = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateModList()
|
||||||
|
{
|
||||||
|
_modDropdown.Clear();
|
||||||
|
_modDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/select_mod"));
|
||||||
|
_modScenes = _controller.GetModSceneIndex();
|
||||||
|
_mods = _modScenes.Select(s => s.ModId).Distinct().ToList();
|
||||||
|
foreach (var modId in _mods)
|
||||||
|
_modDropdown.AddItem(modId);
|
||||||
|
_modDropdown.Selected = 0;
|
||||||
|
|
||||||
|
_sceneDropdown.Clear();
|
||||||
|
_sceneDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/select_scene"));
|
||||||
|
_sceneDropdown.Selected = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateSceneListForMod(string modId)
|
||||||
|
{
|
||||||
|
_currentModScenes = new List<(string, string, string)>();
|
||||||
|
if (!string.IsNullOrEmpty(modId))
|
||||||
|
{
|
||||||
|
foreach (var s in _modScenes)
|
||||||
|
if (s.ModId == modId)
|
||||||
|
_currentModScenes.Add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
_sceneDropdown.Clear();
|
||||||
|
_sceneDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/select_scene"));
|
||||||
|
foreach (var s in _currentModScenes)
|
||||||
|
_sceneDropdown.AddItem(s.Name);
|
||||||
|
_sceneDropdown.Selected = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetSelectedModScene(out (string ModId, string Name, string RelativePath) scene)
|
||||||
|
{
|
||||||
|
scene = default;
|
||||||
|
var idx = (int)_sceneDropdown.Selected - 1;
|
||||||
|
if (idx < 0 || idx >= _currentModScenes.Count)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
scene = _currentModScenes[idx];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildPreview()
|
||||||
|
{
|
||||||
|
var baseIdx = (int)_baseDropdown.Selected;
|
||||||
|
if (baseIdx <= 0 || baseIdx - 1 >= _baseScenes.Count)
|
||||||
|
_controller.LoadEmptyScene(_viewport);
|
||||||
|
else
|
||||||
|
_controller.LoadBaseScene(_baseScenes[baseIdx - 1].Path, _viewport);
|
||||||
|
|
||||||
|
if (TryGetSelectedModScene(out var scene))
|
||||||
|
_controller.LoadModScene(scene.RelativePath);
|
||||||
|
|
||||||
|
_controller.BuildHierarchy(_hierarchyTree);
|
||||||
|
ShowTscnText();
|
||||||
|
RefreshFileSnapshot();
|
||||||
|
_selectedNode = null;
|
||||||
|
_selectionOverlay.Track(null);
|
||||||
|
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_select"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Re-parses the current editor text into the file-truth property snapshot used by the inspector.</summary>
|
||||||
|
private void RefreshFileSnapshot()
|
||||||
|
{
|
||||||
|
_fileProps = TscnFileProps.Build(_tscnEditor.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Restarts the debounced auto-save; called after every inspector/overlay write-back.</summary>
|
||||||
|
private void KickAutosave()
|
||||||
|
{
|
||||||
|
_autosaveTimer.Stop();
|
||||||
|
_autosaveTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAutosaveTimeout()
|
||||||
|
{
|
||||||
|
SaveCurrentText(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowTscnText()
|
||||||
|
{
|
||||||
|
_tscnEditor.Text = "";
|
||||||
|
if (TryGetSelectedModScene(out var scene))
|
||||||
|
{
|
||||||
|
var text = _controller.ReadSceneText(scene.ModId, scene.RelativePath);
|
||||||
|
_tscnEditor.Text = text ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBaseSelected(long index)
|
||||||
|
{
|
||||||
|
RebuildPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnModSelected(long index)
|
||||||
|
{
|
||||||
|
var modId = index == 0 ? null : _mods[(int)index - 1];
|
||||||
|
PopulateSceneListForMod(modId);
|
||||||
|
RebuildPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSceneSelected(long index)
|
||||||
|
{
|
||||||
|
RebuildPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnReloadPressed()
|
||||||
|
{
|
||||||
|
if (TryGetSelectedModScene(out var scene))
|
||||||
|
_controller.InvalidateScene(scene.RelativePath);
|
||||||
|
|
||||||
|
RebuildPreview();
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/reloaded"), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSavePressed()
|
||||||
|
{
|
||||||
|
SaveCurrentText(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the editor text to disk. auto=true marks the debounced
|
||||||
|
/// inspector-driven save: failures are logged and reported, but the
|
||||||
|
/// wording differs from the explicit Save button feedback.
|
||||||
|
/// </summary>
|
||||||
|
private bool SaveCurrentText(bool auto)
|
||||||
|
{
|
||||||
|
if (!TryGetSelectedModScene(out var scene))
|
||||||
|
{
|
||||||
|
if (!auto)
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_controller.SaveSceneText(scene.ModId, scene.RelativePath, _tscnEditor.Text))
|
||||||
|
{
|
||||||
|
MarkClean();
|
||||||
|
ShowOutput(TranslationServer.Translate(auto
|
||||||
|
? "tools/scene_builder/autosaved"
|
||||||
|
: "tools/scene_builder/saved_ok"), false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_save"), true);
|
||||||
|
GameLogger.Log("SCENE_BUILDER",
|
||||||
|
$"save failed: mod='{scene.ModId}' path='{scene.RelativePath}' (zip mods are read-only)",
|
||||||
|
LogLevel.Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRefreshPressed()
|
||||||
|
{
|
||||||
|
_controller.BuildHierarchy(_hierarchyTree);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTreeCellSelected()
|
||||||
|
{
|
||||||
|
var item = _hierarchyTree.GetSelected();
|
||||||
|
if (item == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_controller.SelectNode(item);
|
||||||
|
SyncInspector(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnViewportClick(InputEvent evt)
|
||||||
|
{
|
||||||
|
if (evt is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left && mb.Pressed)
|
||||||
|
{
|
||||||
|
var node = _controller.FindNodeAtPosition(mb.Position);
|
||||||
|
if (node == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var item = _controller.GetItemForNode(node);
|
||||||
|
if (item == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var current = _hierarchyTree.GetSelected();
|
||||||
|
if (current != null)
|
||||||
|
current.Deselect(0);
|
||||||
|
|
||||||
|
item.Select(0);
|
||||||
|
_hierarchyTree.ScrollToItem(item);
|
||||||
|
_controller.SelectNode(item);
|
||||||
|
SyncInspector(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirrors the hierarchy selection into the inspector and the overlay:
|
||||||
|
/// mod-scene nodes get editable rows and manipulation handles, everything
|
||||||
|
/// else gets a read-only notice.
|
||||||
|
/// </summary>
|
||||||
|
private void SyncInspector(TreeItem item)
|
||||||
|
{
|
||||||
|
var node = _controller.GetNodeForItem(item);
|
||||||
|
_selectedNode = node;
|
||||||
|
|
||||||
|
if (node != null && _controller.IsEditableModNode(node))
|
||||||
|
{
|
||||||
|
var nodePath = _controller.GetModNodePath(node);
|
||||||
|
_inspector.ShowNode(node, _fileProps.GetNodeProperties(nodePath));
|
||||||
|
_selectionOverlay.Track(node as Control);
|
||||||
|
_sideTabs.CurrentTab = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_readonly"));
|
||||||
|
_selectionOverlay.Track(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOverlayPick(Vector2 canvasPosition)
|
||||||
|
{
|
||||||
|
var node = _controller.FindNodeAtPosition(canvasPosition);
|
||||||
|
if (node == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var item = _controller.GetItemForNode(node);
|
||||||
|
if (item == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
SelectTreeItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves tree selection to the item and mirrors it into highlight/inspector/overlay.</summary>
|
||||||
|
private void SelectTreeItem(TreeItem item)
|
||||||
|
{
|
||||||
|
var current = _hierarchyTree.GetSelected();
|
||||||
|
if (current != null)
|
||||||
|
current.Deselect(0);
|
||||||
|
|
||||||
|
item.Select(0);
|
||||||
|
_hierarchyTree.ScrollToItem(item);
|
||||||
|
_controller.SelectNode(item);
|
||||||
|
SyncInspector(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAddNodePressed()
|
||||||
|
{
|
||||||
|
if (!TryGetSelectedModScene(out var scene))
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var typeName = CreatableTypes[System.Math.Max(_newNodeTypeDropdown.Selected, 0)];
|
||||||
|
var parent = ResolveAddParent();
|
||||||
|
if (parent == null)
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var created = _controller.CreateUniqueChild(parent, typeName);
|
||||||
|
if (created == null)
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_add_failed"), true);
|
||||||
|
GameLogger.Log("SCENE_BUILDER", $"ClassDB.Instantiate failed for '{typeName}'", LogLevel.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var props = BuildInitialNodeProps(created, parent);
|
||||||
|
var parentPath = _controller.GetModNodePath(parent);
|
||||||
|
if (!TscnTextSurgery.TryInsertNodeSection(_tscnEditor.Text, parentPath, created.Name, typeName, props, out var updated))
|
||||||
|
{
|
||||||
|
created.GetParent().RemoveChild(created);
|
||||||
|
created.QueueFree();
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_add_parent_missing"), true);
|
||||||
|
GameLogger.Log("SCENE_BUILDER",
|
||||||
|
$"node insert rejected: parent section '{parentPath}' absent from file", LogLevel.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tscnEditor.Text = updated;
|
||||||
|
RefreshFileSnapshot();
|
||||||
|
KickAutosave();
|
||||||
|
_controller.BuildHierarchy(_hierarchyTree);
|
||||||
|
|
||||||
|
var item = _controller.GetItemForNode(created);
|
||||||
|
if (item != null)
|
||||||
|
SelectTreeItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Selected editable mod node, falling back to the mod scene root.</summary>
|
||||||
|
private Node ResolveAddParent()
|
||||||
|
{
|
||||||
|
if (_selectedNode != null && _controller.IsEditableModNode(_selectedNode))
|
||||||
|
return _selectedNode;
|
||||||
|
return _controller.GetModRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal viable layout written with a fresh node: container children get
|
||||||
|
/// container mode; free Controls get an anchors-mode box of visible size so
|
||||||
|
/// a new node is immediately grabbable by the overlay handles.
|
||||||
|
/// </summary>
|
||||||
|
private static List<KeyValuePair<string, string>> BuildInitialNodeProps(Node created, Node parent)
|
||||||
|
{
|
||||||
|
var props = new List<KeyValuePair<string, string>>();
|
||||||
|
if (created is Control)
|
||||||
|
{
|
||||||
|
if (parent is Container)
|
||||||
|
props.Add(new KeyValuePair<string, string>("layout_mode", "2"));
|
||||||
|
else
|
||||||
|
{
|
||||||
|
props.Add(new KeyValuePair<string, string>("layout_mode", "1"));
|
||||||
|
props.Add(new KeyValuePair<string, string>("offset_right", "120.0"));
|
||||||
|
props.Add(new KeyValuePair<string, string>("offset_bottom", "40.0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDeleteNodePressed()
|
||||||
|
{
|
||||||
|
var node = _selectedNode;
|
||||||
|
if (node == null || !_controller.IsEditableModNode(node))
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_del_none"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node == _controller.GetModRoot())
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_del_root"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = _controller.GetModNodePath(node);
|
||||||
|
if (!_controller.DeleteModNode(node))
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_del_none"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TscnTextSurgery.TryRemoveNodeSubtree(_tscnEditor.Text, path, out var updated))
|
||||||
|
{
|
||||||
|
_tscnEditor.Text = updated;
|
||||||
|
RefreshFileSnapshot();
|
||||||
|
KickAutosave();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_missing_section"), true);
|
||||||
|
GameLogger.Log("SCENE_BUILDER",
|
||||||
|
$"subtree remove failed: section '{path}' absent from file", LogLevel.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedNode = null;
|
||||||
|
_selectionOverlay.Track(null);
|
||||||
|
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_select"));
|
||||||
|
_controller.BuildHierarchy(_hierarchyTree);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies one overlay drag result: patches every key of the batch into
|
||||||
|
/// the tscn text, refreshes the file snapshot, inspector rows and handles,
|
||||||
|
/// then arms the auto-save.
|
||||||
|
/// </summary>
|
||||||
|
private void OnOverlayBatchCommitted(Node node, List<KeyValuePair<string, Variant>> batch)
|
||||||
|
{
|
||||||
|
if (!TryGetSelectedModScene(out var scene))
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodePath = _controller.GetModNodePath(node);
|
||||||
|
if (nodePath == null)
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_readonly"), true);
|
||||||
|
GameLogger.Log("SCENE_BUILDER", $"batch rejected: '{node.Name}' is not an editable mod node", LogLevel.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = _tscnEditor.Text;
|
||||||
|
foreach (var pair in batch)
|
||||||
|
{
|
||||||
|
var serialized = TscnValueFormatter.Format(pair.Value);
|
||||||
|
if (serialized == null)
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_bad_value"), true);
|
||||||
|
GameLogger.Log("SCENE_BUILDER", $"unserializable value for '{pair.Key}'", LogLevel.Warning);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TscnTextSurgery.TryUpsertProperty(text, nodePath, pair.Key, serialized, out text))
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_missing_section"), true);
|
||||||
|
GameLogger.Log("SCENE_BUILDER",
|
||||||
|
$"section not found: path='{nodePath}' key='{pair.Key}'", LogLevel.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_tscnEditor.Text = text;
|
||||||
|
RefreshFileSnapshot();
|
||||||
|
RebuildInspectorFor(node);
|
||||||
|
_selectionOverlay.Refresh();
|
||||||
|
KickAutosave();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write-back pipeline for one inspector edit: serialize the Variant into
|
||||||
|
/// tscn syntax and patch the corresponding property line of the node's
|
||||||
|
/// section in the source editor. Every failure is reported loudly —
|
||||||
|
/// silent drops previously made edits look like they "did nothing".
|
||||||
|
/// </summary>
|
||||||
|
private void OnInspectorPropertyEdited(Node node, string key, Variant value)
|
||||||
|
{
|
||||||
|
if (!TryGetSelectedModScene(out var scene))
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var serialized = TscnValueFormatter.Format(value);
|
||||||
|
if (serialized == null)
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_bad_value"), true);
|
||||||
|
GameLogger.Log("SCENE_BUILDER", $"unserializable value for '{key}'", LogLevel.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodePath = _controller.GetModNodePath(node);
|
||||||
|
if (nodePath == null)
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_readonly"), true);
|
||||||
|
GameLogger.Log("SCENE_BUILDER", $"edit rejected: '{node.Name}' is not an editable mod node", LogLevel.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TscnTextSurgery.TryUpsertProperty(_tscnEditor.Text, nodePath, key, serialized, out var updated))
|
||||||
|
{
|
||||||
|
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_missing_section"), true);
|
||||||
|
GameLogger.Log("SCENE_BUILDER",
|
||||||
|
$"section not found: path='{nodePath}' key='{key}'", LogLevel.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tscnEditor.Text = updated;
|
||||||
|
RefreshFileSnapshot();
|
||||||
|
_selectionOverlay.Refresh();
|
||||||
|
KickAutosave();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Full inspector resync after batch writes (drag finished, focus loss is fine).</summary>
|
||||||
|
private void RebuildInspectorFor(Node node)
|
||||||
|
{
|
||||||
|
if (_selectedNode != node || !_controller.IsEditableModNode(node))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var nodePath = _controller.GetModNodePath(node);
|
||||||
|
_inspector.ShowNode(node, _fileProps.GetNodeProperties(nodePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowOutput(string text, bool isError)
|
||||||
|
{
|
||||||
|
if (_outputLabel == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var color = isError ? "red" : "green";
|
||||||
|
_outputLabel.Text = $"[color={color}]{text}[/color]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Godot-editor-style manipulation overlay drawn over the scene builder
|
||||||
|
/// preview: 8 resize markers (corners + edges, editing offset_* respecting
|
||||||
|
/// anchors) and 4 draggable anchor points with crosshair lines (editing
|
||||||
|
/// anchor_* while preserving the visual rectangle). One input surface owns
|
||||||
|
/// the whole interaction — markers are purely visual (input-ignore) and the
|
||||||
|
/// overlay itself hit-tests them in its <c>GuiInput</c> handler — because
|
||||||
|
/// per-marker Controls cannot capture motion outside their small rects and
|
||||||
|
/// virtual overrides (_Process/_GuiInput) do not dispatch in mod assemblies.
|
||||||
|
/// Live edits go straight onto the node during the drag; the accumulated
|
||||||
|
/// key/value batch is emitted once on mouse release via
|
||||||
|
/// <see cref="BatchCommitted"/> so text write-back happens in one pass.
|
||||||
|
/// Coordinates map between the fixed-size SubViewport canvas and this
|
||||||
|
/// overlay through the stretch scale of the hosting SubViewportContainer.
|
||||||
|
/// </summary>
|
||||||
|
public partial class SelectionOverlay : ModWidget
|
||||||
|
{
|
||||||
|
/// <summary>Left press that did not grab any marker: (position in viewport canvas coordinates).</summary>
|
||||||
|
public event Action<Vector2> PickRequested;
|
||||||
|
|
||||||
|
/// <summary>Raised once per finished drag: (node, ordered key/value batch to persist).</summary>
|
||||||
|
public event Action<Node, List<KeyValuePair<string, Variant>>> BatchCommitted;
|
||||||
|
|
||||||
|
private const float HandleHalf = 5f;
|
||||||
|
private const float HandleInset = 8f;
|
||||||
|
private const float AnchorDotHalf = 3.5f;
|
||||||
|
private const float AnchorLineLength = 44f;
|
||||||
|
private const float AnchorHitRadius = 10f;
|
||||||
|
private const float HandleHitPadding = 5f;
|
||||||
|
private const float BorderThickness = 1.5f;
|
||||||
|
|
||||||
|
// Resize marker indices: 0=TL corner, 1=top edge, 2=TR, 3=left, 4=right, 5=BL, 6=bottom, 7=BR.
|
||||||
|
// Anchor dot indices: 0=TL, 1=TR, 2=BL, 3=BR of the anchor quad.
|
||||||
|
private const int ResizeMarkerCount = 8;
|
||||||
|
private const int AnchorDotCount = 4;
|
||||||
|
|
||||||
|
private enum DragKind { None, Resize, Anchor }
|
||||||
|
|
||||||
|
private struct Marker
|
||||||
|
{
|
||||||
|
public Rect2 Rect;
|
||||||
|
public bool IsAnchor;
|
||||||
|
public int Index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubViewport _viewport;
|
||||||
|
private Control _containerArea;
|
||||||
|
private Control _selected;
|
||||||
|
private readonly List<Marker> _markers = new();
|
||||||
|
private readonly List<ColorRect> _borderRects = new();
|
||||||
|
private readonly List<ColorRect> _handleRects = new();
|
||||||
|
private readonly List<ColorRect> _anchorLines = new();
|
||||||
|
private readonly List<ColorRect> _anchorDots = new();
|
||||||
|
|
||||||
|
private DragKind _drag = DragKind.None;
|
||||||
|
private int _dragIndex;
|
||||||
|
private readonly Dictionary<string, double> _pendingValues = new();
|
||||||
|
|
||||||
|
/// <summary>Binds the overlay to the preview pipeline; call once before use.</summary>
|
||||||
|
public void Setup(SubViewport viewport, Control containerArea)
|
||||||
|
{
|
||||||
|
_viewport = viewport;
|
||||||
|
_containerArea = containerArea;
|
||||||
|
_containerArea.Resized += Refresh;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Selects the node to manipulate; hides markers for null/non-manipulable nodes.</summary>
|
||||||
|
public void Track(Control selected)
|
||||||
|
{
|
||||||
|
_selected = selected;
|
||||||
|
_drag = DragKind.None;
|
||||||
|
_pendingValues.Clear();
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Construct()
|
||||||
|
{
|
||||||
|
MouseFilter = Control.MouseFilterEnum.Stop;
|
||||||
|
Resized += Refresh;
|
||||||
|
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
_borderRects.Add(CreateMarker(new Color(1f, 0.85f, 0.35f, 0.95f)));
|
||||||
|
for (var i = 0; i < ResizeMarkerCount; i++)
|
||||||
|
_handleRects.Add(CreateMarker(new Color(1f, 0.62f, 0.1f, 1f)));
|
||||||
|
for (var i = 0; i < AnchorDotCount * 2; i++)
|
||||||
|
_anchorLines.Add(CreateMarker(new Color(0.35f, 0.75f, 1f, 0.55f)));
|
||||||
|
for (var i = 0; i < AnchorDotCount; i++)
|
||||||
|
_anchorDots.Add(CreateMarker(new Color(0.45f, 0.85f, 1f, 1f)));
|
||||||
|
|
||||||
|
GuiInput += OnOverlayGui;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ColorRect CreateMarker(Color color)
|
||||||
|
{
|
||||||
|
var rect = new ColorRect { Color = color, MouseFilter = Control.MouseFilterEnum.Ignore };
|
||||||
|
AddChild(rect);
|
||||||
|
return rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recomputes all marker geometry from the tracked node's current state.</summary>
|
||||||
|
public void Refresh()
|
||||||
|
{
|
||||||
|
var control = _selected as Control;
|
||||||
|
var parent = control?.GetParent() as Control;
|
||||||
|
var manipulable = control != null
|
||||||
|
&& GodotObject.IsInstanceValid(control)
|
||||||
|
&& parent != null
|
||||||
|
&& !(parent is Container);
|
||||||
|
|
||||||
|
if (!manipulable || _viewport == null || _containerArea == null)
|
||||||
|
{
|
||||||
|
SetVisibleAll(false);
|
||||||
|
_markers.Clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateMarkers(control);
|
||||||
|
UpdateMarkerControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateMarkers(Control control)
|
||||||
|
{
|
||||||
|
_markers.Clear();
|
||||||
|
var rect = CanvasRectToOverlay(control.GetGlobalRect());
|
||||||
|
|
||||||
|
for (var i = 0; i < ResizeMarkerCount; i++)
|
||||||
|
{
|
||||||
|
var center = GetHandleCenter(rect, i);
|
||||||
|
_markers.Add(new Marker
|
||||||
|
{
|
||||||
|
Rect = new Rect2(center - new Vector2(HandleHalf + HandleHitPadding, HandleHalf + HandleHitPadding),
|
||||||
|
new Vector2(HandleHalf + HandleHitPadding, HandleHalf + HandleHitPadding) * 2f),
|
||||||
|
IsAnchor = false,
|
||||||
|
Index = i
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var parent = (Control)control.GetParent();
|
||||||
|
var parentOrigin = parent.GetGlobalRect().Position;
|
||||||
|
for (var i = 0; i < AnchorDotCount; i++)
|
||||||
|
{
|
||||||
|
var anchorPoint = GetAnchorCorner(parentOrigin, parent.Size,
|
||||||
|
control.AnchorLeft, control.AnchorTop, control.AnchorRight, control.AnchorBottom, i);
|
||||||
|
var center = CanvasToOverlay(anchorPoint);
|
||||||
|
_markers.Add(new Marker
|
||||||
|
{
|
||||||
|
Rect = new Rect2(center - new Vector2(AnchorHitRadius, AnchorHitRadius),
|
||||||
|
new Vector2(AnchorHitRadius, AnchorHitRadius) * 2f),
|
||||||
|
IsAnchor = true,
|
||||||
|
Index = i
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateMarkerControls()
|
||||||
|
{
|
||||||
|
var anyVisible = _markers.Count > 0;
|
||||||
|
var rect = anyVisible ? CanvasRectToOverlay(((Control)_selected).GetGlobalRect()) : new Rect2();
|
||||||
|
|
||||||
|
for (var i = 0; i < _borderRects.Count; i++)
|
||||||
|
{
|
||||||
|
var bar = _borderRects[i];
|
||||||
|
bar.Visible = anyVisible;
|
||||||
|
switch (i)
|
||||||
|
{
|
||||||
|
case 0: bar.Position = rect.Position - new Vector2(BorderThickness, BorderThickness); bar.Size = new Vector2(rect.Size.X + BorderThickness * 2f, BorderThickness); break;
|
||||||
|
case 1: bar.Position = new Vector2(rect.Position.X - BorderThickness, rect.End.Y); bar.Size = new Vector2(rect.Size.X + BorderThickness * 2f, BorderThickness); break;
|
||||||
|
case 2: bar.Position = new Vector2(rect.Position.X - BorderThickness, rect.Position.Y); bar.Size = new Vector2(BorderThickness, rect.Size.Y); break;
|
||||||
|
case 3: bar.Position = new Vector2(rect.End.X, rect.Position.Y); bar.Size = new Vector2(BorderThickness, rect.Size.Y); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < _handleRects.Count; i++)
|
||||||
|
{
|
||||||
|
var handle = _handleRects[i];
|
||||||
|
handle.Visible = anyVisible;
|
||||||
|
if (anyVisible)
|
||||||
|
handle.Position = GetHandleCenter(rect, i) - new Vector2(HandleHalf, HandleHalf);
|
||||||
|
handle.Size = new Vector2(HandleHalf * 2f, HandleHalf * 2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < AnchorDotCount; i++)
|
||||||
|
{
|
||||||
|
var markerIdx = ResizeMarkerCount + i;
|
||||||
|
var has = anyVisible && markerIdx < _markers.Count;
|
||||||
|
var dot = _anchorDots[i];
|
||||||
|
var hLine = _anchorLines[i * 2];
|
||||||
|
var vLine = _anchorLines[i * 2 + 1];
|
||||||
|
dot.Visible = has;
|
||||||
|
hLine.Visible = has;
|
||||||
|
vLine.Visible = has;
|
||||||
|
if (!has)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var center = _markers[markerIdx].Rect.GetCenter();
|
||||||
|
dot.Position = center - new Vector2(AnchorDotHalf, AnchorDotHalf);
|
||||||
|
dot.Size = new Vector2(AnchorDotHalf * 2f, AnchorDotHalf * 2f);
|
||||||
|
hLine.Position = new Vector2(center.X - AnchorLineLength / 2f, center.Y - 0.5f);
|
||||||
|
hLine.Size = new Vector2(AnchorLineLength, 1f);
|
||||||
|
vLine.Position = new Vector2(center.X - 0.5f, center.Y - AnchorLineLength / 2f);
|
||||||
|
vLine.Size = new Vector2(1f, AnchorLineLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetVisibleAll(bool visible)
|
||||||
|
{
|
||||||
|
foreach (var rect in _borderRects) rect.Visible = visible;
|
||||||
|
foreach (var rect in _handleRects) rect.Visible = visible;
|
||||||
|
foreach (var rect in _anchorLines) rect.Visible = visible;
|
||||||
|
foreach (var rect in _anchorDots) rect.Visible = visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector2 GetHandleCenter(Rect2 rect, int index)
|
||||||
|
{
|
||||||
|
var inset = HandleHalf + 3f;
|
||||||
|
switch (index)
|
||||||
|
{
|
||||||
|
case 0: return rect.Position + new Vector2(inset, inset);
|
||||||
|
case 1: return new Vector2(rect.GetCenter().X, rect.Position.Y);
|
||||||
|
case 2: return new Vector2(rect.End.X - inset, rect.Position.Y + inset);
|
||||||
|
case 3: return new Vector2(rect.Position.X, rect.GetCenter().Y);
|
||||||
|
case 4: return new Vector2(rect.End.X, rect.GetCenter().Y);
|
||||||
|
case 5: return new Vector2(rect.Position.X + inset, rect.End.Y - inset);
|
||||||
|
case 6: return new Vector2(rect.GetCenter().X, rect.End.Y);
|
||||||
|
default: return rect.End - new Vector2(inset, inset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector2 GetAnchorCorner(Vector2 parentOrigin, Vector2 parentSize,
|
||||||
|
float aLeft, float aTop, float aRight, float aBottom, int index)
|
||||||
|
{
|
||||||
|
var x = index % 2 == 0 ? aLeft : aRight;
|
||||||
|
var y = index < 2 ? aTop : aBottom;
|
||||||
|
return parentOrigin + new Vector2(x * parentSize.X, y * parentSize.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOverlayGui(InputEvent evt)
|
||||||
|
{
|
||||||
|
if (evt is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
|
||||||
|
{
|
||||||
|
if (mb.Pressed)
|
||||||
|
OnPress(mb.Position);
|
||||||
|
else if (_drag != DragKind.None)
|
||||||
|
FinishDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evt is InputEventMouseMotion motion)
|
||||||
|
{
|
||||||
|
if (_drag != DragKind.None)
|
||||||
|
{
|
||||||
|
ApplyDrag(motion.Position);
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UpdateHoverCursor(motion.Position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPress(Vector2 localPoint)
|
||||||
|
{
|
||||||
|
// Anchor dots win over coinciding resize markers (full-rect nodes keep
|
||||||
|
// their corners resizable because resize centers sit inset).
|
||||||
|
for (var i = 0; i < _markers.Count; i++)
|
||||||
|
{
|
||||||
|
if (!_markers[i].IsAnchor || !_markers[i].Rect.HasPoint(localPoint))
|
||||||
|
continue;
|
||||||
|
BeginDrag(DragKind.Anchor, _markers[i].Index);
|
||||||
|
AcceptEvent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < _markers.Count; i++)
|
||||||
|
{
|
||||||
|
if (_markers[i].IsAnchor || !_markers[i].Rect.HasPoint(localPoint))
|
||||||
|
continue;
|
||||||
|
BeginDrag(DragKind.Resize, _markers[i].Index);
|
||||||
|
AcceptEvent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PickRequested?.Invoke(LocalToCanvas(localPoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BeginDrag(DragKind kind, int index)
|
||||||
|
{
|
||||||
|
_drag = kind;
|
||||||
|
_dragIndex = index;
|
||||||
|
_pendingValues.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyDrag(Vector2 localPoint)
|
||||||
|
{
|
||||||
|
if (!(_selected is Control control) || !(control.GetParent() is Control parent))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var mouseInParent = LocalToCanvas(localPoint) - parent.GetGlobalRect().Position;
|
||||||
|
var parentSize = parent.Size;
|
||||||
|
|
||||||
|
if (_drag == DragKind.Resize)
|
||||||
|
{
|
||||||
|
ApplyResizeEdge(control, parentSize, mouseInParent, _dragIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyAnchorDrag(control, parentSize, mouseInParent, _dragIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyResizeEdge(Control control, Vector2 parentSize, Vector2 mouseInParent, int index)
|
||||||
|
{
|
||||||
|
// Bit layout of the index: 0=TL,1=T,2=TR,3=L,4=R,5=BL,6=B,7=BR.
|
||||||
|
var topSide = index == 0 || index == 1 || index == 2;
|
||||||
|
var bottomSide = index == 5 || index == 6 || index == 7;
|
||||||
|
var leftSide = index == 0 || index == 3 || index == 5;
|
||||||
|
var rightSide = index == 2 || index == 4 || index == 7;
|
||||||
|
|
||||||
|
if (topSide)
|
||||||
|
StorePair(control, "offset_top", mouseInParent.Y - control.AnchorTop * parentSize.Y);
|
||||||
|
if (bottomSide)
|
||||||
|
StorePair(control, "offset_bottom", mouseInParent.Y - control.AnchorBottom * parentSize.Y);
|
||||||
|
if (leftSide)
|
||||||
|
StorePair(control, "offset_left", mouseInParent.X - control.AnchorLeft * parentSize.X);
|
||||||
|
if (rightSide)
|
||||||
|
StorePair(control, "offset_right", mouseInParent.X - control.AnchorRight * parentSize.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyAnchorDrag(Control control, Vector2 parentSize, Vector2 mouseInParent, int index)
|
||||||
|
{
|
||||||
|
var moveRight = index % 2 == 1;
|
||||||
|
var moveBottom = index >= 2;
|
||||||
|
|
||||||
|
var newAnchorX = Mathf.Clamp(mouseInParent.X / Mathf.Max(parentSize.X, 1f), 0f, 1f);
|
||||||
|
var newAnchorY = Mathf.Clamp(mouseInParent.Y / Mathf.Max(parentSize.Y, 1f), 0f, 1f);
|
||||||
|
|
||||||
|
// Preserve the visual rectangle: shift the matching offset by the
|
||||||
|
// anchor delta so only the anchor moves, exactly like the Godot editor.
|
||||||
|
var oldAnchorX = moveRight ? control.AnchorRight : control.AnchorLeft;
|
||||||
|
var oldOffsetX = moveRight ? control.OffsetRight : control.OffsetLeft;
|
||||||
|
var newOffsetX = oldOffsetX + (oldAnchorX - newAnchorX) * parentSize.X;
|
||||||
|
|
||||||
|
var oldAnchorY = moveBottom ? control.AnchorBottom : control.AnchorTop;
|
||||||
|
var oldOffsetY = moveBottom ? control.OffsetBottom : control.OffsetTop;
|
||||||
|
var newOffsetY = oldOffsetY + (oldAnchorY - newAnchorY) * parentSize.Y;
|
||||||
|
|
||||||
|
if (moveRight)
|
||||||
|
{
|
||||||
|
StorePair(control, "anchor_right", newAnchorX);
|
||||||
|
StorePair(control, "offset_right", newOffsetX);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StorePair(control, "anchor_left", newAnchorX);
|
||||||
|
StorePair(control, "offset_left", newOffsetX);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moveBottom)
|
||||||
|
{
|
||||||
|
StorePair(control, "anchor_bottom", newAnchorY);
|
||||||
|
StorePair(control, "offset_bottom", newOffsetY);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StorePair(control, "anchor_top", newAnchorY);
|
||||||
|
StorePair(control, "offset_top", newOffsetY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StorePair(Control control, string key, double value)
|
||||||
|
{
|
||||||
|
_pendingValues[key] = value;
|
||||||
|
control.Set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FinishDrag()
|
||||||
|
{
|
||||||
|
var node = _selected;
|
||||||
|
_drag = DragKind.None;
|
||||||
|
|
||||||
|
if (node == null || _pendingValues.Count == 0)
|
||||||
|
{
|
||||||
|
_pendingValues.Clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical tscn ordering: layout gate, anchors, offsets.
|
||||||
|
var batch = new List<KeyValuePair<string, Variant>>();
|
||||||
|
if (_pendingValues.ContainsKey("anchor_left") && ReadLayoutMode(node) != 1)
|
||||||
|
batch.Add(new KeyValuePair<string, Variant>("layout_mode", Variant.From(1L)));
|
||||||
|
|
||||||
|
foreach (var key in new[] { "anchor_left", "anchor_top", "anchor_right", "anchor_bottom" })
|
||||||
|
if (_pendingValues.TryGetValue(key, out var v))
|
||||||
|
batch.Add(new KeyValuePair<string, Variant>(key, Variant.From(v)));
|
||||||
|
foreach (var key in new[] { "offset_left", "offset_top", "offset_right", "offset_bottom" })
|
||||||
|
if (_pendingValues.TryGetValue(key, out var v))
|
||||||
|
batch.Add(new KeyValuePair<string, Variant>(key, Variant.From(v)));
|
||||||
|
|
||||||
|
_pendingValues.Clear();
|
||||||
|
BatchCommitted?.Invoke(node, batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ReadLayoutMode(Node node)
|
||||||
|
{
|
||||||
|
var v = node.Get("layout_mode");
|
||||||
|
if (v.VariantType == Variant.Type.Int)
|
||||||
|
return v.AsInt64();
|
||||||
|
if (v.VariantType == Variant.Type.Float)
|
||||||
|
return (long)v.AsDouble();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateHoverCursor(Vector2 localPoint)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < _markers.Count; i++)
|
||||||
|
{
|
||||||
|
if (!_markers[i].Rect.HasPoint(localPoint))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (_markers[i].IsAnchor)
|
||||||
|
{
|
||||||
|
MouseDefaultCursorShape = Control.CursorShape.Move;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (_markers[i].Index)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
case 7:
|
||||||
|
MouseDefaultCursorShape = Control.CursorShape.Fdiagsize;
|
||||||
|
return;
|
||||||
|
case 2:
|
||||||
|
case 5:
|
||||||
|
MouseDefaultCursorShape = Control.CursorShape.Bdiagsize;
|
||||||
|
return;
|
||||||
|
case 1:
|
||||||
|
case 6:
|
||||||
|
MouseDefaultCursorShape = Control.CursorShape.Vsize;
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
MouseDefaultCursorShape = Control.CursorShape.Hsize;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseDefaultCursorShape = Control.CursorShape.Arrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── coordinate mapping ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private Vector2 ComputeStretchScale()
|
||||||
|
{
|
||||||
|
var viewSize = _viewport.Size;
|
||||||
|
return new Vector2(
|
||||||
|
viewSize.X > 0 ? _containerArea.Size.X / viewSize.X : 1f,
|
||||||
|
viewSize.Y > 0 ? _containerArea.Size.Y / viewSize.Y : 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Vector2 CanvasToOverlay(Vector2 canvasPoint)
|
||||||
|
{
|
||||||
|
return canvasPoint * ComputeStretchScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rect2 CanvasRectToOverlay(Rect2 canvasRect)
|
||||||
|
{
|
||||||
|
var scale = ComputeStretchScale();
|
||||||
|
return new Rect2(canvasRect.Position * scale, canvasRect.Size * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Vector2 LocalToCanvas(Vector2 localPoint)
|
||||||
|
{
|
||||||
|
var scale = ComputeStretchScale();
|
||||||
|
return new Vector2(
|
||||||
|
scale.X > 0.0001f ? localPoint.X / scale.X : localPoint.X,
|
||||||
|
scale.Y > 0.0001f ? localPoint.Y / scale.Y : localPoint.Y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Scenes.TscnParsing;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of the raw node properties currently stored in a .tscn source
|
||||||
|
/// text: node address → property key → unparsed literal. The inspector uses
|
||||||
|
/// it to show what the FILE contains rather than post-layout live values —
|
||||||
|
/// after Godot lays out a control its position/size reflect computed layout,
|
||||||
|
/// which previously made anchored nodes look like they had none. Rebuild via
|
||||||
|
/// <see cref="Build"/> whenever the source text changes. Section addressing
|
||||||
|
/// matches <see cref="TscnTextSurgery"/> through <see cref="TscnNodeAddressing"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class TscnFileProps
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, Dictionary<string, string>> _nodes = new();
|
||||||
|
|
||||||
|
/// <summary>Parses tscn source into the snapshot; empty snapshot for null/empty text.</summary>
|
||||||
|
public static TscnFileProps Build(string text)
|
||||||
|
{
|
||||||
|
var result = new TscnFileProps();
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
Dictionary<string, string> current = null;
|
||||||
|
foreach (var token in new TscnLexer().Tokenize(text))
|
||||||
|
{
|
||||||
|
if (token.Type == TscnTokenType.SectionHeader)
|
||||||
|
{
|
||||||
|
current = null;
|
||||||
|
if (token.SectionName == "node")
|
||||||
|
{
|
||||||
|
var path = TscnNodeAddressing.ComputePath(token.SectionAttrs);
|
||||||
|
if (path != null && !result._nodes.ContainsKey(path))
|
||||||
|
{
|
||||||
|
current = new Dictionary<string, string>();
|
||||||
|
result._nodes[path] = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (token.Type == TscnTokenType.Property && current != null)
|
||||||
|
{
|
||||||
|
if (!current.ContainsKey(token.PropertyKey))
|
||||||
|
current[token.PropertyKey] = token.PropertyValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Raw property literals of one node; null when the section is absent from the file.</summary>
|
||||||
|
public IReadOnlyDictionary<string, string> GetNodeProperties(string nodePath)
|
||||||
|
{
|
||||||
|
return _nodes.TryGetValue(nodePath ?? "", out var props) ? props : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Inverse of <see cref="TscnValueFormatter"/>: parses raw .tscn property
|
||||||
|
/// literals (as stored in the file, e.g. "0.5", "Vector2(160, 50)",
|
||||||
|
/// "\"Go\"") into display values for inspector widgets. Returns false for
|
||||||
|
/// anything it cannot represent — object references like ExtResource("..."),
|
||||||
|
/// sub-resources — so callers fall back to live-node values instead of
|
||||||
|
/// showing garbage. Pure string math, safe outside the engine runtime.
|
||||||
|
/// </summary>
|
||||||
|
public static class TscnLiteralReader
|
||||||
|
{
|
||||||
|
public static bool TryReadFloat(string raw, out double value)
|
||||||
|
{
|
||||||
|
value = 0d;
|
||||||
|
if (raw == null || raw.Contains('('))
|
||||||
|
return false;
|
||||||
|
return double.TryParse(raw.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryReadLong(string raw, out long value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
if (raw == null || raw.Contains('.') || raw.Contains('('))
|
||||||
|
return false;
|
||||||
|
return long.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryReadBool(string raw, out bool value)
|
||||||
|
{
|
||||||
|
var t = raw?.Trim();
|
||||||
|
if (t == "true") { value = true; return true; }
|
||||||
|
if (t == "false") { value = false; return true; }
|
||||||
|
value = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads a quoted .tscn string literal; null when the raw text is not a quoted string.</summary>
|
||||||
|
public static string ReadString(string raw)
|
||||||
|
{
|
||||||
|
if (raw == null)
|
||||||
|
return null;
|
||||||
|
var t = raw.Trim();
|
||||||
|
if (t.Length < 2 || t[0] != '"' || t[t.Length - 1] != '"')
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var body = t.Substring(1, t.Length - 2);
|
||||||
|
return body
|
||||||
|
.Replace("\\n", "\n")
|
||||||
|
.Replace("\\r", "\r")
|
||||||
|
.Replace("\\t", "\t")
|
||||||
|
.Replace("\\\"", "\"")
|
||||||
|
.Replace("\\\\", "\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryReadVector2(string raw, out Vector2 value)
|
||||||
|
{
|
||||||
|
value = Vector2.Zero;
|
||||||
|
if (!TryReadConstructor(raw, "Vector2", out var parts) || parts.Length != 2)
|
||||||
|
return false;
|
||||||
|
if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x) ||
|
||||||
|
!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
|
||||||
|
return false;
|
||||||
|
value = new Vector2((float)x, (float)y);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryReadColor(string raw, out Color value)
|
||||||
|
{
|
||||||
|
value = Colors.White;
|
||||||
|
if (!TryReadConstructor(raw, "Color", out var parts) || parts.Length is not (3 or 4))
|
||||||
|
return false;
|
||||||
|
if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var r) ||
|
||||||
|
!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var g) ||
|
||||||
|
!double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var b))
|
||||||
|
return false;
|
||||||
|
var a = 1d;
|
||||||
|
if (parts.Length == 4 && !double.TryParse(parts[3], NumberStyles.Float, CultureInfo.InvariantCulture, out a))
|
||||||
|
return false;
|
||||||
|
value = new Color((float)r, (float)g, (float)b, (float)a);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadConstructor(string raw, string typeName, out string[] args)
|
||||||
|
{
|
||||||
|
args = null;
|
||||||
|
if (raw == null)
|
||||||
|
return false;
|
||||||
|
var t = raw.Trim();
|
||||||
|
var prefix = typeName + "(";
|
||||||
|
if (!t.StartsWith(prefix, System.StringComparison.Ordinal) || !t.EndsWith(")", System.StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var body = t.Substring(prefix.Length, t.Length - prefix.Length - 1);
|
||||||
|
args = body.Split(',');
|
||||||
|
for (var i = 0; i < args.Length; i++)
|
||||||
|
args[i] = args[i].Trim();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for mapping a <c>[node]</c> section's attributes to
|
||||||
|
/// the address path used across the scene builder tool (inspector selection,
|
||||||
|
/// tscn text surgery, file property snapshots). Both <see cref="TscnTextSurgery"/>
|
||||||
|
/// and <see cref="TscnFileProps"/> must resolve sections identically, so the
|
||||||
|
/// rule lives here and nowhere else: no parent attribute = scene root (""),
|
||||||
|
/// parent="." = direct child of root, otherwise parent path + "/" + name.
|
||||||
|
/// </summary>
|
||||||
|
internal static class TscnNodeAddressing
|
||||||
|
{
|
||||||
|
/// <summary>Computes the node address from section attributes; null when attributes lack a usable name.</summary>
|
||||||
|
public static string ComputePath(Dictionary<string, string> attrs)
|
||||||
|
{
|
||||||
|
if (attrs == null || !attrs.TryGetValue("name", out var name) || string.IsNullOrEmpty(name))
|
||||||
|
return null;
|
||||||
|
if (!attrs.TryGetValue("parent", out var parent))
|
||||||
|
return "";
|
||||||
|
return parent == "." ? name : parent + "/" + name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Scenes.TscnParsing;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Targeted line edits of raw .tscn source without a full re-serialization:
|
||||||
|
/// the file is tokenized with <see cref="TscnLexer"/> (which reports 1-based
|
||||||
|
/// line numbers), the affected <c>[node]</c> sections are located by their
|
||||||
|
/// Godot address path (via <see cref="TscnNodeAddressing"/>, shared with
|
||||||
|
/// <see cref="TscnFileProps"/>), and only the involved lines are touched.
|
||||||
|
/// Everything outside them — comments, blank lines, formatting — survives.
|
||||||
|
/// Beyond property upsert/removal it can insert a whole new node section
|
||||||
|
/// right after its parent's block (parent-before-child order is what the
|
||||||
|
/// runtime builder relies on) and delete a node subtree together with any
|
||||||
|
/// dangling [connection] entries. Newly inserted properties follow Godot's
|
||||||
|
/// layout convention (<c>layout_mode</c> → anchors → grow → offsets → rest)
|
||||||
|
/// so the builder applies them in a valid sequence.
|
||||||
|
/// </summary>
|
||||||
|
public static class TscnTextSurgery
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Inserts or replaces one property line inside the given node's section.
|
||||||
|
/// nodePath uses .tscn addressing relative to the scene root ("", "Panel", "Panel/Btn");
|
||||||
|
/// "" addresses the root node section itself. Returns false when the section
|
||||||
|
/// does not exist in this file (e.g. override sections for instanced sub-scenes).
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryUpsertProperty(string text, string nodePath, string key, string tscnValue, out string newText)
|
||||||
|
{
|
||||||
|
newText = text;
|
||||||
|
if (text == null || string.IsNullOrEmpty(key))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var lines = SplitLines(text, out var crlf);
|
||||||
|
var tokens = new TscnLexer().Tokenize(text);
|
||||||
|
var blocks = CollectBlocks(tokens);
|
||||||
|
|
||||||
|
if (!TryFindBlock(blocks, nodePath, out var block) || !block.IsNode)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var replaceLine = -1;
|
||||||
|
var insertAfterLine = block.Start;
|
||||||
|
var group = GroupOrder(key);
|
||||||
|
|
||||||
|
foreach (var token in tokens)
|
||||||
|
{
|
||||||
|
if (token.Type != TscnTokenType.Property || token.LineNumber <= block.Start || token.LineNumber > block.End)
|
||||||
|
continue;
|
||||||
|
if (token.PropertyKey == key)
|
||||||
|
{
|
||||||
|
replaceLine = token.LineNumber;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (GroupOrder(token.PropertyKey) <= group)
|
||||||
|
insertAfterLine = token.LineNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newLine = key + " = " + tscnValue;
|
||||||
|
if (replaceLine >= 0)
|
||||||
|
lines[replaceLine - 1] = newLine;
|
||||||
|
else
|
||||||
|
lines.Insert(insertAfterLine, newLine);
|
||||||
|
|
||||||
|
newText = JoinLines(lines, crlf);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes one property line from the given node's section. Returns false
|
||||||
|
/// when the section or the property line is not present.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryRemoveProperty(string text, string nodePath, string key, out string newText)
|
||||||
|
{
|
||||||
|
newText = text;
|
||||||
|
if (text == null || string.IsNullOrEmpty(key))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var lines = SplitLines(text, out var crlf);
|
||||||
|
var tokens = new TscnLexer().Tokenize(text);
|
||||||
|
var blocks = CollectBlocks(tokens);
|
||||||
|
|
||||||
|
if (!TryFindBlock(blocks, nodePath, out var block) || !block.IsNode)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var token in tokens)
|
||||||
|
{
|
||||||
|
if (token.Type != TscnTokenType.Property || token.LineNumber <= block.Start || token.LineNumber > block.End)
|
||||||
|
continue;
|
||||||
|
if (token.PropertyKey != key)
|
||||||
|
continue;
|
||||||
|
lines.RemoveAt(token.LineNumber - 1);
|
||||||
|
newText = JoinLines(lines, crlf);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inserts a complete <c>[node]</c> section directly after its parent's
|
||||||
|
/// block so the runtime builder meets the parent first. parentPath "" adds
|
||||||
|
/// a child of the scene root (parent="."). Returns false when the parent
|
||||||
|
/// section is absent from this file or name/type contain illegal characters.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryInsertNodeSection(string text, string parentPath, string name, string type,
|
||||||
|
List<KeyValuePair<string, string>> properties, out string newText)
|
||||||
|
{
|
||||||
|
newText = text;
|
||||||
|
if (text == null || string.IsNullOrEmpty(name) || string.IsNullOrEmpty(type))
|
||||||
|
return false;
|
||||||
|
if (name.Contains('"') || name.Contains('[') || type.Contains('"') || type.Contains('['))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var lines = SplitLines(text, out var crlf);
|
||||||
|
var blocks = CollectBlocks(new TscnLexer().Tokenize(text));
|
||||||
|
|
||||||
|
if (!TryFindBlock(blocks, parentPath ?? "", out var parentBlock) || !parentBlock.IsNode)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var parentAttr = string.IsNullOrEmpty(parentPath) ? "." : parentPath;
|
||||||
|
var newBlock = new List<string> { "[node name=\"" + name + "\" type=\"" + type + "\" parent=\"" + parentAttr + "\"]" };
|
||||||
|
if (properties != null)
|
||||||
|
{
|
||||||
|
foreach (var pair in properties)
|
||||||
|
newBlock.Add(pair.Key + " = " + pair.Value);
|
||||||
|
}
|
||||||
|
newBlock.Add("");
|
||||||
|
|
||||||
|
// 1-based End line occupies index End-1; inserting at index End places
|
||||||
|
// the new block between the parent's last line and whatever follows.
|
||||||
|
lines.InsertRange(parentBlock.End, newBlock);
|
||||||
|
|
||||||
|
newText = JoinLines(lines, crlf);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the node's own section, every descendant section (path prefix)
|
||||||
|
/// and [connection] headers pointing into the subtree. Trailing blank lines
|
||||||
|
/// of each removed range are swallowed to avoid blank-line buildup.
|
||||||
|
/// Returns false when no matching section exists.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryRemoveNodeSubtree(string text, string nodePath, out string newText)
|
||||||
|
{
|
||||||
|
newText = text;
|
||||||
|
if (text == null || string.IsNullOrEmpty(nodePath))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var lines = SplitLines(text, out var crlf);
|
||||||
|
var blocks = CollectBlocks(new TscnLexer().Tokenize(text));
|
||||||
|
var subtreePrefix = nodePath + "/";
|
||||||
|
|
||||||
|
// (startIndex, endExclusiveIndex) removal ranges in descending order later.
|
||||||
|
var ranges = new List<(int StartIdx, int EndIdx)>();
|
||||||
|
var matched = false;
|
||||||
|
|
||||||
|
foreach (var block in blocks)
|
||||||
|
{
|
||||||
|
var inSubtree = false;
|
||||||
|
if (block.IsNode && block.Path != null &&
|
||||||
|
(block.Path == nodePath || block.Path.StartsWith(subtreePrefix, System.StringComparison.Ordinal)))
|
||||||
|
inSubtree = true;
|
||||||
|
if (block.IsConnection && block.From != null &&
|
||||||
|
(block.From == nodePath || block.From.StartsWith(subtreePrefix, System.StringComparison.Ordinal)))
|
||||||
|
inSubtree = true;
|
||||||
|
|
||||||
|
if (!inSubtree)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
matched = true;
|
||||||
|
var startIdx = block.Start - 1;
|
||||||
|
var endIdx = block.End; // 0-based index of the first line AFTER the block
|
||||||
|
while (endIdx < lines.Count && lines[endIdx].Trim().Length == 0)
|
||||||
|
endIdx++;
|
||||||
|
ranges.Add((startIdx, endIdx));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matched)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
ranges.Sort((a, b) => b.StartIdx.CompareTo(a.StartIdx));
|
||||||
|
foreach (var range in ranges)
|
||||||
|
lines.RemoveRange(range.StartIdx, range.EndIdx - range.StartIdx);
|
||||||
|
|
||||||
|
newText = JoinLines(lines, crlf);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SectionInfo
|
||||||
|
{
|
||||||
|
public bool IsNode;
|
||||||
|
public bool IsConnection;
|
||||||
|
public string Path;
|
||||||
|
public string From;
|
||||||
|
public int Start;
|
||||||
|
public int End;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Walks tokens once and produces every section block with its inclusive [Start..End] 1-based line span.</summary>
|
||||||
|
private static List<SectionInfo> CollectBlocks(List<TscnToken> tokens)
|
||||||
|
{
|
||||||
|
var result = new List<SectionInfo>();
|
||||||
|
TscnToken currentHeader = null;
|
||||||
|
var lastLine = -1;
|
||||||
|
|
||||||
|
for (var i = 0; i <= tokens.Count; i++)
|
||||||
|
{
|
||||||
|
var token = i < tokens.Count ? tokens[i] : null;
|
||||||
|
if (token == null || token.Type == TscnTokenType.SectionHeader)
|
||||||
|
{
|
||||||
|
if (currentHeader != null)
|
||||||
|
{
|
||||||
|
var attrs = currentHeader.SectionAttrs ?? new Dictionary<string, string>();
|
||||||
|
var info = new SectionInfo { Start = currentHeader.LineNumber, End = lastLine };
|
||||||
|
if (currentHeader.SectionName == "node")
|
||||||
|
{
|
||||||
|
info.IsNode = true;
|
||||||
|
info.Path = TscnNodeAddressing.ComputePath(attrs);
|
||||||
|
}
|
||||||
|
else if (currentHeader.SectionName == "connection")
|
||||||
|
{
|
||||||
|
info.IsConnection = true;
|
||||||
|
attrs.TryGetValue("from", out info.From);
|
||||||
|
}
|
||||||
|
result.Add(info);
|
||||||
|
}
|
||||||
|
if (token == null)
|
||||||
|
break;
|
||||||
|
currentHeader = token;
|
||||||
|
lastLine = token.LineNumber;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lastLine = token.LineNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryFindBlock(List<SectionInfo> blocks, string nodePath, out SectionInfo block)
|
||||||
|
{
|
||||||
|
block = default;
|
||||||
|
foreach (var candidate in blocks)
|
||||||
|
{
|
||||||
|
if (candidate.Path != null && candidate.Path == nodePath)
|
||||||
|
{
|
||||||
|
block = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GroupOrder(string key)
|
||||||
|
{
|
||||||
|
if (key == "layout_mode")
|
||||||
|
return 0;
|
||||||
|
if (key == "anchors_preset")
|
||||||
|
return 1;
|
||||||
|
if (key.StartsWith("anchor_", System.StringComparison.Ordinal))
|
||||||
|
return 2;
|
||||||
|
if (key.StartsWith("grow_", System.StringComparison.Ordinal))
|
||||||
|
return 3;
|
||||||
|
if (key.StartsWith("offset_", System.StringComparison.Ordinal))
|
||||||
|
return 4;
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> SplitLines(string text, out bool crlf)
|
||||||
|
{
|
||||||
|
crlf = text.Contains("\r\n");
|
||||||
|
var normalized = text.Replace("\r\n", "\n").Replace('\r', '\n');
|
||||||
|
return new List<string>(normalized.Split('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string JoinLines(List<string> lines, bool crlf)
|
||||||
|
{
|
||||||
|
var separator = crlf ? "\r\n" : "\n";
|
||||||
|
return string.Join(separator, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes editor-side <see cref="Variant"/> values into .tscn literal syntax
|
||||||
|
/// accepted by the runtime parser (<c>TscnPropertyParser</c>): floats always carry
|
||||||
|
/// a decimal point, strings are quoted with Godot escape sequences, composite
|
||||||
|
/// types use the <c>Type(a, b, ...)</c> constructor form. Returns null for values
|
||||||
|
/// the text format cannot represent (non-finite floats, unsupported variants) so
|
||||||
|
/// callers can skip the write-back instead of corrupting the scene file.
|
||||||
|
/// </summary>
|
||||||
|
public static class TscnValueFormatter
|
||||||
|
{
|
||||||
|
/// <summary>Formats a Variant as a .tscn property value; null when not representable.</summary>
|
||||||
|
public static string Format(Variant value)
|
||||||
|
{
|
||||||
|
switch (value.VariantType)
|
||||||
|
{
|
||||||
|
case Variant.Type.Nil:
|
||||||
|
return "null";
|
||||||
|
case Variant.Type.Bool:
|
||||||
|
return value.AsBool() ? "true" : "false";
|
||||||
|
case Variant.Type.Int:
|
||||||
|
return value.AsInt64().ToString(CultureInfo.InvariantCulture);
|
||||||
|
case Variant.Type.Float:
|
||||||
|
return FormatFloat(value.AsDouble());
|
||||||
|
case Variant.Type.String:
|
||||||
|
return Quote(value.AsString());
|
||||||
|
case Variant.Type.Vector2:
|
||||||
|
{
|
||||||
|
var v = value.AsVector2();
|
||||||
|
var x = FormatFloat(v.X);
|
||||||
|
var y = FormatFloat(v.Y);
|
||||||
|
return x == null || y == null ? null : $"Vector2({x}, {y})";
|
||||||
|
}
|
||||||
|
case Variant.Type.Color:
|
||||||
|
{
|
||||||
|
var c = value.AsColor();
|
||||||
|
var r = FormatFloat(c.R);
|
||||||
|
var g = FormatFloat(c.G);
|
||||||
|
var b = FormatFloat(c.B);
|
||||||
|
var a = FormatFloat(c.A);
|
||||||
|
return r == null || g == null || b == null || a == null ? null : $"Color({r}, {g}, {b}, {a})";
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a float in invariant culture with a guaranteed decimal point
|
||||||
|
/// ("1" → "1.0"). Values pass through float precision first because every
|
||||||
|
/// numeric property edited here originates from Godot float properties —
|
||||||
|
/// that yields the short canonical form ("0.35" instead of the double
|
||||||
|
/// expansion "0.34999999403953552"). Null for NaN/Infinity.
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatFloat(double v)
|
||||||
|
{
|
||||||
|
var f = (float)v;
|
||||||
|
if (!float.IsFinite(f))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var s = f.ToString("R", CultureInfo.InvariantCulture);
|
||||||
|
if (!s.Contains('.') && !s.Contains('e') && !s.Contains('E'))
|
||||||
|
s += ".0";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Quote(string s)
|
||||||
|
{
|
||||||
|
var escaped = s
|
||||||
|
.Replace("\\", "\\\\")
|
||||||
|
.Replace("\"", "\\\"")
|
||||||
|
.Replace("\n", "\\n")
|
||||||
|
.Replace("\r", "\\r")
|
||||||
|
.Replace("\t", "\\t");
|
||||||
|
return "\"" + escaped + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using Cthangover.Core.Factories.Impls;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Mods.Resolvers;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Static service for discovering and loading mod background resources.
|
||||||
|
/// Queries <see cref="Files.Collect"/> for files under
|
||||||
|
/// <c>"backgrounds"</c>, strips extensions to produce IDs, and loads textures
|
||||||
|
/// via <see cref="BackgroundFactory"/>. Used by the light editor, interactive
|
||||||
|
/// editor, and scenario editor to populate background selection lists and
|
||||||
|
/// render previews.
|
||||||
|
/// </summary>
|
||||||
|
public static class ModResourceService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all unique background IDs (filenames without extension) from
|
||||||
|
/// the <c>"backgrounds"</c> file collection across all installed mods.
|
||||||
|
/// </summary>
|
||||||
|
public static List<string> GetBackgroundIds()
|
||||||
|
{
|
||||||
|
var ids = new List<string>();
|
||||||
|
var files = Files.Collect("backgrounds");
|
||||||
|
var exts = GetExt();
|
||||||
|
|
||||||
|
foreach (var kvp in files)
|
||||||
|
{
|
||||||
|
var id = kvp.Key;
|
||||||
|
var ext = Path.GetExtension(id);
|
||||||
|
if (!string.IsNullOrWhiteSpace(id) && exts.Contains(ext.ToLower()))
|
||||||
|
{
|
||||||
|
var cleanId = id.Substring(0, id.LastIndexOf('.'));
|
||||||
|
if (!cleanId.EndsWith("_albedo") && !cleanId.EndsWith("_depth"))
|
||||||
|
ids.Add(cleanId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> GetExt()
|
||||||
|
{
|
||||||
|
var set = new HashSet<string>();
|
||||||
|
foreach (var ext in ModConfig.Instance.TextureExtensions)
|
||||||
|
{
|
||||||
|
if(string.IsNullOrWhiteSpace(ext))
|
||||||
|
continue;
|
||||||
|
set.Add(ext.ToLower());
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a background texture by ID via <see cref="BackgroundFactory.Instance.Get"/>.
|
||||||
|
/// Returns <c>null</c> on any error (missing resource, load failure).
|
||||||
|
/// </summary>
|
||||||
|
public static Texture2D LoadBackgroundTexture(string id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return BackgroundFactory.Instance.Get(id);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Scenarios;
|
||||||
|
using Cthangover.Core.Factories.Impls;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.Services
|
||||||
|
{
|
||||||
|
/// <summary>Parsed metadata from a scenario file header (before the <c>---</c> separator).</summary>
|
||||||
|
public sealed class ScenarioMetadata
|
||||||
|
{
|
||||||
|
/// <summary>Target scene name for the scenario.</summary>
|
||||||
|
public string Scene { get; set; } = "-";
|
||||||
|
/// <summary>Priority level of the scenario.</summary>
|
||||||
|
public string Priority { get; set; } = "-";
|
||||||
|
/// <summary>Condition string for the scenario to activate.</summary>
|
||||||
|
public string Condition { get; set; } = "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Collected references extracted from scenario command lines for the info panel.</summary>
|
||||||
|
public sealed class ScenarioReferences
|
||||||
|
{
|
||||||
|
/// <summary>Scene switch targets referenced in commands.</summary>
|
||||||
|
public List<string> SwitchTargets { get; set; } = new();
|
||||||
|
/// <summary>Localisation keys found in named parameters.</summary>
|
||||||
|
public List<string> LocaleKeys { get; set; } = new();
|
||||||
|
/// <summary>Quest IDs referenced in named <c>quest_id</c> parameters.</summary>
|
||||||
|
public List<string> QuestRefs { get; set; } = new();
|
||||||
|
/// <summary>Background IDs referenced in positional parameters of background-related commands.</summary>
|
||||||
|
public List<string> BackgroundRefs { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Describes a single scenario file found during scanning.</summary>
|
||||||
|
public sealed class ScenarioFileInfo
|
||||||
|
{
|
||||||
|
/// <summary>Absolute path on disk.</summary>
|
||||||
|
public string AbsolutePath { get; set; }
|
||||||
|
/// <summary>Filename without extension.</summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
/// <summary>Directory path relative to the mod's <c>scenarios/</c> folder.</summary>
|
||||||
|
public string RelativeDir { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A group of scenario files within the same subdirectory under <c>scenarios/</c>.</summary>
|
||||||
|
public sealed class ScenarioGroup
|
||||||
|
{
|
||||||
|
/// <summary>Relative directory path (empty for root-level files).</summary>
|
||||||
|
public string DirectoryPath { get; set; }
|
||||||
|
/// <summary>Files in this group, sorted by name.</summary>
|
||||||
|
public List<ScenarioFileInfo> Files { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Represents all scenario files for one mod, grouped by subdirectory.</summary>
|
||||||
|
public sealed class ModScenarioTree
|
||||||
|
{
|
||||||
|
/// <summary>The mod's identifier.</summary>
|
||||||
|
public string ModId { get; set; }
|
||||||
|
/// <summary>Groups of scenario files within this mod.</summary>
|
||||||
|
public List<ScenarioGroup> Groups { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Static service for discovering, reading, writing, and analysing scenario files.
|
||||||
|
/// Scans all <c>mods/*/scenarios/**/*.scenario</c> files in the project, groups
|
||||||
|
/// them by mod and subdirectory, parses YAML-like metadata blocks, and extracts
|
||||||
|
/// referenced background/scene/locale/quest identifiers. Used by
|
||||||
|
/// <see cref="ScenarioEditorWindow"/> for the file tree and info panel.
|
||||||
|
/// </summary>
|
||||||
|
public static class ScenarioFileService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Recursively scans <c>res://mods/*/scenarios/</c> for <c>.scenario</c> files.
|
||||||
|
/// Groups results by mod ID and subdirectory. Returns an empty list if the
|
||||||
|
/// mods directory does not exist.
|
||||||
|
/// </summary>
|
||||||
|
public static List<ModScenarioTree> ScanScenarioFiles()
|
||||||
|
{
|
||||||
|
var result = new List<ModScenarioTree>();
|
||||||
|
var roots = ModRegistry.AllModsRoots;
|
||||||
|
|
||||||
|
foreach (var (modsPath, _) in roots)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(modsPath))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foreach (var modDir in Directory.GetDirectories(modsPath).OrderBy(d => Path.GetFileName(d)))
|
||||||
|
{
|
||||||
|
var modId = Path.GetFileName(modDir);
|
||||||
|
var scenariosDir = Path.Combine(modDir, "scenarios");
|
||||||
|
if (!Directory.Exists(scenariosDir))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var scenarioFiles = Directory.GetFiles(scenariosDir, "*.scenario", SearchOption.AllDirectories);
|
||||||
|
if (scenarioFiles.Length == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var groups = scenarioFiles
|
||||||
|
.GroupBy(f =>
|
||||||
|
{
|
||||||
|
var relDir = Path.GetRelativePath(scenariosDir, Path.GetDirectoryName(f));
|
||||||
|
return relDir == "." ? "" : relDir.Replace('\\', '/');
|
||||||
|
})
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(g => new ScenarioGroup
|
||||||
|
{
|
||||||
|
DirectoryPath = g.Key,
|
||||||
|
Files = g.OrderBy(f => Path.GetFileNameWithoutExtension(f))
|
||||||
|
.Select(f => new ScenarioFileInfo
|
||||||
|
{
|
||||||
|
AbsolutePath = f,
|
||||||
|
Name = Path.GetFileNameWithoutExtension(f),
|
||||||
|
RelativeDir = g.Key
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
result.Add(new ModScenarioTree { ModId = modId, Groups = groups });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the header block (before first <c>---</c> line) in YAML-like
|
||||||
|
/// <c>key: value</c> format to extract scene, priority, and condition.
|
||||||
|
/// </summary>
|
||||||
|
public static ScenarioMetadata ParseMetadata(string text)
|
||||||
|
{
|
||||||
|
var meta = new ScenarioMetadata();
|
||||||
|
var metaEnd = text.IndexOf("\n---");
|
||||||
|
var metaText = metaEnd >= 0 ? text.Substring(0, metaEnd) : "";
|
||||||
|
|
||||||
|
foreach (var line in metaText.Split('\n'))
|
||||||
|
{
|
||||||
|
var t = line.Trim();
|
||||||
|
if (t.Length == 0 || t.StartsWith("#"))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var ci = t.IndexOf(':');
|
||||||
|
if (ci <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var key = t.Substring(0, ci).Trim().ToLowerInvariant();
|
||||||
|
var val = t.Substring(ci + 1).Trim();
|
||||||
|
|
||||||
|
switch (key)
|
||||||
|
{
|
||||||
|
case "scene": meta.Scene = val; break;
|
||||||
|
case "priority": meta.Priority = val; break;
|
||||||
|
case "condition": meta.Condition = val; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks the body of the scenario (after <c>---</c>) and extracts references:
|
||||||
|
/// background IDs, scene switch targets, locale keys, and quest IDs.
|
||||||
|
/// Uses <see cref="ScenarioSyntaxService.ExtractCommandParams"/> to tokenise
|
||||||
|
/// each line and <see cref="ScenarioCommandStrategyFactory"/> metadata to
|
||||||
|
/// classify positional parameters.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">Full scenario file text including header.</param>
|
||||||
|
public static ScenarioReferences ExtractReferences(string text)
|
||||||
|
{
|
||||||
|
var refs = new ScenarioReferences();
|
||||||
|
var metaEnd = text.IndexOf("\n---");
|
||||||
|
var content = metaEnd >= 0 ? text.Substring(metaEnd + 4) : text;
|
||||||
|
|
||||||
|
foreach (var rawLine in content.Split('\n'))
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(":"))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
ScenarioSyntaxService.ExtractCommandParams(line, out var cmd, out var positional, out var named);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(cmd))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var meta = ScenarioCommandStrategyFactory.GetReferenceMetadata(cmd);
|
||||||
|
if (meta != null && positional.Count > 0)
|
||||||
|
{
|
||||||
|
switch (meta.Positional0Kind)
|
||||||
|
{
|
||||||
|
case PositionalReferenceKind.Background:
|
||||||
|
refs.BackgroundRefs.Add(positional[0]);
|
||||||
|
break;
|
||||||
|
case PositionalReferenceKind.Scene:
|
||||||
|
refs.SwitchTargets.Add(positional[0]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (named.TryGetValue("key", out var lk))
|
||||||
|
refs.LocaleKeys.Add(lk);
|
||||||
|
if (named.TryGetValue("quest_id", out var qid))
|
||||||
|
refs.QuestRefs.Add(qid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the entire file at <paramref name="filePath"/> as a UTF-8 string.</summary>
|
||||||
|
public static string ReadAllText(string filePath)
|
||||||
|
{
|
||||||
|
return File.ReadAllText(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes <paramref name="text"/> to <paramref name="filePath"/>, overwriting existing content.</summary>
|
||||||
|
public static void WriteAllText(string filePath, string text)
|
||||||
|
{
|
||||||
|
File.WriteAllText(filePath, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Converts an absolute file path to a path relative to <c>res://</c>, using forward slashes.</summary>
|
||||||
|
public static string GetRelativePath(string absolutePath)
|
||||||
|
{
|
||||||
|
var projectPath = GameEnvironment.IsRuntime ? ProjectSettings.GlobalizePath("res://") : "";
|
||||||
|
return Path.GetRelativePath(projectPath, absolutePath).Replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SceneGraphData BuildSceneGraph()
|
||||||
|
{
|
||||||
|
var data = new SceneGraphData();
|
||||||
|
var sceneNodes = new Dictionary<string, SceneGraphNode>(System.StringComparer.OrdinalIgnoreCase);
|
||||||
|
var edgeSet = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var allFiles = ScanAllScenarioFiles();
|
||||||
|
|
||||||
|
foreach (var file in allFiles)
|
||||||
|
{
|
||||||
|
string text;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
text = File.ReadAllText(file.AbsolutePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = ParseMetadata(text);
|
||||||
|
if (string.IsNullOrEmpty(metadata.Scene) || metadata.Scene == "-")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var sceneKey = metadata.Scene.ToLowerInvariant();
|
||||||
|
|
||||||
|
if (!sceneNodes.TryGetValue(sceneKey, out var node))
|
||||||
|
{
|
||||||
|
node = new SceneGraphNode
|
||||||
|
{
|
||||||
|
SceneName = metadata.Scene,
|
||||||
|
ModId = file.ModId,
|
||||||
|
IsStartScene = string.Equals(metadata.Scene, "start_scene", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
};
|
||||||
|
sceneNodes[sceneKey] = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.ScenarioCount++;
|
||||||
|
node.Scenarios.Add(new ScenarioGraphLink
|
||||||
|
{
|
||||||
|
Name = file.Name,
|
||||||
|
Priority = int.TryParse(metadata.Priority, out var p) ? p : 0,
|
||||||
|
FilePath = file.AbsolutePath
|
||||||
|
});
|
||||||
|
|
||||||
|
var refs = ExtractReferences(text);
|
||||||
|
foreach (var target in refs.SwitchTargets)
|
||||||
|
{
|
||||||
|
var srcKey = metadata.Scene.ToLowerInvariant();
|
||||||
|
var dstKey = target.ToLowerInvariant();
|
||||||
|
var edgeKey = srcKey + "|" + dstKey;
|
||||||
|
if (!edgeSet.Contains(edgeKey))
|
||||||
|
{
|
||||||
|
edgeSet.Add(edgeKey);
|
||||||
|
data.Edges.Add(new SceneGraphEdge
|
||||||
|
{
|
||||||
|
FromScene = metadata.Scene,
|
||||||
|
ToScene = target
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var node in sceneNodes.Values)
|
||||||
|
data.Nodes.Add(node);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FlatScenarioFile
|
||||||
|
{
|
||||||
|
public string AbsolutePath;
|
||||||
|
public string Name;
|
||||||
|
public string ModId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FlatScenarioFile> ScanAllScenarioFiles()
|
||||||
|
{
|
||||||
|
var result = new List<FlatScenarioFile>();
|
||||||
|
var roots = ModRegistry.AllModsRoots;
|
||||||
|
|
||||||
|
foreach (var (modsPath, _) in roots)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(modsPath))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foreach (var modDir in Directory.GetDirectories(modsPath))
|
||||||
|
{
|
||||||
|
var modId = Path.GetFileName(modDir);
|
||||||
|
var scenariosDir = Path.Combine(modDir, "scenarios");
|
||||||
|
if (!Directory.Exists(scenariosDir))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foreach (var file in Directory.GetFiles(scenariosDir, "*.scenario", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
result.Add(new FlatScenarioFile
|
||||||
|
{
|
||||||
|
AbsolutePath = file,
|
||||||
|
Name = Path.GetFileNameWithoutExtension(file),
|
||||||
|
ModId = modId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.Services
|
||||||
|
{
|
||||||
|
public sealed class ScenarioGraphLink
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public int Priority { get; set; }
|
||||||
|
public string FilePath { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SceneGraphNode
|
||||||
|
{
|
||||||
|
public string SceneName { get; set; }
|
||||||
|
public string ModId { get; set; }
|
||||||
|
public int ScenarioCount { get; set; }
|
||||||
|
public List<string> Backgrounds { get; set; } = new();
|
||||||
|
public bool IsStartScene { get; set; }
|
||||||
|
public List<ScenarioGraphLink> Scenarios { get; set; } = new();
|
||||||
|
public bool HasErrors { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SceneGraphEdge
|
||||||
|
{
|
||||||
|
public string FromScene { get; set; }
|
||||||
|
public string ToScene { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SceneGraphData
|
||||||
|
{
|
||||||
|
public List<SceneGraphNode> Nodes { get; set; } = new();
|
||||||
|
public List<SceneGraphEdge> Edges { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Scenarios;
|
||||||
|
using Cthangover.Core.Factories.Impls;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Tools.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Static service providing scenario DSL syntax highlighting, tokenisation,
|
||||||
|
/// and command-line parsing. <see cref="CreateHighlighter"/> produces a
|
||||||
|
/// <see cref="CodeHighlighter"/> for the scenario editor's
|
||||||
|
/// <see cref="TextEdit"/>. <see cref="ExtractCommandParams"/> tokenises a
|
||||||
|
/// scenario command line into a command name, positional arguments, and
|
||||||
|
/// named (<c>key=value</c>) parameters — used by
|
||||||
|
/// <see cref="ScenarioFileService.ExtractReferences"/> for reference analysis.
|
||||||
|
/// </summary>
|
||||||
|
public static class ScenarioSyntaxService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="CodeHighlighter"/> for scenario DSL syntax.
|
||||||
|
/// Comments (lines starting with <c>#</c>) are green, strings are orange,
|
||||||
|
/// command names are blue, and argument keywords (e.g. <c>key</c>,
|
||||||
|
/// <c>wait</c>, <c>quest_id</c>) are amber.
|
||||||
|
/// </summary>
|
||||||
|
public static CodeHighlighter CreateHighlighter()
|
||||||
|
{
|
||||||
|
var hl = new CodeHighlighter();
|
||||||
|
hl.AddColorRegion("#", null, new Color(0.45f, 0.75f, 0.45f), true);
|
||||||
|
hl.AddColorRegion("\"", "\"", new Color(1f, 0.6f, 0.4f), false);
|
||||||
|
hl.NumberColor = new Color(0.7f, 0.7f, 1f);
|
||||||
|
hl.SymbolColor = new Color(0.8f, 0.8f, 0.8f);
|
||||||
|
|
||||||
|
var cmdColor = new Color(0.4f, 0.7f, 1f);
|
||||||
|
hl.KeywordColors = new Godot.Collections.Dictionary();
|
||||||
|
|
||||||
|
foreach (var cmd in ScenarioCommandStrategyFactory.GetAllCommandNames())
|
||||||
|
hl.KeywordColors[cmd] = cmdColor;
|
||||||
|
|
||||||
|
foreach (var cmd in new[] {
|
||||||
|
"option", "select"
|
||||||
|
}) hl.KeywordColors[cmd] = cmdColor;
|
||||||
|
|
||||||
|
var argColor = new Color(0.8f, 0.6f, 0.3f);
|
||||||
|
foreach (var kw in new[] {
|
||||||
|
"key", "first", "second", "hide_color", "wait", "speed", "hidden",
|
||||||
|
"sprites", "loop", "target", "quest_id", "show", "hide", "duration"
|
||||||
|
}) hl.KeywordColors[kw] = argColor;
|
||||||
|
|
||||||
|
return hl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits a line into tokens, handling quoted strings (with backslash
|
||||||
|
/// escaping) and whitespace. Returns all non-whitespace tokens in order.
|
||||||
|
/// </summary>
|
||||||
|
public static List<string> Tokenize(string line)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
var i = 0;
|
||||||
|
while (i < line.Length)
|
||||||
|
{
|
||||||
|
if (char.IsWhiteSpace(line[i])) { i++; continue; }
|
||||||
|
if (line[i] == '"')
|
||||||
|
{
|
||||||
|
i++;
|
||||||
|
var start = i;
|
||||||
|
while (i < line.Length && line[i] != '"')
|
||||||
|
{
|
||||||
|
if (line[i] == '\\' && i + 1 < line.Length) i++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
result.Add(line.Substring(start, i - start));
|
||||||
|
if (i < line.Length) i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var tok = i;
|
||||||
|
while (i < line.Length && !char.IsWhiteSpace(line[i])) i++;
|
||||||
|
result.Add(line.Substring(tok, i - tok));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Strips surrounding double quotes from a value if present.</summary>
|
||||||
|
public static string StripQuotes(string val)
|
||||||
|
{
|
||||||
|
if (val.Length >= 2 && val.StartsWith("\"") && val.EndsWith("\""))
|
||||||
|
return val.Substring(1, val.Length - 2);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a scenario command line into its components. Tokenises via
|
||||||
|
/// <see cref="Tokenize"/>, then extracts the first token as the command
|
||||||
|
/// name (lowercased), remaining bare tokens as positional arguments, and
|
||||||
|
/// <c>key=value</c> pairs as named arguments. The <c>-></c> arrow token
|
||||||
|
/// is consumed but discarded.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="line">A single scenario command line (trimmed).</param>
|
||||||
|
/// <param name="cmd">The command name, lowercased.</param>
|
||||||
|
/// <param name="positional">Positional arguments in order.</param>
|
||||||
|
/// <param name="named">Named arguments keyed by lowercased name.</param>
|
||||||
|
public static void ExtractCommandParams(string line, out string cmd, out List<string> positional, out Dictionary<string, string> named)
|
||||||
|
{
|
||||||
|
positional = new List<string>();
|
||||||
|
named = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
|
||||||
|
cmd = "";
|
||||||
|
|
||||||
|
var tokens = Tokenize(line);
|
||||||
|
if (tokens.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
cmd = tokens[0].ToLowerInvariant();
|
||||||
|
|
||||||
|
for (int t = 1; t < tokens.Count; t++)
|
||||||
|
{
|
||||||
|
var tk = tokens[t];
|
||||||
|
if (tk == "->") { t++; continue; }
|
||||||
|
var eq = tk.IndexOf('=');
|
||||||
|
if (eq > 0)
|
||||||
|
named[tk.Substring(0, eq).ToLowerInvariant()] = StripQuotes(tk.Substring(eq + 1));
|
||||||
|
else
|
||||||
|
positional.Add(tk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user