This commit is contained in:
2026-09-10 10:59:40 +03:00
commit 8e6ad52ee4
44 changed files with 6842 additions and 0 deletions
+127
View File
@@ -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:
+128
View File
@@ -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:
+11
View File
@@ -0,0 +1,11 @@
{
"id": "tools",
"name": "Tools",
"version": "1.0.0",
"author": "ct",
"description": "Development tools",
"sources": [
"src/**/*.cs"
],
"depends": ["core>=1"]
}
+12
View File
@@ -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
+47
View File
@@ -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 = "-"
+10
View File
@@ -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 (01).</summary>
public float RectX { get; set; }
/// <summary>Normalised Y position of the rect collider (01).</summary>
public float RectY { get; set; }
/// <summary>Normalised width of the rect collider (01).</summary>
public float RectW { get; set; } = 0.1f;
/// <summary>Normalised height of the rect collider (01).</summary>
public float RectH { get; set; } = 0.1f;
/// <summary>Normalised X centre of the circle collider (01).</summary>
public float CircleX { get; set; } = 0.5f;
/// <summary>Normalised Y centre of the circle collider (01).</summary>
public float CircleY { get; set; } = 0.5f;
/// <summary>Normalised radius of the circle collider (01).</summary>
public float CircleRadius { get; set; } = 0.05f;
/// <summary>List of normalised (01) 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();
}
}
}
+175
View File
@@ -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("\"", "\\\"")}\"";
}
}
}
+58
View File
@@ -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;
}
}
+374
View File
@@ -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
{
}
}
}
}
+297
View File
@@ -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();
}
}
+354
View File
@@ -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;
}
}
}
+103
View File
@@ -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);
}
}
}
+211
View File
@@ -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);
}
}
}
+62
View File
@@ -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();
}
}
}
+24
View File
@@ -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; }
}
}
+25
View File
@@ -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);
}
}
}
+27
View File
@@ -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);
}
}
}
+48
View File
@@ -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]);
}
}
}
+37
View File
@@ -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);
}
}
}
+391
View File
@@ -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 }
}
+26
View File
@@ -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);
}
}
}
+45
View File
@@ -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));
}
}
}
+430
View File
@@ -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();
}
}
+723
View File
@@ -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]";
}
}
}
+486
View File
@@ -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);
}
}
}
+58
View File
@@ -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;
}
}
}
+104
View File
@@ -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;
}
}
}
+25
View File
@@ -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;
}
}
}
+283
View File
@@ -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);
}
}
}
+82
View File
@@ -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 + "\"";
}
}
}
+73
View File
@@ -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;
}
}
}
}
+338
View File
@@ -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;
}
}
}
+34
View File
@@ -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();
}
}
+126
View File
@@ -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);
}
}
}
}