diff --git a/collections/novel_editor.json b/collections/novel_editor.json new file mode 100644 index 0000000..7be3447 --- /dev/null +++ b/collections/novel_editor.json @@ -0,0 +1,50 @@ +{ + "id": "novel_editor", + "kind": "game", + "name": "Novel Editor", + "author": "ct", + "version": "1.0.0", + "description": "Authoring workspace instead of a game: opens a screen with every installed editor — scenarios, scenes, maps, interactive objects, lighting, catalog export — over the novel engine and the story content they edit.", + "entry": { "scene": "novel_editor" }, + "tags": ["tools", "editor"], + "locale": [ + { "lang": "en", "name": "Novel Editor", "description": "Authoring workspace instead of a game: opens a screen with every installed editor — scenarios, scenes, maps, interactive objects, lighting, catalog export — over the novel engine and the story content they edit." }, + { "lang": "ru", "name": "Редактор новелл", "description": "Рабочее место автора вместо игры: открывает экран со всеми установленными редакторами — сценариев, сцен, карт, интерактива, света, экспорта каталога — поверх движка новеллы и сюжета, который они правят." } + ], + "mechanics": [ + { + "id": "editors", + "name": "Editors", + "role": "tools", + "description": "Editor windows and the launcher screen that lists them.", + "locale": [ { "lang": "ru", "name": "Редакторы", "description": "Окна редакторов и стартовый экран со списком." } ], + "mods": ["tools>=1"] + }, + { + "id": "novel_engine", + "name": "Novel engine", + "role": "engine", + "description": "Scenes, scenarios, dialogs, interactive objects, effects, time and lighting, the world map: what the editors preview and play.", + "locale": [ { "lang": "ru", "name": "Движок новеллы", "description": "Сцены, сценарии, диалоги, интерактив, эффекты, время и свет, карта мира: то, что редакторы показывают и проигрывают." } ], + "mods": ["visual_novel_core>=1", "base2d>=1", "dialog_base>=1", "interactive_base>=1", "effects_base>=1", "time_base>=1", "map_base>=1"] + }, + { + "id": "story", + "name": "Story", + "role": "content", + "description": "The bundled story content to edit: Murakami's line and the starter party.", + "locale": [ { "lang": "ru", "name": "Сюжет", "description": "Встроенный сюжет для правки: линия Мураками и стартовая партия." } ], + "optional": true, + "mods": ["murakami>=1", "starter_party>=1"] + }, + { + "id": "validation", + "name": "Validation", + "role": "tools", + "description": "Content validator, the cross-mod resource atlas and the localization key map.", + "locale": [ { "lang": "ru", "name": "Проверки", "description": "Валидатор контента, карта ресурсов между модами и карта ключей локализации." } ], + "optional": true, + "mods": ["validator>=0.1", "resource_atlas>=0.1", "locale_atlas>=0.1"] + } + ] +} diff --git a/locale/catalog_export_en.properties b/locale/catalog_export_en.properties index 9f3c41a..8e9c6c3 100644 --- a/locale/catalog_export_en.properties +++ b/locale/catalog_export_en.properties @@ -1,2 +1,5 @@ # Catalog export tool — extra keys mods/catalog_select_mod = Select a mod: +mods/catalog_add_locale = Add language +mods/catalog_locale_name = Name in this language +mods/catalog_locale_desc = Description in this language diff --git a/locale/catalog_export_ru.properties b/locale/catalog_export_ru.properties index 9c721f3..9de9806 100644 --- a/locale/catalog_export_ru.properties +++ b/locale/catalog_export_ru.properties @@ -1,2 +1,5 @@ # Catalog export tool — дополнительные ключи mods/catalog_select_mod = Выберите мод: +mods/catalog_add_locale = Добавить язык +mods/catalog_locale_name = Название на этом языке +mods/catalog_locale_desc = Описание на этом языке diff --git a/locale/ui_en.properties b/locale/ui_en.properties index aaa0d9a..cefa27d 100644 --- a/locale/ui_en.properties +++ b/locale/ui_en.properties @@ -1,39 +1,145 @@ mod_tools_name = Tools mod_tools_desc = Developer toolbox: scenario graph editor, map editor with zones, scene builder with inspector, interactive and light editors, and the mod catalog export window. -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/common/save = Save +tools/common/save_tip = Save the file (Ctrl+S) +tools/common/saved = Saved: {0} +tools/common/loaded = Opened: {0} +tools/common/load_error = Load error +tools/common/readonly = Cannot save: the mod is read-only (zip) or the path is invalid. +tools/common/no_writable_mods = No writable mods (only folder mods can be edited). +tools/common/no_files = Mod "{0}" has no such files yet — create one with "+". +tools/common/no_file_to_save = Pick or create a file first ("+" next to the file list). +tools/common/new_file = New file +tools/common/new_file_hint = The file is created in {0}/{1}/ with the {2} extension on first save. +tools/common/new_file_status = New file {0} — created on save. +tools/common/mod_tip = Mod (mods that already have such files first; · — none yet) +tools/common/file_tip = File inside the mod +tools/common/search = Search… +tools/common/json = JSON +tools/common/json_tip = Show the resulting file JSON +tools/common/background_missing = Background "{0}" of scene "{1}" is not provided by any mod — pick one by hand. + +tools/map_editor/title = Map Editor +tools/map_editor/hint = LMB — select / drag · RMB — delete vertex · double-click, Enter or Esc — finish region · Del — delete zone · Ctrl+S — save +tools/map_editor/reload_map = Refresh in-game map +tools/map_editor/reload_map_tip = Re-read saved map files in the running game +tools/map_editor/reloaded = In-game map refreshed. +tools/map_editor/select_mode = Select +tools/map_editor/draw_region = Draw +tools/map_editor/draw_started = Click the map to place vertices. Double-click, Enter or Esc finishes, RMB removes the last one. +tools/map_editor/background = Preview background +tools/map_editor/zones = Zones +tools/map_editor/add_region = + Region +tools/map_editor/add_region_tip = New region: draw the polygon by clicking the map +tools/map_editor/add_marker = + Marker +tools/map_editor/add_label = + Label +tools/map_editor/delete = Delete zone +tools/map_editor/delete_tip = Delete the selected zone (Del) +tools/map_editor/delete_confirm = Delete the selected zone? The change reaches the file on save. +tools/map_editor/deleted = Zone "{0}" deleted. +tools/map_editor/props = Zone properties +tools/map_editor/zone_missing = Pick a zone in the list or on the map. +tools/map_editor/id = ID +tools/map_editor/id_empty = Zone ID cannot be empty. +tools/map_editor/duplicate_id = Zone ID already used: +tools/map_editor/title_key = Title +tools/map_editor/description = Description +tools/map_editor/no_translation = key not found in localization +tools/map_editor/scene = Scene +tools/map_editor/scene_none = (none) +tools/map_editor/availability = Availability +tools/map_editor/locked = Locked for travel +tools/map_editor/hidden = Hidden from the map +tools/map_editor/effective = In game: +tools/map_editor/effective_open = open +tools/map_editor/effective_locked = locked (Locked flag) +tools/map_editor/effective_no_scene = locked (no scene) +tools/map_editor/effective_unknown_scene = locked (scene not found) +tools/map_editor/effective_hidden = hidden from the map +tools/map_editor/marker = Marker +tools/map_editor/image = Icon +tools/map_editor/image_placeholder = texture ID +tools/map_editor/icon_search = Find icon… +tools/map_editor/label_section = Label +tools/map_editor/label = Text label on the map +tools/map_editor/x = X (0–1) +tools/map_editor/y = Y (0–1) +tools/map_editor/vertices = Polygon vertices +tools/map_editor/vertices_hint = Drag vertices on the map; RMB on a vertex deletes it. +tools/map_editor/add_vertex = + Vertex +tools/map_editor/delete_vertex = Delete vertex +tools/map_editor/preview = ▶ As in game +tools/map_editor/preview_tip = View the map as in game: hover, zone card, markers (unsaved edits included) +tools/map_editor/preview_on = Preview: Esc or ✕ returns to the editor. +tools/map_editor/preview_off = Editing mode. +tools/map_editor/preview_missing = Cannot load the map scene (map_widget.tscn). + +tools/scenario_editor/title = Scenario Editor +tools/scenario_editor/hint = Ctrl+S — save · F5 — play the scenario · click a scene in the graph — its scenarios +tools/scenario_editor/play = ▶ Play +tools/scenario_editor/play_tooltip = Save and play the scenario in game (F5) +tools/scenario_editor/play_stopped = Test play stopped. +tools/scenario_editor/stop = ■ Stop +tools/scenario_editor/open_file = Open in external editor +tools/scenario_editor/open_file_tip = Open the file with the system default program +tools/scenario_editor/open_file_error = Cannot open the file +tools/scenario_editor/rescan = Rescan +tools/scenario_editor/rescan_tip = Re-read the scenario list and graph (after adding files) +tools/scenario_editor/rescanned = Scenario list refreshed. +tools/scenario_editor/search_files = Find file… +tools/scenario_editor/tab_files = Files +tools/scenario_editor/tab_info = Summary +tools/scenario_editor/tab_graph = Scene graph +tools/scenario_editor/graph_select_node = Select a scene in the graph to see its scenarios and transitions. +tools/scenario_editor/graph_node = mod {0} · scenarios {1} +tools/scenario_editor/graph_node_bg = background: {0} +tools/scenario_editor/graph_detail_mod = mod: {0} +tools/scenario_editor/graph_detail_scenarios = Scenarios ({0}), by priority: +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 switches +tools/scenario_editor/info_locale_keys = Locale keys +tools/scenario_editor/info_quest_refs = Quests +tools/scenario_editor/bg_previews = Scenario backgrounds +tools/scenario_editor/bg_missing = Background not found: {0} +tools/scenario_editor/no_backgrounds = The scenario does not change the background +tools/scenario_editor/save_error = Save error: {0} +tools/scenario_editor/readonly_zip = Files of zip mods are read-only +tools/scenario_editor/no_scene_error = The scenario header has no scene (scene:) — nothing to play. +tools/scenario_editor/unsaved_title = Unsaved changes +tools/scenario_editor/unsaved_switch_text = This tab has unsaved changes. Close it without saving? 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/hint = Drag a node by its body or handles · click — pick the node under the cursor · Del — delete node · Ctrl+S — save and rebuild +tools/scene_builder/base_scene = Base scene the mod scene is shown over +tools/scene_builder/mod = Mod +tools/scene_builder/scene = Mod scene (.tscn) +tools/scene_builder/empty_scene = (empty base scene) +tools/scene_builder/select_mod = — pick a mod — +tools/scene_builder/select_scene = — pick a scene — +tools/scene_builder/save_tip = Save the .tscn and rebuild the preview (Ctrl+S) +tools/scene_builder/reload = Reload from disk +tools/scene_builder/reload_tip = Drop unsaved edits and re-read the scene file +tools/scene_builder/reloaded = Scene reloaded from disk. tools/scene_builder/saved_ok = Saved. -tools/scene_builder/reloaded = Reloaded. -tools/scene_builder/inspector = Inspector +tools/scene_builder/saved_rebuilt = Saved, preview rebuilt. +tools/scene_builder/autosaved = Autosaved. +tools/scene_builder/error_save = Save error: the mod is read-only. +tools/scene_builder/error_no_scene = Pick a mod scene first. +tools/scene_builder/hierarchy = Hierarchy +tools/scene_builder/hierarchy_hint = Gold — mod scene root, bright — editable nodes, faint — base scene (read-only). +tools/scene_builder/node_type_tip = Type of the new node +tools/scene_builder/add_node_tip = Add a node of the chosen type into the selected node (or the scene root) +tools/scene_builder/delete_node_tip = Delete the selected mod scene node +tools/scene_builder/delete_node_title = Delete node +tools/scene_builder/delete_node_confirm = Delete the selected node with its children? The file is saved automatically. +tools/scene_builder/refresh_tip = Refresh the node tree +tools/scene_builder/tab_inspector = Inspector +tools/scene_builder/tab_tscn = .tscn source +tools/scene_builder/tscn_hint = Inspector and handle edits save themselves; hand edits of the text save with Ctrl+S, which also rebuilds the preview. +tools/scene_builder/inspector_cat_container = In container 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) @@ -42,135 +148,103 @@ 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/readonly_zip = File is provided by a zip mod and is read-only -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? - -mods/catalog_select_mod = Select a mod: - -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/hint = Click a collider — select · drag handles · in polygon mode a click on the background adds a vertex, RMB on a vertex deletes it · Del — delete object · Ctrl+S — save +tools/interactive_editor/items = Objects in file +tools/interactive_editor/add = + New +tools/interactive_editor/duplicate = Duplicate +tools/interactive_editor/delete = Delete object +tools/interactive_editor/delete_confirm = Remove the selected interactive from the file? The change reaches the file on save. +tools/interactive_editor/deleted = Object "{0}" removed. +tools/interactive_editor/background = Preview background +tools/interactive_editor/props = Properties +tools/interactive_editor/none_selected = Pick an object in the list or on the preview. +tools/interactive_editor/label_id = ID +tools/interactive_editor/label_texture_key = Texture +tools/interactive_editor/texture_placeholder = no sprite +tools/interactive_editor/texture_tip = Sprite texture ID; empty — an invisible clickable area +tools/interactive_editor/label_layer = Layer +tools/interactive_editor/label_z = Z order +tools/interactive_editor/label_cursor = Cursor +tools/interactive_editor/cursor_none = (default) +tools/interactive_editor/enabled = Enabled +tools/interactive_editor/visible = Visible +tools/interactive_editor/collider = Collider +tools/interactive_editor/label_collider_type = Shape +tools/interactive_editor/hit_rect = rectangle +tools/interactive_editor/hit_circle = circle +tools/interactive_editor/hit_polygon = polygon +tools/interactive_editor/label_x = X +tools/interactive_editor/label_y = Y +tools/interactive_editor/label_w = Width +tools/interactive_editor/label_h = Height +tools/interactive_editor/label_cx = Centre X +tools/interactive_editor/label_cy = Centre Y +tools/interactive_editor/label_r = Radius +tools/interactive_editor/polygon_hint = A click on the background adds a vertex, RMB on a vertex deletes it. At least 3 vertices. +tools/interactive_editor/polygon_too_small = Polygon "{0}" has fewer than 3 vertices. +tools/interactive_editor/highlight = Highlight +tools/interactive_editor/label_highlight_color = Colour +tools/interactive_editor/label_highlight_scale = Scale +tools/interactive_editor/actions = Actions +tools/interactive_editor/label_onclick_scenario = Scenario +tools/interactive_editor/label_onclick_commands = Commands on click (one per line) +tools/interactive_editor/label_hover_enter = Hover in +tools/interactive_editor/label_hover_leave = Hover out +tools/interactive_editor/id_empty = An object has an empty ID. +tools/interactive_editor/duplicate_id = Duplicate object ID: -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/light_editor/title = Light Editor +tools/light_editor/hint = Double-click the background — new light · drag lights · Del — delete · Ctrl+S — write light_set into the scenario +tools/light_editor/scenario_tip = Scenario whose light_set line is edited (● — already sets lights) +tools/light_editor/pick_scenario = — pick a scenario — +tools/light_editor/save_tip = Write the light_set line into the scenario (Ctrl+S) +tools/light_editor/copy_line = Copy line +tools/light_editor/copy_line_tip = Copy the light_set command to the clipboard +tools/light_editor/copied = light_set line copied. +tools/light_editor/show_line = light_set +tools/light_editor/loaded = Lights of {0} loaded. +tools/light_editor/no_lights_yet = {0} has no light_set yet — add lights, the line is inserted after the header. +tools/light_editor/static_lights = Lights +tools/light_editor/add_light = + Light +tools/light_editor/delete = Delete +tools/light_editor/limit = At most {0} static lights. +tools/light_editor/selected_props = Selected light +tools/light_editor/none_selected = Pick a light in the list or on the preview. +tools/light_editor/x = X (0–1) +tools/light_editor/y = Y (0–1) +tools/light_editor/radius = Radius +tools/light_editor/radius_tip = Radius in pixels of the 1920 px game screen +tools/light_editor/influence = Strength +tools/light_editor/influence_tip = Light influence (0–1) +tools/light_editor/color = Colour +tools/light_editor/background = Preview background -tools/interactive_editor/tab_background = Background -tools/interactive_editor/tab_interactive = Interactive -tools/interactive_editor/tab_scenario = Scenarios +tools/catalog_export/hint = The archive and hashes are computed on export; the JSON is written next to the mod archive +tools/catalog_export/mod_info = Mod +tools/catalog_export/id = ID +tools/catalog_export/name = Name +tools/catalog_export/metadata = Catalog +tools/catalog_export/locales = Localizations +tools/catalog_export/locales_hint = With no rows the English name and description of the manifest are used. +tools/catalog_export/remove_locale = Remove language +tools/catalog_export/preview = Resulting JSON +tools/catalog_export/copied = JSON copied to the clipboard. +tools/catalog_export/export_tip = Build the archive, compute hashes and write the catalog entry JSON +tools/catalog_export/exported = Exported: {0} +tools/catalog_export/export_error = Export error +tools/catalog_export/url_required = Set the download URL — a catalog entry is useless without it. -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: - -tools/map_editor/title = Map Editor -tools/map_editor/mod = Mod: -tools/map_editor/file = File: -tools/map_editor/load = Load -tools/map_editor/save = Save -tools/map_editor/save_ok = Saved. -tools/map_editor/reloaded = Map reloaded. -tools/map_editor/load_error = Load error -tools/map_editor/readonly = Cannot save: mod is not writable (zip) or file path is invalid. -tools/map_editor/no_mods = No writable mods -tools/map_editor/no_file = (new file) -tools/map_editor/reload_map = Reload map -tools/map_editor/background = Background: -tools/map_editor/zones = Zones -tools/map_editor/props = Selected zone -tools/map_editor/add_region = + Region -tools/map_editor/add_marker = + Marker -tools/map_editor/add_label = + Label -tools/map_editor/delete = Delete -tools/map_editor/select_mode = Select -tools/map_editor/draw_region = Draw region -tools/map_editor/id = ID: -tools/map_editor/id_empty = ID must not be empty. -tools/map_editor/duplicate_id = Duplicate zone ID. -tools/map_editor/title_key = Title locale key: -tools/map_editor/no_translation = no translation -tools/map_editor/scene = Scene: -tools/map_editor/scene_none = (none) -tools/map_editor/description = Description locale key: -tools/map_editor/image = Marker icon: -tools/map_editor/image_pos = Marker position (0-1): -tools/map_editor/label = Text label on map -tools/map_editor/locked = Closed for travel (locked) -tools/map_editor/hidden = Hidden from map -tools/map_editor/vertices = Polygon vertices -tools/map_editor/add_vertex = + Vertex -tools/map_editor/x = x: -tools/map_editor/y = y: -tools/map_editor/zone_missing = No zone selected. -tools/map_editor/preview = ▶ Preview -tools/map_editor/preview_tip = View the map as in game: hover, location card, markers -tools/map_editor/preview_on = Preview mode: Esc or ✕ returns to the editor. -tools/map_editor/preview_off = Edit mode. -tools/map_editor/preview_missing = Failed to load the map widget scene (map_widget.tscn). -tools/map_editor/effective = In game: -tools/map_editor/effective_open = available -tools/map_editor/effective_locked = closed (flag) -tools/map_editor/effective_no_scene = closed (no scene) -tools/map_editor/effective_unknown_scene = closed (scene not found) -tools/map_editor/effective_hidden = hidden from map +tools/hub/title = Novel Editor +tools/hub/hint = Pick an editor — windows open over this screen. A scenario can be played right from the editor (F5). +tools/hub/editors = Editors +tools/hub/empty = No editors are installed. +tools/hub/main_menu = Main menu diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties index 634ccba..3c90eb3 100644 --- a/locale/ui_ru.properties +++ b/locale/ui_ru.properties @@ -1,40 +1,145 @@ -# Русская локализация для инструментов разработки mod_tools_name = Инструменты mod_tools_desc = Инструменты разработчика: редактор сценариев (граф), редактор карты с зонами, конструктор сцен с инспектором, редакторы интерактива и света, экспорт каталога модов. -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/common/save = Сохранить +tools/common/save_tip = Сохранить файл (Ctrl+S) +tools/common/saved = Сохранено: {0} +tools/common/loaded = Открыт: {0} +tools/common/load_error = Ошибка загрузки +tools/common/readonly = Не удалось сохранить: мод доступен только для чтения (zip) или путь неверен. +tools/common/no_writable_mods = Нет модов, доступных для записи (только папочные моды можно редактировать). +tools/common/no_files = В моде «{0}» таких файлов пока нет — создайте новый кнопкой «+». +tools/common/no_file_to_save = Сначала выберите или создайте файл («+» рядом со списком файлов). +tools/common/new_file = Новый файл +tools/common/new_file_hint = Файл будет создан в {0}/{1}/ с расширением {2} при первом сохранении. +tools/common/new_file_status = Новый файл {0} — будет создан при сохранении. +tools/common/mod_tip = Мод (сначала — моды, где такие файлы уже есть; · — пока нет) +tools/common/file_tip = Файл внутри мода +tools/common/search = Поиск… +tools/common/json = JSON +tools/common/json_tip = Показать итоговый JSON файла +tools/common/background_missing = Фон «{0}» сцены «{1}» не найден ни в одном моде — выберите фон вручную. + +tools/map_editor/title = Редактор карты +tools/map_editor/hint = ЛКМ — выбрать / тянуть · ПКМ — удалить вершину · двойной клик, Enter или Esc — закончить регион · Del — удалить зону · Ctrl+S — сохранить +tools/map_editor/reload_map = Обновить карту в игре +tools/map_editor/reload_map_tip = Перечитать сохранённые файлы карты в запущенной игре +tools/map_editor/reloaded = Карта в игре обновлена. +tools/map_editor/select_mode = Выбор +tools/map_editor/draw_region = Рисование +tools/map_editor/draw_started = Кликайте по карте, чтобы ставить вершины. Двойной клик, Enter или Esc — закончить, ПКМ — убрать последнюю. +tools/map_editor/background = Фон превью +tools/map_editor/zones = Зоны +tools/map_editor/add_region = + Регион +tools/map_editor/add_region_tip = Новый регион: нарисуйте полигон кликами по карте +tools/map_editor/add_marker = + Метка +tools/map_editor/add_label = + Надпись +tools/map_editor/delete = Удалить зону +tools/map_editor/delete_tip = Удалить выбранную зону (Del) +tools/map_editor/delete_confirm = Удалить выбранную зону? Изменение попадёт в файл при сохранении. +tools/map_editor/deleted = Зона «{0}» удалена. +tools/map_editor/props = Свойства зоны +tools/map_editor/zone_missing = Выберите зону в списке или на карте. +tools/map_editor/id = ID +tools/map_editor/id_empty = ID зоны не может быть пустым. +tools/map_editor/duplicate_id = ID зоны уже занят: +tools/map_editor/title_key = Название +tools/map_editor/description = Описание +tools/map_editor/no_translation = ключ не найден в локализации +tools/map_editor/scene = Сцена +tools/map_editor/scene_none = (нет) +tools/map_editor/availability = Доступность +tools/map_editor/locked = Закрыта для посещения +tools/map_editor/hidden = Скрыта с карты +tools/map_editor/effective = В игре: +tools/map_editor/effective_open = доступна +tools/map_editor/effective_locked = закрыта (флаг «Закрыта») +tools/map_editor/effective_no_scene = закрыта (сцена не задана) +tools/map_editor/effective_unknown_scene = закрыта (сцена не найдена) +tools/map_editor/effective_hidden = скрыта с карты +tools/map_editor/marker = Метка +tools/map_editor/image = Иконка +tools/map_editor/image_placeholder = ID текстуры +tools/map_editor/icon_search = Поиск иконки… +tools/map_editor/label_section = Надпись +tools/map_editor/label = Текстовая надпись на карте +tools/map_editor/x = X (0–1) +tools/map_editor/y = Y (0–1) +tools/map_editor/vertices = Вершины полигона +tools/map_editor/vertices_hint = Тяните вершины на карте; ПКМ по вершине — удалить. +tools/map_editor/add_vertex = + Вершина +tools/map_editor/delete_vertex = Удалить вершину +tools/map_editor/preview = ▶ Как в игре +tools/map_editor/preview_tip = Посмотреть карту как в игре: наведение, карточка зоны, метки (несохранённые правки тоже) +tools/map_editor/preview_on = Просмотр: Esc или ✕ — вернуться к редактору. +tools/map_editor/preview_off = Режим редактирования. +tools/map_editor/preview_missing = Не удалось загрузить сцену карты (map_widget.tscn). + +tools/scenario_editor/title = Редактор сценариев +tools/scenario_editor/hint = Ctrl+S — сохранить · F5 — запустить сценарий · клик по сцене в графе — список её сценариев +tools/scenario_editor/play = ▶ Запустить +tools/scenario_editor/play_tooltip = Сохранить и запустить сценарий в игре (F5) +tools/scenario_editor/play_stopped = Тестовый запуск остановлен. +tools/scenario_editor/stop = ■ Стоп +tools/scenario_editor/open_file = Открыть во внешнем редакторе +tools/scenario_editor/open_file_tip = Открыть файл программой, назначенной в системе +tools/scenario_editor/open_file_error = Не удалось открыть файл +tools/scenario_editor/rescan = Пересканировать +tools/scenario_editor/rescan_tip = Перечитать список сценариев и граф (после добавления файлов) +tools/scenario_editor/rescanned = Список сценариев обновлён. +tools/scenario_editor/search_files = Поиск файла… +tools/scenario_editor/tab_files = Файлы +tools/scenario_editor/tab_info = Сводка +tools/scenario_editor/tab_graph = Граф сцен +tools/scenario_editor/graph_select_node = Выберите сцену в графе, чтобы увидеть её сценарии и переходы. +tools/scenario_editor/graph_node = мод {0} · сценариев {1} +tools/scenario_editor/graph_node_bg = фон: {0} +tools/scenario_editor/graph_detail_mod = мод: {0} +tools/scenario_editor/graph_detail_scenarios = Сценарии ({0}), по приоритету: +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/bg_missing = Фон не найден: {0} +tools/scenario_editor/no_backgrounds = Сценарий не меняет фон +tools/scenario_editor/save_error = Ошибка сохранения: {0} +tools/scenario_editor/readonly_zip = Файл из zip-мода доступен только для чтения +tools/scenario_editor/no_scene_error = В заголовке сценария не указана сцена (scene:) — запускать некуда. +tools/scenario_editor/unsaved_title = Несохранённые изменения +tools/scenario_editor/unsaved_switch_text = В этой вкладке есть несохранённые изменения. Закрыть её без сохранения? 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/hint = Тяните узел за тело или маркеры · клик — выбрать узел под курсором · Del — удалить узел · Ctrl+S — сохранить и пересобрать +tools/scene_builder/base_scene = Базовая сцена, поверх которой показывается мод-сцена +tools/scene_builder/mod = Мод +tools/scene_builder/scene = Сцена мода (.tscn) +tools/scene_builder/empty_scene = (пустая базовая сцена) +tools/scene_builder/select_mod = — выберите мод — +tools/scene_builder/select_scene = — выберите сцену — +tools/scene_builder/save_tip = Сохранить .tscn и пересобрать превью (Ctrl+S) +tools/scene_builder/reload = Перечитать с диска +tools/scene_builder/reload_tip = Отбросить несохранённое и перечитать сцену из файла +tools/scene_builder/reloaded = Сцена перечитана с диска. tools/scene_builder/saved_ok = Сохранено. -tools/scene_builder/reloaded = Перезагружено. -tools/scene_builder/inspector = Инспектор +tools/scene_builder/saved_rebuilt = Сохранено, превью пересобрано. +tools/scene_builder/autosaved = Автосохранено. +tools/scene_builder/error_save = Ошибка сохранения: мод доступен только для чтения. +tools/scene_builder/error_no_scene = Сначала выберите сцену мода. +tools/scene_builder/hierarchy = Иерархия +tools/scene_builder/hierarchy_hint = Золотой — корень мод-сцены, светлые — редактируемые узлы, тусклые — базовая сцена (только чтение). +tools/scene_builder/node_type_tip = Тип нового узла +tools/scene_builder/add_node_tip = Добавить узел выбранного типа в выделенный узел (или в корень сцены) +tools/scene_builder/delete_node_tip = Удалить выбранный узел мод-сцены +tools/scene_builder/delete_node_title = Удалить узел +tools/scene_builder/delete_node_confirm = Удалить выбранный узел вместе с дочерними? Файл сохранится автоматически. +tools/scene_builder/refresh_tip = Обновить дерево узлов +tools/scene_builder/tab_inspector = Инспектор +tools/scene_builder/tab_tscn = Исходник .tscn +tools/scene_builder/tscn_hint = Правки инспектора и маркеров сохраняются сами; ручные правки текста — по Ctrl+S, после чего превью пересобирается. +tools/scene_builder/inspector_cat_container = В контейнере tools/scene_builder/inspector_select = Выберите узел мод-сцены для правки свойств tools/scene_builder/inspector_readonly = Только чтение: узел принадлежит базовой сцене tools/scene_builder/inspector_missing_section = Секция узла не найдена в tscn (узлы вложенных сцен пока не редактируются) @@ -43,133 +148,103 @@ 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/readonly_zip = Файл из zip-мода доступен только для чтения -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/hint = Клик по коллайдеру — выбрать · тяните маркеры · в режиме полигона клик по фону добавляет вершину, ПКМ по вершине — удаляет · Del — удалить объект · Ctrl+S — сохранить +tools/interactive_editor/items = Объекты файла +tools/interactive_editor/add = + Новый +tools/interactive_editor/duplicate = Дублировать +tools/interactive_editor/delete = Удалить объект +tools/interactive_editor/delete_confirm = Удалить выбранный интерактив из файла? Изменение попадёт в файл при сохранении. +tools/interactive_editor/deleted = Объект «{0}» удалён. +tools/interactive_editor/background = Фон превью +tools/interactive_editor/props = Свойства +tools/interactive_editor/none_selected = Выберите объект в списке или на превью. +tools/interactive_editor/label_id = ID +tools/interactive_editor/label_texture_key = Текстура +tools/interactive_editor/texture_placeholder = без спрайта +tools/interactive_editor/texture_tip = ID текстуры спрайта объекта; пусто — невидимая кликабельная зона +tools/interactive_editor/label_layer = Слой +tools/interactive_editor/label_z = Z-порядок +tools/interactive_editor/label_cursor = Курсор +tools/interactive_editor/cursor_none = (обычный) +tools/interactive_editor/enabled = Активен +tools/interactive_editor/visible = Видим +tools/interactive_editor/collider = Коллайдер +tools/interactive_editor/label_collider_type = Форма +tools/interactive_editor/hit_rect = прямоугольник +tools/interactive_editor/hit_circle = круг +tools/interactive_editor/hit_polygon = полигон +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_cx = Центр X +tools/interactive_editor/label_cy = Центр Y +tools/interactive_editor/label_r = Радиус +tools/interactive_editor/polygon_hint = Клик по фону добавляет вершину, ПКМ по вершине удаляет её. Нужно минимум 3 вершины. +tools/interactive_editor/polygon_too_small = У полигона «{0}» меньше 3 вершин. +tools/interactive_editor/highlight = Подсветка +tools/interactive_editor/label_highlight_color = Цвет +tools/interactive_editor/label_highlight_scale = Масштаб +tools/interactive_editor/actions = Действия +tools/interactive_editor/label_onclick_scenario = Сценарий +tools/interactive_editor/label_onclick_commands = Команды по клику (по одной в строке) +tools/interactive_editor/label_hover_enter = Наведение +tools/interactive_editor/label_hover_leave = Уход +tools/interactive_editor/id_empty = У объекта пустой ID. +tools/interactive_editor/duplicate_id = ID объекта повторяется: -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/light_editor/title = Редактор освещения +tools/light_editor/hint = Двойной клик по фону — новый источник · тяните источники · Del — удалить · Ctrl+S — записать light_set в сценарий +tools/light_editor/scenario_tip = Сценарий, чья строка light_set редактируется (● — уже задаёт свет) +tools/light_editor/pick_scenario = — выберите сценарий — +tools/light_editor/save_tip = Записать строку light_set в сценарий (Ctrl+S) +tools/light_editor/copy_line = Копировать строку +tools/light_editor/copy_line_tip = Скопировать команду light_set в буфер обмена +tools/light_editor/copied = Строка light_set скопирована. +tools/light_editor/show_line = light_set +tools/light_editor/loaded = Свет сценария {0} загружен. +tools/light_editor/no_lights_yet = В {0} ещё нет light_set — добавьте источники, строка будет вставлена после заголовка. +tools/light_editor/static_lights = Источники света +tools/light_editor/add_light = + Источник +tools/light_editor/delete = Удалить +tools/light_editor/limit = Не больше {0} статичных источников. +tools/light_editor/selected_props = Выбранный источник +tools/light_editor/none_selected = Выберите источник в списке или на превью. +tools/light_editor/x = X (0–1) +tools/light_editor/y = Y (0–1) +tools/light_editor/radius = Радиус +tools/light_editor/radius_tip = Радиус в пикселях игрового экрана 1920 px +tools/light_editor/influence = Сила +tools/light_editor/influence_tip = Влияние источника (0–1) +tools/light_editor/color = Цвет +tools/light_editor/background = Фон превью -tools/interactive_editor/tab_background = Задник -tools/interactive_editor/tab_interactive = Интерактив -tools/interactive_editor/tab_scenario = Сценарии +tools/catalog_export/hint = Архив и хэши считаются при экспорте; JSON кладётся рядом с архивом мода +tools/catalog_export/mod_info = Мод +tools/catalog_export/id = ID +tools/catalog_export/name = Название +tools/catalog_export/metadata = Каталог +tools/catalog_export/locales = Локализации +tools/catalog_export/locales_hint = Без строк будет использовано английское название и описание из манифеста. +tools/catalog_export/remove_locale = Убрать язык +tools/catalog_export/preview = Итоговый JSON +tools/catalog_export/copied = JSON скопирован в буфер обмена. +tools/catalog_export/export_tip = Собрать архив, посчитать хэши и записать JSON записи каталога +tools/catalog_export/exported = Экспортировано: {0} +tools/catalog_export/export_error = Ошибка экспорта +tools/catalog_export/url_required = Укажите URL скачивания — без него запись каталога бесполезна. -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: - -tools/map_editor/title = Редактор карты -tools/map_editor/mod = Мод: -tools/map_editor/file = Файл: -tools/map_editor/load = Загрузить -tools/map_editor/save = Сохранить -tools/map_editor/save_ok = Сохранено. -tools/map_editor/reloaded = Карта перезагружена. -tools/map_editor/load_error = Ошибка загрузки -tools/map_editor/readonly = Не удалось сохранить: мод не для записи (zip) или неверный путь файла. -tools/map_editor/no_mods = Нет модов для записи -tools/map_editor/no_file = (новый файл) -tools/map_editor/reload_map = Перезагрузить карту -tools/map_editor/background = Фон: -tools/map_editor/zones = Зоны -tools/map_editor/props = Выбранная зона -tools/map_editor/add_region = + Регион -tools/map_editor/add_marker = + Метка -tools/map_editor/add_label = + Надпись -tools/map_editor/delete = Удалить -tools/map_editor/select_mode = Выбор -tools/map_editor/draw_region = Рисовать регион -tools/map_editor/id = ID: -tools/map_editor/id_empty = ID не может быть пустым. -tools/map_editor/duplicate_id = Дубликат ID зоны. -tools/map_editor/title_key = Ключ названия: -tools/map_editor/no_translation = нет перевода -tools/map_editor/scene = Сцена: -tools/map_editor/scene_none = (нет) -tools/map_editor/description = Ключ описания: -tools/map_editor/image = Иконка метки: -tools/map_editor/image_pos = Позиция метки (0-1): -tools/map_editor/label = Текстовая надпись на карте -tools/map_editor/locked = Закрыта для посещения -tools/map_editor/hidden = Скрыта с карты -tools/map_editor/vertices = Вершины полигона -tools/map_editor/add_vertex = + Вершина -tools/map_editor/x = x: -tools/map_editor/y = y: -tools/map_editor/zone_missing = Зона не выбрана. -tools/map_editor/preview = ▶ Просмотр -tools/map_editor/preview_tip = Посмотреть карту как в игре: наведение, карточка зоны, метки -tools/map_editor/preview_on = Режим просмотра: Esc или ✕ — вернуться к редактору. -tools/map_editor/preview_off = Режим редактирования. -tools/map_editor/preview_missing = Не удалось загрузить сцену карты (map_widget.tscn). -tools/map_editor/effective = В игре: -tools/map_editor/effective_open = доступна -tools/map_editor/effective_locked = закрыта (флаг «Закрыта») -tools/map_editor/effective_no_scene = закрыта (сцена не задана) -tools/map_editor/effective_unknown_scene = закрыта (сцена не найдена) -tools/map_editor/effective_hidden = скрыта с карты +tools/hub/title = Редактор новелл +tools/hub/hint = Выберите редактор — окна открываются поверх этого экрана. Сценарий можно проиграть прямо из редактора (F5). +tools/hub/editors = Редакторы +tools/hub/empty = Не установлено ни одного редактора. +tools/hub/main_menu = Главное меню diff --git a/scenes/novel_editor_hub.tscn b/scenes/novel_editor_hub.tscn new file mode 100644 index 0000000..95f1886 --- /dev/null +++ b/scenes/novel_editor_hub.tscn @@ -0,0 +1,12 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="src/Hub/NovelEditorHub.cs" id="NovelEditorHubScript"] + +[node name="NovelEditorHub" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("NovelEditorHubScript") diff --git a/scenes/scenario_file_tree_panel.tscn b/scenes/scenario_file_tree_panel.tscn index d940b45..5bf0905 100644 --- a/scenes/scenario_file_tree_panel.tscn +++ b/scenes/scenario_file_tree_panel.tscn @@ -5,6 +5,10 @@ [node name="ScenarioFileTreePanel" type="VBoxContainer" script=ExtResource("1_script")] size_flags_horizontal = 3 size_flags_vertical = 3 +theme_override_constants/separation = 4 + +[node name="Search" type="LineEdit" parent="."] +clear_button_enabled = true [node name="FileTree" type="Tree" parent="."] size_flags_horizontal = 3 diff --git a/scenes/scenario_info_panel.tscn b/scenes/scenario_info_panel.tscn index a9ba3f5..60892eb 100644 --- a/scenes/scenario_info_panel.tscn +++ b/scenes/scenario_info_panel.tscn @@ -9,10 +9,11 @@ theme_override_constants/separation = 6 [node name="Scroll" type="ScrollContainer" parent="."] size_flags_vertical = 3 -horizontal_scroll_mode = 3 +horizontal_scroll_mode = 0 [node name="VBox" type="VBoxContainer" parent="Scroll"] -theme_override_constants/separation = 8 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 [node name="HintScene" type="Label" parent="Scroll/VBox"] diff --git a/src/CatalogExport/CatalogExportWindow.cs b/src/CatalogExport/CatalogExportWindow.cs index 932f0f7..cb0b21a 100644 --- a/src/CatalogExport/CatalogExportWindow.cs +++ b/src/CatalogExport/CatalogExportWindow.cs @@ -4,11 +4,12 @@ using System.Linq; using System.Text.Json; using Cthangover.Core.Mods; using Cthangover.Core.UI.Theming; -using Cthangover.Core.UI.Tool; using Cthangover.Core.Utils; +using Cthangover.Mods.Interface.Theming; +using Cthangover.Mods.Interface.Tool; +using Cthangover.Tools.Common; using Godot; -using Cthangover.Mods.Interface.Tool; namespace Cthangover.Core.UI.Tool.CatalogExport { /// @@ -17,11 +18,11 @@ namespace Cthangover.Core.UI.Tool.CatalogExport /// mods menu so that mod-management UI can live in the mod_manager /// mod while this authoring utility stays a tool. /// - /// The window lets the author pick any discovered mod, fill in the - /// catalog-only metadata (download URL, category, tags, changelog, status, - /// minimum game version, icon URL, localized names/descriptions), preview - /// the resulting JSON, copy it to the clipboard, or export it next to the - /// mod's zip via . + /// Left: the form — read-only manifest data, catalog-only metadata + /// (download URL, categories, tags, changelog, status, minimum game + /// version, icon URL) and localized names. Right: the live JSON preview. + /// Actions: copy the JSON or export it next to the mod's zip via + /// (archive and hashes are computed there). /// public partial class CatalogExportWindow : ToolWindow { @@ -30,57 +31,59 @@ namespace Cthangover.Core.UI.Tool.CatalogExport private OptionButton _modSelect; private VBoxContainer _formHost; + private CodeEdit _previewEdit; private ModInfo _modInfo; private ModManifest _manifest; private LineEdit _urlEdit; - private ItemList _categoryList; + private readonly List _categoryChecks = new(); private LineEdit _tagsEdit; private TextEdit _changelogEdit; private OptionButton _statusOpt; private LineEdit _minGameVerEdit; private LineEdit _iconUrlEdit; private VBoxContainer _localeContainer; - private TextEdit _previewEdit; public CatalogExportWindow() : base("mods/catalog_title") { - Size = new Vector2I(620, 720); + Size = new Vector2I(1200, 820); CthangoverTheme.Apply(this); - var vbox = CreateFillContainer(); - vbox.AddThemeConstantOverride("separation", 8); - AddChild(vbox); + var outer = CreateFillContainer(); + AddChild(outer); - var selectRow = new HBoxContainer(); - selectRow.AddThemeConstantOverride("separation", 8); - var selectLabel = new Label { Text = TranslationServer.Translate("mods/catalog_select_mod") }; - selectLabel.AddThemeColorOverride("font_color", UiTokens.Muted); - selectRow.AddChild(selectLabel); - - _modSelect = new OptionButton - { - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - }; + var toolbar = CreateToolbar(); + outer.AddChild(toolbar); + _modSelect = new OptionButton { CustomMinimumSize = new Vector2(320, 0), TooltipText = ToolUi.Tr("mods/catalog_select_mod") }; _modSelect.ItemSelected += OnModSelected; - selectRow.AddChild(_modSelect); - vbox.AddChild(selectRow); + toolbar.AddChild(_modSelect); + toolbar.AddChild(UiKit.Spacer()); + toolbar.AddChild(ToolUi.Button("mods/catalog_copy", OnCopyPressed)); + toolbar.AddChild(ToolUi.Button("mods/catalog_export_file", OnExportPressed, ButtonKind.Primary, "tools/catalog_export/export_tip")); - var scroll = new ScrollContainer + var split = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - SizeFlagsVertical = Control.SizeFlags.ExpandFill, - HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, + SizeFlagsVertical = Control.SizeFlags.ExpandFill }; - vbox.AddChild(scroll); + outer.AddChild(split); - _formHost = new VBoxContainer + _formHost = new VBoxContainer(); + var form = CreateSidebar(_formHost, 520); + form.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + split.AddChild(form); + + var previewColumn = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill }; - _formHost.AddThemeConstantOverride("separation", 8); - scroll.AddChild(_formHost); + previewColumn.AddChild(ToolUi.Section("tools/catalog_export/preview")); + _previewEdit = ToolUi.Code(editable: false); + previewColumn.AddChild(_previewEdit); + split.AddChild(previewColumn); + SetHint(ToolUi.Tr("tools/catalog_export/hint")); PopulateModList(); } @@ -93,7 +96,7 @@ namespace Cthangover.Core.UI.Tool.CatalogExport if (info == null) continue; - _modSelect.AddItem(info.DisplayTitle); + _modSelect.AddItem($"{info.DisplayTitle} ({id})"); _modSelect.SetItemMetadata(_modSelect.ItemCount - 1, id); } @@ -113,175 +116,101 @@ namespace Cthangover.Core.UI.Tool.CatalogExport { foreach (var child in _formHost.GetChildren()) child.QueueFree(); + _categoryChecks.Clear(); _modInfo = ModRegistry.Instance.GetMod(modId) as ModInfo; _manifest = _modInfo?.Manifest; if (_modInfo == null || _manifest == null) return; - AddSectionLabel(_formHost, "Mod Info"); - AddReadOnlyField(_formHost, "mods/id", _modInfo.Id); - AddReadOnlyField(_formHost, "mods/name", _modInfo.DisplayTitle); - AddReadOnlyField(_formHost, "mods/version", _modInfo.Version ?? "—"); - AddReadOnlyField(_formHost, "mods/author", _modInfo.Author ?? "—"); + _formHost.AddChild(ToolUi.Section("tools/catalog_export/mod_info")); + AddReadOnly("tools/catalog_export/id", _modInfo.Id); + AddReadOnly("tools/catalog_export/name", _modInfo.DisplayTitle); + AddReadOnly("mods/version", _modInfo.Version ?? "—"); + AddReadOnly("mods/author", _modInfo.Author ?? "—"); - AddSectionLabel(_formHost, "Catalog Metadata"); - _urlEdit = AddLineEdit(_formHost, "mods/catalog_download_url"); + _formHost.AddChild(ToolUi.Section("tools/catalog_export/metadata")); + _urlEdit = ToolUi.Field(_formHost, "mods/catalog_download_url", ToolUi.Text("https://…/" + modId + ".zip", _ => UpdatePreview())); + _statusOpt = ToolUi.Field(_formHost, "mods/catalog_status", new OptionButton()); + foreach (var status in Statuses) + _statusOpt.AddItem(status); + _statusOpt.ItemSelected += _ => UpdatePreview(); + _minGameVerEdit = ToolUi.Field(_formHost, "mods/catalog_min_game_version", ToolUi.Text("1.0.0", _ => UpdatePreview())); + _iconUrlEdit = ToolUi.Field(_formHost, "mods/catalog_icon_url", ToolUi.Text("https://…/icon.png", _ => UpdatePreview())); + _tagsEdit = ToolUi.Field(_formHost, "mods/catalog_tags", ToolUi.Text("story, ru", _ => UpdatePreview())); - _categoryList = new ItemList - { - CustomMinimumSize = new Vector2(0, 130), - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - SelectMode = ItemList.SelectModeEnum.Toggle, - }; + _formHost.AddChild(ToolUi.Section("mods/catalog_category")); + var grid = new GridContainer { Columns = 3 }; + grid.AddThemeConstantOverride("h_separation", UiTokens.Space5); var preselected = _manifest.Category ?? CategoryType.Other; - for (var i = 0; i < Categories.Length; i++) + foreach (var category in Categories) { - _categoryList.AddItem(GetCategoryDisplayName(Categories[i])); - if ((preselected & Categories[i]) != 0) - _categoryList.Select(i, false); + var check = new CheckBox { Text = GetCategoryDisplayName(category), ButtonPressed = (preselected & category) != 0 }; + check.Toggled += _ => UpdatePreview(); + _categoryChecks.Add(check); + grid.AddChild(check); } - AddLabeledControl(_formHost, "mods/catalog_category", _categoryList); - - _tagsEdit = AddLineEdit(_formHost, "mods/catalog_tags"); + _formHost.AddChild(grid); + _formHost.AddChild(ToolUi.Section("mods/catalog_changelog")); _changelogEdit = new TextEdit { - CustomMinimumSize = new Vector2(0, 60), - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + CustomMinimumSize = new Vector2(0, 80), WrapMode = TextEdit.LineWrappingMode.Boundary, }; - AddLabeledControl(_formHost, "mods/catalog_changelog", _changelogEdit); - - _statusOpt = AddOptionButton(_formHost, "mods/catalog_status", Statuses); - - _minGameVerEdit = AddLineEdit(_formHost, "mods/catalog_min_game_version"); - _iconUrlEdit = AddLineEdit(_formHost, "mods/catalog_icon_url"); - - AddSectionLabel(_formHost, "JSON Preview"); - _previewEdit = new TextEdit - { - CustomMinimumSize = new Vector2(0, 160), - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - Editable = false, - WrapMode = TextEdit.LineWrappingMode.None, - }; - _previewEdit.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall); - _formHost.AddChild(_previewEdit); - - var btnRow = new HBoxContainer - { - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - }; - btnRow.AddThemeConstantOverride("separation", 8); - - var copyBtn = new Button - { - Text = TranslationServer.Translate("mods/catalog_copy"), - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - }; - copyBtn.Pressed += OnCopyPressed; - btnRow.AddChild(copyBtn); - - var exportBtn = new Button - { - Text = TranslationServer.Translate("mods/catalog_export_file"), - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - }; - exportBtn.Pressed += OnExportPressed; - btnRow.AddChild(exportBtn); - - var closeBtn = new Button - { - Text = TranslationServer.Translate("mods/catalog_close"), - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - }; - closeBtn.Pressed += () => QueueFree(); - btnRow.AddChild(closeBtn); - - _formHost.AddChild(btnRow); - - AddSectionLabel(_formHost, "Locales"); - _localeContainer = new VBoxContainer - { - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - }; - _localeContainer.AddThemeConstantOverride("separation", 4); - _formHost.AddChild(_localeContainer); - - var addLocaleBtn = new Button - { - Text = TranslationServer.Translate("mods/catalog_add_locale"), - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - }; - addLocaleBtn.Pressed += () => AddLocaleRow(string.Empty, string.Empty, string.Empty); - _formHost.AddChild(addLocaleBtn); - - _urlEdit.TextChanged += _ => UpdatePreview(); - _categoryList.ItemSelected += _ => UpdatePreview(); - _categoryList.MultiSelected += (_, _) => UpdatePreview(); - _tagsEdit.TextChanged += _ => UpdatePreview(); _changelogEdit.TextChanged += UpdatePreview; - _statusOpt.ItemSelected += _ => UpdatePreview(); - _minGameVerEdit.TextChanged += _ => UpdatePreview(); - _iconUrlEdit.TextChanged += _ => UpdatePreview(); + _formHost.AddChild(_changelogEdit); + + _formHost.AddChild(ToolUi.Section("tools/catalog_export/locales")); + _formHost.AddChild(ToolUi.Hint(ToolUi.Tr("tools/catalog_export/locales_hint"))); + _localeContainer = new VBoxContainer(); + _localeContainer.AddThemeConstantOverride("separation", UiTokens.Space1); + _formHost.AddChild(_localeContainer); + _formHost.AddChild(ToolUi.Button("mods/catalog_add_locale", () => AddLocaleRow(string.Empty, string.Empty, string.Empty))); UpdatePreview(); + SetStatus(string.Empty); + } + + private void AddReadOnly(string labelKey, string value) + { + var label = UiKit.Label(value, UiTokens.FontBody, UiTokens.Body); + label.AddThemeFontOverride("font", ToolUi.Mono()); + ToolUi.Field(_formHost, labelKey, label); } private static string GetCategoryDisplayName(CategoryType cat) => cat switch { - CategoryType.Story => TranslationServer.Translate("mods/category_story"), - CategoryType.Characters => TranslationServer.Translate("mods/category_characters"), - CategoryType.Gameplay => TranslationServer.Translate("mods/category_gameplay"), - CategoryType.Visual => TranslationServer.Translate("mods/category_visual"), - CategoryType.Audio => TranslationServer.Translate("mods/category_audio"), - CategoryType.UI => TranslationServer.Translate("mods/category_ui"), - CategoryType.Locale => TranslationServer.Translate("mods/category_locale"), - CategoryType.Library => TranslationServer.Translate("mods/category_library"), - CategoryType.Engine => TranslationServer.Translate("mods/category_engine"), - CategoryType.Overhaul => TranslationServer.Translate("mods/category_overhaul"), - CategoryType.Tools => TranslationServer.Translate("mods/category_tools"), - CategoryType.Editor => TranslationServer.Translate("mods/category_editor"), - CategoryType.Debug => TranslationServer.Translate("mods/category_debug"), - CategoryType.Other => TranslationServer.Translate("mods/category_other"), + CategoryType.Story => ToolUi.Tr("mods/category_story"), + CategoryType.Characters => ToolUi.Tr("mods/category_characters"), + CategoryType.Gameplay => ToolUi.Tr("mods/category_gameplay"), + CategoryType.Visual => ToolUi.Tr("mods/category_visual"), + CategoryType.Audio => ToolUi.Tr("mods/category_audio"), + CategoryType.UI => ToolUi.Tr("mods/category_ui"), + CategoryType.Locale => ToolUi.Tr("mods/category_locale"), + CategoryType.Library => ToolUi.Tr("mods/category_library"), + CategoryType.Engine => ToolUi.Tr("mods/category_engine"), + CategoryType.Overhaul => ToolUi.Tr("mods/category_overhaul"), + CategoryType.Tools => ToolUi.Tr("mods/category_tools"), + CategoryType.Editor => ToolUi.Tr("mods/category_editor"), + CategoryType.Debug => ToolUi.Tr("mods/category_debug"), + CategoryType.Other => ToolUi.Tr("mods/category_other"), _ => cat.ToString(), }; private void AddLocaleRow(string lang, string name, string description) { - var row = new HBoxContainer - { - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - }; - row.AddThemeConstantOverride("separation", 4); + var row = new HBoxContainer(); + row.AddThemeConstantOverride("separation", UiTokens.Space1); - var langEdit = new LineEdit - { - CustomMinimumSize = new Vector2(44, 0), - PlaceholderText = "en", - MaxLength = 5, - Text = lang, - }; + var langEdit = new LineEdit { CustomMinimumSize = new Vector2(52, 0), PlaceholderText = "en", MaxLength = 5, Text = lang }; row.AddChild(langEdit); - - var nameEdit = new LineEdit - { - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - PlaceholderText = TranslationServer.Translate("mods/catalog_locale_name"), - Text = name, - }; + var nameEdit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, PlaceholderText = ToolUi.Tr("mods/catalog_locale_name"), Text = name }; row.AddChild(nameEdit); - - var descEdit = new LineEdit - { - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - PlaceholderText = TranslationServer.Translate("mods/catalog_locale_desc"), - Text = description, - }; + var descEdit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, PlaceholderText = ToolUi.Tr("mods/catalog_locale_desc"), Text = description }; row.AddChild(descEdit); - var removeBtn = new Button { Text = "×", CustomMinimumSize = new Vector2(28, 0) }; + var removeBtn = UiKit.IconButton("✕", ToolUi.Tr("tools/catalog_export/remove_locale")); removeBtn.Pressed += () => RemoveLocaleRow(row); row.AddChild(removeBtn); @@ -290,6 +219,7 @@ namespace Cthangover.Core.UI.Tool.CatalogExport descEdit.TextChanged += _ => UpdatePreview(); _localeContainer.AddChild(row); + UpdatePreview(); } private void RemoveLocaleRow(HBoxContainer row) @@ -301,28 +231,13 @@ namespace Cthangover.Core.UI.Tool.CatalogExport private void UpdatePreview() { - if (_previewEdit == null || _modInfo == null || _manifest == null) + if (_previewEdit == null || _modInfo == null || _manifest == null || _changelogEdit == null) return; - var entry = BuildEntryFromManifest(_manifest); - entry.Downloads = new List - { - new() - { - Version = entry.Version, - Urls = new List { _urlEdit.Text.Trim() }, - SizeBytes = 0, - ArchiveSha256 = "—", - InstallSha256 = "—", - }, - }; - entry.Category = CollectSelectedCategories(_categoryList, Categories); - entry.Tags = ParseTags(_tagsEdit.Text); - entry.Downloads[0].Changelog = NullIfEmpty(_changelogEdit.Text); - entry.Status = Statuses[_statusOpt.Selected]; - entry.MinGameVersion = NullIfEmpty(_minGameVerEdit.Text); - entry.IconUrl = NullIfEmpty(_iconUrlEdit.Text); - entry.Locale = CollectLocales(); + var entry = BuildExportEntry(); + // Hashes are computed by the exporter from the real archive. + entry.Downloads[0].ArchiveSha256 = "computed-on-export"; + entry.Downloads[0].InstallSha256 = "computed-on-export"; _previewEdit.Text = JsonSerializer.Serialize(entry, JsonOptionsProvider.WriteIndentedIgnoreNullOptions); } @@ -331,32 +246,23 @@ namespace Cthangover.Core.UI.Tool.CatalogExport var list = new List(); foreach (var child in _localeContainer.GetChildren()) { - if (child is not HBoxContainer row || row.GetChildCount() < 3) + if (child is not HBoxContainer row || row.IsQueuedForDeletion() || row.GetChildCount() < 3) continue; var lang = ((LineEdit)row.GetChild(0)).Text.Trim(); - var name = ((LineEdit)row.GetChild(1)).Text.Trim(); - var description = ((LineEdit)row.GetChild(2)).Text.Trim(); if (lang.Length == 0) continue; list.Add(new CatalogLocaleEntry { Lang = lang, - Name = NullIfEmpty(name), - Description = NullIfEmpty(description), + Name = NullIfEmpty(((LineEdit)row.GetChild(1)).Text), + Description = NullIfEmpty(((LineEdit)row.GetChild(2)).Text), }); } - if (list.Count == 0 && _manifest != null) - { - list.Add(new CatalogLocaleEntry - { - Lang = "en", - Name = _manifest.Name, - Description = _manifest.Description, - }); - } + if (list.Count == 0) + list.Add(new CatalogLocaleEntry { Lang = "en", Name = _manifest.Name, Description = _manifest.Description }); return list; } @@ -371,12 +277,12 @@ namespace Cthangover.Core.UI.Tool.CatalogExport Version = entry.Version, Urls = new List { _urlEdit.Text.Trim() }, SizeBytes = 0, + Changelog = NullIfEmpty(_changelogEdit.Text), }, }; - entry.Category = CollectSelectedCategories(_categoryList, Categories); + entry.Category = CollectSelectedCategories(); entry.Tags = ParseTags(_tagsEdit.Text); - entry.Downloads[0].Changelog = NullIfEmpty(_changelogEdit.Text); - entry.Status = Statuses[_statusOpt.Selected]; + entry.Status = Statuses[Math.Max(0, _statusOpt.Selected)]; entry.MinGameVersion = NullIfEmpty(_minGameVerEdit.Text); entry.IconUrl = NullIfEmpty(_iconUrlEdit.Text); entry.Locale = CollectLocales(); @@ -386,7 +292,7 @@ namespace Cthangover.Core.UI.Tool.CatalogExport private void OnCopyPressed() { DisplayServer.ClipboardSet(_previewEdit.Text); - GameLogger.Log("CATALOG_EXPORT", $"Copied catalog JSON for '{_modInfo?.Id}' to clipboard"); + SetStatus(ToolUi.Tr("tools/catalog_export/copied"), ToolStatusKind.Ok); } private void OnExportPressed() @@ -394,15 +300,27 @@ namespace Cthangover.Core.UI.Tool.CatalogExport if (_modInfo == null) return; - var entry = BuildExportEntry(); - var modsRoot = ModRegistry.ModsPath; - var isZip = _modInfo.FileProvider.RootPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); - if (isZip) - ModCatalogExporter.ExportFromZip(_modInfo, entry, modsRoot); - else - ModCatalogExporter.ExportFromFolder(_modInfo, entry, modsRoot); + if (string.IsNullOrWhiteSpace(_urlEdit.Text)) + { + SetStatus(ToolUi.Tr("tools/catalog_export/url_required"), ToolStatusKind.Error); + return; + } - QueueFree(); + try + { + var entry = BuildExportEntry(); + var modsRoot = ModRegistry.ModsPath; + var isZip = _modInfo.FileProvider.RootPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + var path = isZip + ? ModCatalogExporter.ExportFromZip(_modInfo, entry, modsRoot) + : ModCatalogExporter.ExportFromFolder(_modInfo, entry, modsRoot); + SetStatus(string.Format(ToolUi.Tr("tools/catalog_export/exported"), path), ToolStatusKind.Ok); + } + catch (Exception ex) + { + GameLogger.Log("CATALOG_EXPORT", $"export of '{_modInfo.Id}' failed: {ex.Message}", LogLevel.Error); + SetStatus($"{ToolUi.Tr("tools/catalog_export/export_error")}: {ex.Message}", ToolStatusKind.Error); + } } private static List ParseTags(string text) @@ -435,66 +353,15 @@ namespace Cthangover.Core.UI.Tool.CatalogExport }; } - private static CategoryType CollectSelectedCategories(ItemList list, CategoryType[] categories) + private CategoryType CollectSelectedCategories() { var selected = (CategoryType)0; - for (var i = 0; i < categories.Length && i < list.ItemCount; i++) + for (var i = 0; i < Categories.Length && i < _categoryChecks.Count; i++) { - if (list.IsSelected(i)) - selected |= categories[i]; + if (_categoryChecks[i].ButtonPressed) + selected |= Categories[i]; } return selected != 0 ? selected : CategoryType.Other; } - - private static void AddLabeledControl(Container parent, string labelKey, Control control) - { - var box = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; - var label = new Label - { - Text = TranslationServer.Translate(labelKey), - }; - label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall); - label.AddThemeColorOverride("font_color", UiTokens.Muted); - box.AddChild(label); - box.AddChild(control); - parent.AddChild(box); - } - - private static void AddReadOnlyField(Container parent, string labelKey, string value) - { - var label = new Label - { - Text = $"{TranslationServer.Translate(labelKey)}: {value}", - }; - label.AddThemeFontSizeOverride("font_size", UiTokens.FontBody); - label.AddThemeColorOverride("font_color", UiTokens.Body); - parent.AddChild(label); - } - - private static LineEdit AddLineEdit(Container parent, string labelKey) - { - var edit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; - AddLabeledControl(parent, labelKey, edit); - return edit; - } - - private static OptionButton AddOptionButton(Container parent, string labelKey, string[] items) - { - var opt = new OptionButton { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; - foreach (var item in items) - opt.AddItem(item); - AddLabeledControl(parent, labelKey, opt); - return opt; - } - - private static void AddSectionLabel(Container parent, string text) - { - var sep = new HSeparator(); - parent.AddChild(sep); - var label = new Label { Text = text }; - label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodyLarge); - label.AddThemeColorOverride("font_color", UiTokens.Ink); - parent.AddChild(label); - } } } diff --git a/src/Common/BackgroundCanvas.cs b/src/Common/BackgroundCanvas.cs new file mode 100644 index 0000000..c4a1650 --- /dev/null +++ b/src/Common/BackgroundCanvas.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.UI.Theming; +using Godot; + +namespace Cthangover.Tools.Common +{ + /// + /// Base of the editors that place things over a scene background (map + /// zones, interactive colliders, lights). Draws the background with the + /// same aspect-preserving "fit" layout the game uses and converts between + /// canvas pixels and the normalised [0..1] coordinates stored in the data. + /// Getting this rectangle wrong is what made saved positions drift: the + /// data means "fraction of the background", not "fraction of the panel". + /// + /// Subclasses paint their overlay in and rebuild + /// their s in , which is + /// also called whenever the canvas is resized or the background changes. + /// + public abstract partial class BackgroundCanvas : Control + { + private static readonly Vector2 FallbackSize = new(1920, 1024); + + /// Live handles of the current selection. + protected readonly List _handles = new(); + + /// Reference background; null draws a flat placeholder. + public Texture2D Background { get; private set; } + + protected BackgroundCanvas() + { + MouseFilter = MouseFilterEnum.Stop; + ClipContents = true; + Resized += OnResized; + Connect("draw", Callable.From((Action)DrawAll)); + } + + /// Assigns the background texture and relayouts handles. + public void SetBackground(Texture2D texture) + { + Background = texture; + RebuildHandles(); + QueueRedraw(); + } + + /// Recreates the handles from the current selection. + public abstract void RebuildHandles(); + + /// Paints the overlay above the background inside . + protected abstract void DrawContent(Rect2 rect); + + /// + /// Aspect-preserving rectangle the background occupies inside the + /// canvas — identical to the runtime "fit" layout. + /// + public Rect2 GetBackgroundRect() + { + var controlSize = Size; + if (controlSize.X <= 0f || controlSize.Y <= 0f) + return new Rect2(); + + var textureSize = Background?.GetSize() ?? FallbackSize; + if (textureSize.X <= 0f || textureSize.Y <= 0f) + textureSize = FallbackSize; + + var scale = Mathf.Min(controlSize.X / textureSize.X, controlSize.Y / textureSize.Y); + var size = textureSize * scale; + return new Rect2((controlSize - size) / 2f, size); + } + + /// Canvas-local pixel of a normalised point. + public Vector2 NormToLocal(Vector2 normalized) + { + var rect = GetBackgroundRect(); + return rect.Position + normalized * rect.Size; + } + + /// Normalised point of a canvas-local pixel (not clamped). + public Vector2 LocalToNorm(Vector2 local) + { + var rect = GetBackgroundRect(); + if (rect.Size.X <= 0f || rect.Size.Y <= 0f) + return Vector2.Zero; + return (local - rect.Position) / rect.Size; + } + + /// Clamps both axes into [0..1]. + public static Vector2 Clamp01(Vector2 value) + { + return new Vector2(Mathf.Clamp(value.X, 0f, 1f), Mathf.Clamp(value.Y, 0f, 1f)); + } + + /// Frees every handle. + protected void ClearHandles() + { + foreach (var handle in _handles) + handle.QueueFree(); + _handles.Clear(); + } + + /// + /// Adds a handle centred on a normalised point. The handle enters the + /// tree before it is positioned (setting a global position on a + /// detached Control lands it in the wrong place). + /// receives the clamped normalised point while dragging. + /// + protected DragHandle AddHandle(Vector2 normalized, Color color, int index, Action drag, float diameter = 14f) + { + var handle = new DragHandle(color, index, diameter); + handle.DragUpdate = globalCenter => drag(Clamp01(LocalToNorm(globalCenter - GlobalPosition))); + AddChild(handle); + handle.CenterAt(GlobalPosition + NormToLocal(Clamp01(normalized))); + _handles.Add(handle); + return handle; + } + + private void OnResized() + { + RebuildHandles(); + QueueRedraw(); + } + + private void DrawAll() + { + var rect = GetBackgroundRect(); + if (rect.Size.X <= 0f || rect.Size.Y <= 0f) + return; + + if (Background != null) + DrawTextureRect(Background, rect, false); + else + DrawRect(rect, UiTokens.Surface2); + + DrawContent(rect); + } + } +} diff --git a/src/Common/CollapsibleSection.cs b/src/Common/CollapsibleSection.cs new file mode 100644 index 0000000..0d73174 --- /dev/null +++ b/src/Common/CollapsibleSection.cs @@ -0,0 +1,62 @@ +using Cthangover.Core.UI.Theming; +using Godot; + +namespace Cthangover.Tools.Common +{ + /// + /// Sidebar section with a clickable uppercase caption (style guide + /// .ct-section-title plus a ▸/▾ marker) that shows or hides its + /// . Editors expand the sections relevant to the selected + /// object and collapse the rest, so a long property panel stays readable + /// without losing access to rarely used fields. + /// + public partial class CollapsibleSection : VBoxContainer + { + private readonly Button _header; + private readonly string _title; + + /// Container for the section's controls. + public VBoxContainer Body { get; } + + /// Whether the body is shown. + public bool Expanded + { + get => Body.Visible; + set + { + Body.Visible = value; + _header.Text = (value ? "▾ " : "▸ ") + _title; + } + } + + /// Creates a section titled by . + public CollapsibleSection(string titleKey, bool expanded = true) + { + _title = ToolUi.Tr(titleKey).ToUpperInvariant(); + AddThemeConstantOverride("separation", UiTokens.Space2); + + _header = new Button + { + Flat = true, + Alignment = HorizontalAlignment.Left, + FocusMode = FocusModeEnum.None, + MouseDefaultCursorShape = CursorShape.PointingHand + }; + _header.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall); + _header.AddThemeColorOverride("font_color", UiTokens.Muted); + _header.AddThemeColorOverride("font_hover_color", UiTokens.AccentBright); + _header.AddThemeColorOverride("font_pressed_color", UiTokens.Muted); + _header.AddThemeStyleboxOverride("normal", new StyleBoxEmpty()); + _header.AddThemeStyleboxOverride("hover", new StyleBoxEmpty()); + _header.AddThemeStyleboxOverride("pressed", new StyleBoxEmpty()); + _header.Pressed += () => Expanded = !Expanded; + AddChild(_header); + + Body = new VBoxContainer(); + Body.AddThemeConstantOverride("separation", UiTokens.Space2); + AddChild(Body); + + Expanded = expanded; + } + } +} diff --git a/src/Common/DragHandle.cs b/src/Common/DragHandle.cs new file mode 100644 index 0000000..d9372bb --- /dev/null +++ b/src/Common/DragHandle.cs @@ -0,0 +1,102 @@ +using System; +using Cthangover.Core.UI.Theming; +using Godot; + +namespace Cthangover.Tools.Common +{ + /// + /// Round draggable point used by every canvas editor (polygon vertices, + /// rect corners, markers, lights). It is a small Control placed over its + /// canvas: the left button drags it and reports the new centre in global + /// coordinates, the right button reports a delete request. Input arrives + /// through the GuiInput event and drawing through the draw + /// signal — the forms that dispatch reliably for mod assemblies. + /// + /// is free for the owner: a vertex index or a negative + /// role constant (marker, label, rect corner...). + /// + public partial class DragHandle : Control + { + /// Owner-defined identity of the handle. + public int Index; + + /// Called while dragging with the handle centre in global coordinates. + public Action DragUpdate; + + /// Called on left press. + public Action Clicked; + + /// Called on left release after a press. + public Action DragEnd; + + /// Called on right press. + public Action RightClicked; + + private readonly Color _fill; + private bool _dragging; + private bool _hovered; + private Vector2 _dragOffset; + + /// Creates a handle of the given fill colour, role index and diameter. + public DragHandle(Color fill, int index, float diameter = 14f) + { + _fill = fill; + Index = index; + Size = new Vector2(diameter, diameter); + MouseFilter = MouseFilterEnum.Stop; + MouseDefaultCursorShape = CursorShape.Drag; + GuiInput += OnGuiInput; + MouseEntered += () => { _hovered = true; QueueRedraw(); }; + MouseExited += () => { _hovered = false; QueueRedraw(); }; + Connect("draw", Callable.From((Action)Paint)); + } + + /// Places the handle so that its centre sits at ; call after it is in the tree. + public void CenterAt(Vector2 globalCenter) + { + GlobalPosition = globalCenter - Size / 2f; + } + + private void Paint() + { + var center = Size / 2f; + var radius = Size.X / 2f - 1f; + DrawCircle(center, radius, UiTokens.Surface0); + DrawCircle(center, radius - 2f, _fill); + DrawArc(center, radius, 0f, Mathf.Tau, 24, _hovered || _dragging ? UiTokens.AccentHover : UiTokens.Ink, 1.5f, true); + } + + private void OnGuiInput(InputEvent @event) + { + if (@event is InputEventMouseButton mb) + { + if (mb.ButtonIndex == MouseButton.Left) + { + if (mb.Pressed) + { + _dragging = true; + _dragOffset = GetGlobalMousePosition() - GlobalPosition; + Clicked?.Invoke(); + } + else if (_dragging) + { + _dragging = false; + DragEnd?.Invoke(); + } + AcceptEvent(); + } + else if (mb.ButtonIndex == MouseButton.Right && mb.Pressed) + { + RightClicked?.Invoke(); + AcceptEvent(); + } + } + else if (@event is InputEventMouseMotion && _dragging) + { + GlobalPosition = GetGlobalMousePosition() - _dragOffset; + DragUpdate?.Invoke(GlobalPosition + Size / 2f); + AcceptEvent(); + } + } + } +} diff --git a/src/Common/FilteredList.cs b/src/Common/FilteredList.cs new file mode 100644 index 0000000..9bbdace --- /dev/null +++ b/src/Common/FilteredList.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.UI.Theming; +using Godot; + +namespace Cthangover.Tools.Common +{ + /// + /// Search field + list used wherever an editor picks from a long catalogue + /// (backgrounds, icons, files, definitions). Items are identified by a + /// stable id, not by row index: filtering reorders and hides rows, so the + /// list keeps the id↔row mapping itself and raises / + /// with the id. is silent so + /// windows can mirror their model into the list without feedback loops. + /// + public partial class FilteredList : VBoxContainer + { + private readonly LineEdit _search; + private readonly ItemList _list; + private readonly List<(string Id, string Text, Texture2D Icon)> _items = new(); + private readonly List _visibleIds = new(); + private string _selectedId; + private bool _silent; + + /// Raised when the user selects a row (click or keyboard). + public event Action Chosen; + + /// Raised on double-click / Enter. + public event Action Activated; + + /// Currently selected id, or null. + public string SelectedId => _selectedId; + + /// Creates the list; is the list part's minimum height. + public FilteredList(float minHeight = 140f, string placeholderKey = "tools/common/search") + { + AddThemeConstantOverride("separation", UiTokens.Space1); + + _search = new LineEdit + { + PlaceholderText = TranslationServer.Translate(placeholderKey), + ClearButtonEnabled = true + }; + _search.TextChanged += _ => Rebuild(); + AddChild(_search); + + _list = new ItemList + { + CustomMinimumSize = new Vector2(0, minHeight), + SizeFlagsVertical = SizeFlags.ExpandFill, + AllowReselect = true + }; + _list.ItemSelected += OnItemSelected; + _list.ItemActivated += index => Activated?.Invoke(IdAt((int)index)); + AddChild(_list); + } + + /// Turns rows into an icon grid of px thumbnails. + public void UseIconGrid(int iconSize) + { + _list.FixedIconSize = new Vector2I(iconSize, iconSize); + _list.IconMode = ItemList.IconModeEnum.Top; + _list.MaxColumns = 0; + _list.SameColumnWidth = true; + _list.FixedColumnWidth = iconSize + 24; + } + + /// Replaces all items; keeps the current selection when its id is still present. + public void SetItems(IEnumerable<(string Id, string Text, Texture2D Icon)> items) + { + _items.Clear(); + _items.AddRange(items); + Rebuild(); + } + + /// Replaces all items with text-only rows where text equals id. + public void SetItems(IEnumerable ids) + { + _items.Clear(); + foreach (var id in ids) + _items.Add((id, id, null)); + Rebuild(); + } + + /// Selects the row with (null clears) without raising events. + public void Select(string id) + { + _selectedId = id; + _silent = true; + _list.DeselectAll(); + var index = _visibleIds.IndexOf(id); + if (index >= 0) + { + _list.Select(index); + _list.EnsureCurrentIsVisible(); + } + _silent = false; + } + + /// Number of items before filtering. + public int Count => _items.Count; + + private void Rebuild() + { + var filter = _search.Text.Trim(); + _silent = true; + _list.Clear(); + _visibleIds.Clear(); + + foreach (var item in _items) + { + if (filter.Length > 0 && item.Text.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0 + && item.Id.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0) + continue; + + var index = item.Icon != null ? _list.AddItem(item.Text, item.Icon) : _list.AddItem(item.Text); + _list.SetItemTooltip(index, item.Id); + _visibleIds.Add(item.Id); + } + + _silent = false; + Select(_selectedId); + } + + private void OnItemSelected(long index) + { + if (_silent) + return; + + _selectedId = IdAt((int)index); + Chosen?.Invoke(_selectedId); + } + + private string IdAt(int index) => index >= 0 && index < _visibleIds.Count ? _visibleIds[index] : null; + } +} diff --git a/src/Common/ModFileSelector.cs b/src/Common/ModFileSelector.cs new file mode 100644 index 0000000..1dd1c81 --- /dev/null +++ b/src/Common/ModFileSelector.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Cthangover.Core.UI.Theming; +using Cthangover.Mods.Interface.Theming; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Tools.Common +{ + /// + /// Toolbar group "mod ▾ file ▾ +" for editors that own one kind of file + /// inside a mod folder (map/*.json, interactives/*.json). + /// Only writable (folder-backed) mods are offered, and mods that already + /// contain such files come first, so the author lands on real data instead + /// of an arbitrary engine mod with nothing to edit. + /// + /// The selector never loads anything itself: it raises + /// and the window decides (usually after an + /// "unsaved changes" confirmation). When the window declines, it calls + /// with the file still open to roll the dropdowns back. + /// A null path means "this mod has no such file yet". + /// + public partial class ModFileSelector : HBoxContainer + { + private readonly string _directory; + private readonly string _extension; + private readonly OptionButton _modSelect; + private readonly OptionButton _fileSelect; + private readonly List _mods = new(); + private readonly List _files = new(); + private bool _silent; + + /// Raised with (modId, mod-relative path or null) when the author picks or creates a file. + public event Action FileRequested; + + /// Mod shown in the selector. + public string CurrentMod => _modSelect.Selected >= 0 && _modSelect.Selected < _mods.Count ? _mods[_modSelect.Selected] : null; + + /// File shown in the selector, or null. + public string CurrentPath => _fileSelect.Selected >= 0 && _fileSelect.Selected < _files.Count ? _files[_fileSelect.Selected] : null; + + /// Creates the selector for files in with . + public ModFileSelector(string directory, string extension) + { + _directory = directory; + _extension = extension; + AddThemeConstantOverride("separation", UiTokens.Space2); + + _modSelect = new OptionButton { CustomMinimumSize = new Vector2(170, 0), TooltipText = ToolUi.Tr("tools/common/mod_tip") }; + _modSelect.ItemSelected += _ => OnModPicked(); + AddChild(_modSelect); + + _fileSelect = new OptionButton { CustomMinimumSize = new Vector2(220, 0), TooltipText = ToolUi.Tr("tools/common/file_tip") }; + _fileSelect.ItemSelected += _ => OnFilePicked(); + AddChild(_fileSelect); + + var newBtn = UiKit.IconButton("+", ToolUi.Tr("tools/common/new_file")); + newBtn.Pressed += OnNewPressed; + AddChild(newBtn); + } + + /// Re-reads mods and files and requests the first file of the first mod that has any. + public void Refresh() + { + var writable = ModFileService.ListWritableMods(); + _mods.Clear(); + _mods.AddRange(writable.Where(HasFiles)); + _mods.AddRange(writable.Where(m => !HasFiles(m))); + + _silent = true; + _modSelect.Clear(); + foreach (var mod in _mods) + _modSelect.AddItem(HasFiles(mod) ? mod : mod + " ·"); + _silent = false; + + if (_mods.Count == 0) + { + FileRequested?.Invoke(null, null); + return; + } + + _modSelect.Select(0); + FillFiles(_mods[0]); + FileRequested?.Invoke(_mods[0], CurrentPath); + } + + /// Silently shows / (a not-yet-saved path is added). + public void Show(string modId, string path) + { + _silent = true; + var modIndex = _mods.IndexOf(modId); + if (modIndex >= 0) + { + _modSelect.Select(modIndex); + FillFiles(modId); + if (path != null && !_files.Contains(path)) + { + _files.Add(path); + _fileSelect.AddItem(ShortName(path)); + } + _fileSelect.Select(path != null ? _files.IndexOf(path) : -1); + } + _silent = false; + } + + private bool HasFiles(string modId) => ModFileService.ListFiles(modId, _directory, _extension).Count > 0; + + private void FillFiles(string modId) + { + _files.Clear(); + _files.AddRange(ModFileService.ListFiles(modId, _directory, _extension)); + var silent = _silent; + _silent = true; + _fileSelect.Clear(); + foreach (var file in _files) + _fileSelect.AddItem(ShortName(file)); + _fileSelect.Disabled = _files.Count == 0; + if (_files.Count > 0) + _fileSelect.Select(0); + _silent = silent; + } + + private string ShortName(string path) + { + var prefix = _directory + "/"; + return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? path.Substring(prefix.Length) : path; + } + + private void OnModPicked() + { + if (_silent) + return; + var mod = CurrentMod; + FillFiles(mod); + FileRequested?.Invoke(mod, CurrentPath); + } + + private void OnFilePicked() + { + if (!_silent) + FileRequested?.Invoke(CurrentMod, CurrentPath); + } + + private void OnNewPressed() + { + if (CurrentMod == null) + return; + + var dialog = new ConfirmationDialog { Title = ToolUi.Tr("tools/common/new_file") }; + var column = new VBoxContainer(); + column.AddChild(UiKit.Hint(string.Format(ToolUi.Tr("tools/common/new_file_hint"), CurrentMod, _directory, _extension))); + var nameEdit = new LineEdit { PlaceholderText = "new_file", CustomMinimumSize = new Vector2(320, 0) }; + column.AddChild(nameEdit); + dialog.AddChild(column); + dialog.RegisterTextEnter(nameEdit); + dialog.Confirmed += () => + { + var name = SanitizeName(nameEdit.Text); + dialog.QueueFree(); + if (name.Length == 0) + return; + FileRequested?.Invoke(CurrentMod, _directory + "/" + name + _extension); + }; + dialog.Canceled += () => dialog.QueueFree(); + AddChild(dialog); + dialog.PopupCentered(); + nameEdit.GrabFocus(); + } + + private string SanitizeName(string raw) + { + var name = (raw ?? string.Empty).Trim().Replace('\\', '/').Trim('/'); + if (name.EndsWith(_extension, StringComparison.OrdinalIgnoreCase)) + name = name.Substring(0, name.Length - _extension.Length); + var chars = name.Where(c => char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '/').ToArray(); + return new string(chars); + } + } +} diff --git a/src/Common/ToolUi.cs b/src/Common/ToolUi.cs new file mode 100644 index 0000000..e58e568 --- /dev/null +++ b/src/Common/ToolUi.cs @@ -0,0 +1,145 @@ +using System; +using Cthangover.Core.UI.Theming; +using Cthangover.Mods.Interface.Theming; +using Cthangover.Mods.Interface.Tool; +using Godot; + +namespace Cthangover.Tools.Common +{ + /// + /// Building blocks shared by every editor window of the tools mod, so all + /// of them read the same way: uppercase muted section captions, "label + + /// control" rows with a fixed label column, numeric spin boxes and token + /// buttons from . Editors are built in code (the + /// skill-code exception for utility tools), so this is the place where + /// their look is kept consistent with the style guide instead of each + /// window inventing its own spacing and label styles. + /// + public static class ToolUi + { + /// Width of the label column in rows. + public const int LabelWidth = 110; + + /// Translates a locale key. + public static string Tr(string key) => TranslationServer.Translate(key); + + /// Uppercase muted section caption (style guide .ct-section-title) with a little air above. + public static Control Section(string titleKey) + { + var box = new MarginContainer { MouseFilter = Control.MouseFilterEnum.Ignore }; + box.AddThemeConstantOverride("margin_top", UiTokens.Space2); + box.AddChild(UiKit.SectionTitle(Tr(titleKey), UiTokens.FontSmall)); + return box; + } + + /// + /// Adds a "label | control" row to : the label is + /// muted, fixed width and carries the optional tooltip; the control fills + /// the rest. Returns the control for chaining. + /// + public static T Field(Container parent, string labelKey, T control, string tooltipKey = null) where T : Control + { + var row = new HBoxContainer(); + row.AddThemeConstantOverride("separation", UiTokens.Space3); + + var label = new Label + { + Text = Tr(labelKey), + CustomMinimumSize = new Vector2(LabelWidth, 0), + VerticalAlignment = VerticalAlignment.Center, + ClipText = true, + MouseFilter = Control.MouseFilterEnum.Pass, + TooltipText = tooltipKey != null ? Tr(tooltipKey) : string.Empty + }; + label.AddThemeColorOverride("font_color", UiTokens.Muted); + label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall); + row.AddChild(label); + + control.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + row.AddChild(control); + parent.AddChild(row); + return control; + } + + /// + /// Numeric field. Values are typed and shown culture-independently by + /// , which also clamps and steps them, so the editors + /// no longer parse free text ("0,5" vs "0.5"). + /// + public static SpinBox Number(double min, double max, double step, Action changed) + { + var spin = new SpinBox + { + MinValue = min, + MaxValue = max, + Step = step, + SelectAllOnFocus = true, + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + CustomArrowStep = step + }; + spin.GetLineEdit().AddThemeFontOverride("font", Mono()); + if (changed != null) + spin.ValueChanged += value => changed(value); + return spin; + } + + /// Single-line text field with a placeholder and an optional change handler. + public static LineEdit Text(string placeholder = "", Action changed = null) + { + var edit = new LineEdit { PlaceholderText = placeholder ?? string.Empty }; + if (changed != null) + edit.TextChanged += text => changed(text); + return edit; + } + + /// Token button with a localized caption, optional tooltip and kind. + public static Button Button(string textKey, Action pressed, ButtonKind kind = ButtonKind.Secondary, string tooltipKey = null) + { + var button = UiKit.Button(Tr(textKey), kind); + if (tooltipKey != null) + button.TooltipText = Tr(tooltipKey); + if (pressed != null) + button.Pressed += pressed; + return button; + } + + /// Thin vertical divider between toolbar groups. + public static VSeparator Divider() => new() { CustomMinimumSize = new Vector2(UiTokens.Space3, 0) }; + + /// Faint helper text that wraps inside narrow sidebars. + public static Label Hint(string text) + { + var label = UiKit.Hint(text); + label.AutowrapMode = TextServer.AutowrapMode.WordSmart; + label.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + label.CustomMinimumSize = new Vector2(80, 0); + return label; + } + + /// Monospace font for IDs, numbers and code (style guide --ct-mono). + public static Font Mono() => ToolWindow.GetMonospaceFont(); + + /// Code editor preset: monospace, line numbers, current-line highlight, no wrap. + public static CodeEdit Code(bool editable = true) + { + var edit = new CodeEdit + { + Editable = editable, + GuttersDrawLineNumbers = true, + HighlightCurrentLine = true, + WrapMode = TextEdit.LineWrappingMode.None, + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill, + IndentUseSpaces = true, + IndentSize = 2, + AutoBraceCompletionEnabled = false + }; + edit.AddThemeFontOverride("font", Mono()); + edit.AddThemeFontSizeOverride("font_size", UiTokens.FontBody); + edit.AddThemeColorOverride("current_line_color", UiTokens.WhiteTrace); + edit.AddThemeColorOverride("line_number_color", UiTokens.Faint); + edit.AddThemeColorOverride("font_readonly_color", UiTokens.Body); + return edit; + } + } +} diff --git a/src/Hub/NovelEditorHub.cs b/src/Hub/NovelEditorHub.cs new file mode 100644 index 0000000..a2dc6f4 --- /dev/null +++ b/src/Hub/NovelEditorHub.cs @@ -0,0 +1,79 @@ +namespace Cthangover.Tools.Hub +{ + using Cthangover.Core.Scenes; + using Cthangover.Core.UI.Theming; + using Cthangover.Core.UI.Tool; + using Cthangover.Core.Utils; + using Cthangover.Mods.Interface.Theming; + using Godot; + + /// + /// Launcher screen of the novel_editor collection, entered as its + /// logical entry scene. Lists every registered tool the same way the main + /// menu does — each appends its own button via + /// — so a new editor shows up here + /// without touching this screen. Tool windows open on the tree root and + /// survive scene switches (playing a scenario, returning to the menu). + /// + public partial class NovelEditorHub : Control + { + /// Logical scene name; the collection's entry.scene. + public const string SceneName = "novel_editor"; + + private const int PanelWidth = 420; + + public override void _Ready() + { + CthangoverTheme.Apply(this); + + AddChild(new ColorRect + { + Color = UiTokens.Page, + MouseFilter = MouseFilterEnum.Ignore, + AnchorRight = 1f, + AnchorBottom = 1f, + }); + + var center = new CenterContainer { AnchorRight = 1f, AnchorBottom = 1f }; + AddChild(center); + + var panel = new PanelContainer { CustomMinimumSize = new Vector2(PanelWidth, 0) }; + var frame = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Surface1, UiTokens.Line); + frame.SetContentMarginAll(UiTokens.Space8); + panel.AddThemeStyleboxOverride("panel", frame); + center.AddChild(panel); + + var column = UiKit.Column(UiTokens.Space5); + panel.AddChild(column); + + column.AddChild(UiKit.Label(Tr("tools/hub/title").ToUpperInvariant(), UiTokens.FontTitle, UiTokens.InkBright)); + var hint = UiKit.Hint(Tr("tools/hub/hint")); + hint.AutowrapMode = TextServer.AutowrapMode.WordSmart; + column.AddChild(hint); + + column.AddChild(new HSeparator()); + column.AddChild(UiKit.SectionTitle(Tr("tools/hub/editors"), UiTokens.FontSmall)); + + var tools = UiKit.Column(UiTokens.Space2); + tools.MouseFilter = MouseFilterEnum.Pass; + column.AddChild(tools); + foreach (var tool in ToolFactory.Instance.GetAll()) + tool.AddMenuButton(tools); + + if (tools.GetChildCount() == 0) + column.AddChild(UiKit.EmptyState(Tr("tools/hub/empty"))); + + column.AddChild(new HSeparator()); + var menu = UiKit.Button(Tr("tools/hub/main_menu")); + menu.Pressed += OnMainMenuPressed; + column.AddChild(menu); + + GameLogger.Log("TOOLS", $"Novel editor hub: {tools.GetChildCount()} tool button(s)"); + } + + private void OnMainMenuPressed() + { + GetNodeOrNull("/root/GodotSceneService")?.SwitchToMenu(); + } + } +} diff --git a/src/Hub/ToolsInitializer.cs b/src/Hub/ToolsInitializer.cs new file mode 100644 index 0000000..f2d8783 --- /dev/null +++ b/src/Hub/ToolsInitializer.cs @@ -0,0 +1,26 @@ +namespace Cthangover.Tools.Hub +{ + using Cthangover.Core.Mods; + using Cthangover.Core.Scenes; + + /// + /// Lifecycle entry point of the tools mod. Publishes the logical scene + /// , the entry scene of the + /// novel_editor collection: launching that collection from the game + /// picker goes through the regular new-game flow and lands on the editor + /// launcher instead of a story scene. + /// + public class ToolsInitializer : IModInitializer + { + private const string HubScenePath = "scenes/novel_editor_hub.tscn"; + + public void OnModLoaded(string modId) + { + } + + public void OnModResourcesReady() + { + SceneRouteRegistry.Register(NovelEditorHub.SceneName, HubScenePath, fade: false); + } + } +} diff --git a/src/InteractiveEditor/InteractiveEditorCanvas.cs b/src/InteractiveEditor/InteractiveEditorCanvas.cs new file mode 100644 index 0000000..244550b --- /dev/null +++ b/src/InteractiveEditor/InteractiveEditorCanvas.cs @@ -0,0 +1,260 @@ +using System; +using System.Linq; +using Cthangover.Core.Interactive; +using Cthangover.Core.UI.Theming; +using Cthangover.Tools.Common; +using Godot; + +namespace Cthangover.Tools.InteractiveEditor +{ + /// + /// Preview of the interactives of one file over a scene background. Every + /// collider is drawn (others faint, the selected one filled), so the author + /// sees how objects of the same scene sit next to each other; clicking a + /// collider selects it. The selected collider gets handles: + /// + /// rect — top-left and bottom-right corners plus a centre handle + /// that moves the whole rect; + /// circle — centre and a radius handle; + /// polygon — one handle per vertex (right-click deletes it); a click + /// on empty background appends a vertex. + /// + /// Geometry is in the same normalised background space as + /// InteractiveObject: x/y/width/height are fractions of the + /// background size, a circle radius is a fraction of its shorter side. + /// + public partial class InteractiveEditorCanvas : BackgroundCanvas + { + private const int TopLeft = 0; + private const int BottomRight = 1; + private const int Move = 2; + private const int RadiusHandle = 1; + private const float MinExtent = 0.005f; + + /// Data being edited; set once by the window. + public InteractiveEditorController Controller; + + /// Raised whenever geometry of the selected item changes. + public event Action Changed; + + /// Raised when a click on the canvas selects another item (-1: none). + public event Action ItemPicked; + + public InteractiveEditorCanvas() + { + GuiInput += OnGuiInput; + } + + /// Recreates the handles of the selected item's collider. + public override void RebuildHandles() + { + ClearHandles(); + + var hit = Controller?.Selected?.HitArea; + if (hit == null || GetBackgroundRect().Size.X <= 0f) + return; + + switch (hit.Type) + { + case "circle": + AddHandle(new Vector2(hit.X, hit.Y), UiTokens.Info, Move, p => { hit.X = p.X; hit.Y = p.Y; Moved(); }); + AddHandle(RadiusPoint(hit), UiTokens.Info, RadiusHandle, p => + { + var shortSide = ShortSide(); + var centre = NormToLocal(new Vector2(hit.X, hit.Y)); + hit.Radius = Mathf.Max(MinExtent, (NormToLocal(p) - centre).Length() / shortSide); + Moved(); + }); + break; + case "polygon": + var vertices = hit.Vertices ?? Array.Empty(); + for (var i = 0; i < vertices.Length; i++) + { + var index = i; + var handle = AddHandle(vertices[i], UiTokens.Accent, i, p => { hit.Vertices[index] = p; Moved(); }); + handle.RightClicked = () => RemoveVertex(index); + } + break; + default: + AddHandle(new Vector2(hit.X, hit.Y), UiTokens.Success, TopLeft, p => + { + var right = hit.X + hit.Width; + var bottom = hit.Y + hit.Height; + hit.X = Mathf.Min(p.X, right - MinExtent); + hit.Y = Mathf.Min(p.Y, bottom - MinExtent); + hit.Width = right - hit.X; + hit.Height = bottom - hit.Y; + Moved(); + }); + AddHandle(new Vector2(hit.X + hit.Width, hit.Y + hit.Height), UiTokens.Success, BottomRight, p => + { + hit.Width = Mathf.Max(MinExtent, p.X - hit.X); + hit.Height = Mathf.Max(MinExtent, p.Y - hit.Y); + Moved(); + }); + AddHandle(new Vector2(hit.X + hit.Width / 2f, hit.Y + hit.Height / 2f), UiTokens.AccentBright, Move, p => + { + hit.X = Mathf.Clamp(p.X - hit.Width / 2f, 0f, 1f - hit.Width); + hit.Y = Mathf.Clamp(p.Y - hit.Height / 2f, 0f, 1f - hit.Height); + Moved(); + }, 12f); + break; + } + + foreach (var handle in _handles) + handle.DragEnd = () => { RebuildHandles(); Changed?.Invoke(); }; + } + + /// Keeps the other handles glued to the shape while one is dragged. + private void Moved() + { + var hit = Controller.Selected.HitArea; + foreach (var handle in _handles) + { + Vector2? target = hit.Type switch + { + "circle" => handle.Index == Move ? new Vector2(hit.X, hit.Y) : RadiusPoint(hit), + "polygon" => null, + _ => handle.Index switch + { + TopLeft => new Vector2(hit.X, hit.Y), + BottomRight => new Vector2(hit.X + hit.Width, hit.Y + hit.Height), + _ => new Vector2(hit.X + hit.Width / 2f, hit.Y + hit.Height / 2f) + } + }; + if (target.HasValue) + handle.CenterAt(GlobalPosition + NormToLocal(target.Value)); + } + + QueueRedraw(); + Changed?.Invoke(); + } + + private Vector2 RadiusPoint(HitAreaDefinition hit) + { + var rect = GetBackgroundRect(); + var offset = rect.Size.X > 0f ? hit.Radius * ShortSide() / rect.Size.X : 0f; + return new Vector2(hit.X + offset, hit.Y); + } + + private float ShortSide() + { + var rect = GetBackgroundRect(); + return Mathf.Max(1f, Mathf.Min(rect.Size.X, rect.Size.Y)); + } + + private void RemoveVertex(int index) + { + var hit = Controller.Selected?.HitArea; + if (hit?.Vertices == null || index >= hit.Vertices.Length) + return; + + hit.Vertices = hit.Vertices.Where((_, i) => i != index).ToArray(); + RebuildHandles(); + QueueRedraw(); + Changed?.Invoke(); + } + + private void OnGuiInput(InputEvent @event) + { + if (Controller == null || @event is not InputEventMouseButton mb || !mb.Pressed || mb.ButtonIndex != MouseButton.Left) + return; + + var norm = LocalToNorm(mb.Position); + var hitIndex = HitTest(norm); + var selected = Controller.Selected; + + if (hitIndex >= 0 && hitIndex != Controller.SelectedIndex) + { + Controller.Select(hitIndex); + RebuildHandles(); + QueueRedraw(); + ItemPicked?.Invoke(hitIndex); + } + else if (hitIndex < 0 && selected?.HitArea.Type == "polygon") + { + var vertices = selected.HitArea.Vertices ?? Array.Empty(); + selected.HitArea.Vertices = vertices.Append(Clamp01(norm)).ToArray(); + RebuildHandles(); + QueueRedraw(); + Changed?.Invoke(); + } + + AcceptEvent(); + } + + /// Topmost item whose collider contains the normalised point, or -1. + private int HitTest(Vector2 norm) + { + var rect = GetBackgroundRect(); + var local = NormToLocal(norm); + for (var i = Controller.Items.Count - 1; i >= 0; i--) + { + var hit = Controller.Items[i].HitArea; + if (hit == null) + continue; + + var inside = hit.Type switch + { + "circle" => (local - NormToLocal(new Vector2(hit.X, hit.Y))).Length() <= hit.Radius * Mathf.Min(rect.Size.X, rect.Size.Y), + "polygon" => hit.Vertices is { Length: >= 3 } && Geometry2D.IsPointInPolygon(norm, hit.Vertices), + _ => new Rect2(hit.X, hit.Y, hit.Width, hit.Height).HasPoint(norm) + }; + if (inside) + return i; + } + return -1; + } + + protected override void DrawContent(Rect2 rect) + { + if (Controller == null) + return; + + for (var i = 0; i < Controller.Items.Count; i++) + DrawCollider(Controller.Items[i], rect, i == Controller.SelectedIndex); + } + + private void DrawCollider(InteractiveDefinition def, Rect2 rect, bool selected) + { + var hit = def.HitArea; + if (hit == null) + return; + + var line = selected ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.Ink, 0.55f); + var fill = selected ? UiTokens.Alpha(UiTokens.Accent, 0.22f) : UiTokens.Alpha(UiTokens.Ink, 0.06f); + Vector2 labelAt; + + switch (hit.Type) + { + case "circle": + var centre = NormToLocal(new Vector2(hit.X, hit.Y)); + var radius = hit.Radius * Mathf.Min(rect.Size.X, rect.Size.Y); + DrawCircle(centre, radius, fill); + DrawArc(centre, radius, 0f, Mathf.Tau, 48, line, selected ? 2f : 1f, true); + labelAt = centre + new Vector2(-radius, -radius); + break; + case "polygon": + var vertices = hit.Vertices ?? Array.Empty(); + var points = vertices.Select(NormToLocal).ToArray(); + if (points.Length >= 3 && Geometry2D.TriangulatePolygon(points).Length > 0) + DrawColoredPolygon(points, fill); + if (points.Length >= 2) + DrawPolyline(points.Append(points[0]).ToArray(), line, selected ? 2f : 1f, true); + labelAt = points.Length > 0 ? points[0] : rect.Position; + break; + default: + var box = new Rect2(NormToLocal(new Vector2(hit.X, hit.Y)), new Vector2(hit.Width, hit.Height) * rect.Size); + DrawRect(box, fill); + DrawRect(box, line, false, selected ? 2f : 1f); + labelAt = box.Position; + break; + } + + var font = GetThemeDefaultFont(); + var size = UiTokens.FontSmall; + DrawString(font, labelAt + new Vector2(4f, -4f), def.ID ?? "?", HorizontalAlignment.Left, -1, size, + selected ? UiTokens.AccentBright : UiTokens.Body); + } + } +} diff --git a/src/InteractiveEditor/InteractiveEditorController.cs b/src/InteractiveEditor/InteractiveEditorController.cs index 1906a27..cc59404 100644 --- a/src/InteractiveEditor/InteractiveEditorController.cs +++ b/src/InteractiveEditor/InteractiveEditorController.cs @@ -1,244 +1,206 @@ +using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; -using System.Text.Json.Serialization; using Cthangover.Core.Interactive; +using Cthangover.Core.Mods; +using Cthangover.Core.Utils; using Godot; namespace Cthangover.Tools.InteractiveEditor { - /// - /// Data model for the interactive editor. Holds interactive definition - /// fields, manages collider vertex list for polygon editing, and produces - /// the output JSON. - /// - public class InteractiveEditorController - { - /// Unique identifier for the interactive object resource. - public string Id { get; set; } = "new_interactive"; - /// Texture key mapped to a sprite resource by the resource system. - public string Texture { get; set; } = ""; - /// Render layer: "foreground", "background", or "ui". - public string Layer { get; set; } = "foreground"; - /// Z-index for draw order within the layer. - public int ZIndex { get; set; } - /// Whether the interactive is initially enabled. - public bool Enabled { get; set; } = true; - /// Whether the interactive is visible. - public bool Visible { get; set; } = true; - /// Mouse cursor override name, e.g. "PointingHand". Empty for default. - public string Cursor { get; set; } = ""; - /// Collider shape type: "rect", "circle", or "polygon". - public string HitType { get; set; } = "rect"; + /// + /// Data model of the interactive editor: every definition of one + /// interactives/*.json file plus the selection. Definitions are + /// edited in place (the same objects the game deserialises), so the file + /// round-trips without a parallel field-by-field copy that could forget a + /// property. fills the optional sub-objects the editor + /// needs (hit area, highlight, actions) before a definition is shown. + /// + public class InteractiveEditorController + { + /// Collider kinds understood by InteractiveObject. + public static readonly string[] HitTypes = { "rect", "circle", "polygon" }; - /// Normalised X position of the rect collider (0–1). - public float RectX { get; set; } - /// Normalised Y position of the rect collider (0–1). - public float RectY { get; set; } - /// Normalised width of the rect collider (0–1). - public float RectW { get; set; } = 0.1f; - /// Normalised height of the rect collider (0–1). - public float RectH { get; set; } = 0.1f; + /// Render layers understood by the interactive layer host. + public static readonly string[] Layers = { "foreground", "background", "ui" }; - /// Normalised X centre of the circle collider (0–1). - public float CircleX { get; set; } = 0.5f; - /// Normalised Y centre of the circle collider (0–1). - public float CircleY { get; set; } = 0.5f; - /// Normalised radius of the circle collider (0–1). - public float CircleRadius { get; set; } = 0.05f; + /// Definitions of the open file, in file order. + public List Items { get; private set; } = new(); - /// List of normalised (0–1) vertices defining the polygon collider. - public List PolygonVertices { get; set; } = new(); + /// Index of the selected definition, or -1. + public int SelectedIndex { get; private set; } = -1; - /// Hex colour string (e.g. "#FFFF0033") for the highlight overlay. - public string HighlightColorHex { get; set; } = "#FFFF0033"; - /// Scale multiplier applied to the sprite when highlighted. - public float HighlightScale { get; set; } = 1.02f; + /// The selected definition, or null. + public InteractiveDefinition Selected => SelectedIndex >= 0 && SelectedIndex < Items.Count ? Items[SelectedIndex] : null; - /// Scenario file path executed on click. - public string OnClickScenario { get; set; } = ""; - /// Inline DSL commands executed on click, one per line. - public string OnClickCommands { get; set; } = ""; - /// DSL commands executed when the mouse enters the interactive area. - public string OnHoverEnter { get; set; } = ""; - /// DSL commands executed when the mouse leaves the interactive area. - public string OnHoverLeave { get; set; } = ""; + /// Replaces the items with the contents of a {"Items": [...]} file. Throws on malformed JSON. + public void FromJson(string json) + { + var data = string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize>(json, JsonOptionsProvider.ReadOptions); + Items = data?.Items ?? new List(); + foreach (var item in Items) + Ensure(item); + SelectedIndex = Items.Count > 0 ? 0 : -1; + } - /// Builds an InteractiveDefinition from current state. - public InteractiveDefinition ToDefinition() - { - var hitArea = new HitAreaDefinition { Type = HitType }; + /// Serialises all items into the standard envelope (indented, nulls omitted). + public string ToJson() + { + var data = new FileData { Items = Items }; + return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions); + } - 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; - } + /// Drops all items. + public void Clear() + { + Items = new List(); + SelectedIndex = -1; + } - var clickAction = new ClickAction(); + /// Selects by index; out of range clears the selection. + public void Select(int index) + { + SelectedIndex = index >= 0 && index < Items.Count ? index : -1; + } - if (!string.IsNullOrWhiteSpace(OnClickScenario)) - clickAction.Scenario = OnClickScenario.Trim(); + /// Appends a new clickable rect in the middle of the background and selects it. + public InteractiveDefinition Add() + { + var def = new InteractiveDefinition + { + ID = MakeUniqueId("interactive"), + Cursor = "PointingHand", + HitArea = new HitAreaDefinition { Type = "rect", X = 0.45f, Y = 0.45f, Width = 0.1f, Height = 0.1f } + }; + Ensure(def); + Items.Add(def); + SelectedIndex = Items.Count - 1; + return def; + } - if (!string.IsNullOrWhiteSpace(OnClickCommands)) - { - var lines = OnClickCommands.Split('\n', System.StringSplitOptions.RemoveEmptyEntries); - var cleanLines = new List(); - foreach (var line in lines) - { - var trimmed = line.Trim(); - if (!string.IsNullOrEmpty(trimmed)) - cleanLines.Add(trimmed); - } - if (cleanLines.Count > 0) - clickAction.Commands = cleanLines.ToArray(); - } + /// Appends a deep copy of the selected definition with a fresh ID, slightly offset. + public InteractiveDefinition DuplicateSelected() + { + var source = Selected; + if (source == null) + return null; - 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() - } - }; - } + var json = JsonSerializer.Serialize(source, JsonOptionsProvider.WriteCompactOptions); + var copy = JsonSerializer.Deserialize(json, JsonOptionsProvider.ReadOptions); + copy.ID = MakeUniqueId(source.ID); + Ensure(copy); + Offset(copy.HitArea, new Vector2(0.02f, 0.02f)); + Items.Add(copy); + SelectedIndex = Items.Count - 1; + return copy; + } - /// Loads state from an InteractiveDefinition. - public void FromDefinition(InteractiveDefinition def) - { - if (def == null) return; + /// Removes the selected definition and keeps a sensible selection. + public void RemoveSelected() + { + if (Selected == 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 ?? ""; + Items.RemoveAt(SelectedIndex); + SelectedIndex = Items.Count == 0 ? -1 : Math.Min(SelectedIndex, Items.Count - 1); + } - if (def.HitArea != null) - { - HitType = def.HitArea.Type ?? "rect"; - RectX = def.HitArea.X; - RectY = def.HitArea.Y; - RectW = def.HitArea.Width; - RectH = def.HitArea.Height; - CircleX = def.HitArea.X; - CircleY = def.HitArea.Y; - CircleRadius = def.HitArea.Radius; - PolygonVertices = def.HitArea.Vertices != null - ? new List(def.HitArea.Vertices) - : new List(); - } - else - { - HitType = "rect"; - } + /// + /// Changes the collider kind of a definition, converting the current + /// shape so it stays roughly where it was (rect ↔ circle by bounds, + /// polygon from the bounds' corners). + /// + public static void SetHitType(InteractiveDefinition def, string type) + { + var hit = def.HitArea; + if (hit.Type == type) + return; - if (def.Highlight != null) - { - HighlightColorHex = def.Highlight.ColorHex ?? "#FFFF0033"; - HighlightScale = def.Highlight.Scale; - } + var bounds = Bounds(hit); + hit.Type = type; + switch (type) + { + case "circle": + hit.X = bounds.GetCenter().X; + hit.Y = bounds.GetCenter().Y; + hit.Radius = Mathf.Max(0.01f, Mathf.Min(bounds.Size.X, bounds.Size.Y) / 2f); + break; + case "polygon": + hit.Vertices = new[] + { + bounds.Position, + new Vector2(bounds.End.X, bounds.Position.Y), + bounds.End, + new Vector2(bounds.Position.X, bounds.End.Y) + }; + break; + default: + hit.X = bounds.Position.X; + hit.Y = bounds.Position.Y; + hit.Width = bounds.Size.X; + hit.Height = bounds.Size.Y; + break; + } + } - 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 ?? ""; - } + /// + /// Normalised bounding box of a hit area (circle radius taken as a + /// fraction of both axes — an approximation good enough for + /// conversions and duplication offsets). + /// + public static Rect2 Bounds(HitAreaDefinition hit) + { + switch (hit?.Type) + { + case "circle": + return new Rect2(hit.X - hit.Radius, hit.Y - hit.Radius, hit.Radius * 2f, hit.Radius * 2f); + case "polygon" when hit.Vertices is { Length: > 0 }: + var min = hit.Vertices[0]; + var max = hit.Vertices[0]; + foreach (var v in hit.Vertices) + { + min = new Vector2(Mathf.Min(min.X, v.X), Mathf.Min(min.Y, v.Y)); + max = new Vector2(Mathf.Max(max.X, v.X), Mathf.Max(max.Y, v.Y)); + } + return new Rect2(min, max - min); + case null: + return new Rect2(0.45f, 0.45f, 0.1f, 0.1f); + default: + return new Rect2(hit.X, hit.Y, hit.Width, hit.Height); + } + } - /// Serializes the definition to formatted JSON (FileData envelope). - public string ToJson() - { - var def = ToDefinition(); - var wrapper = new - { - Items = new[] { def } - }; + /// Makes sure the optional parts the editor binds to exist. + public static void Ensure(InteractiveDefinition def) + { + def.HitArea ??= new HitAreaDefinition(); + def.HitArea.Type = HitTypes.Contains(def.HitArea.Type) ? def.HitArea.Type : "rect"; + def.Highlight ??= new HighlightDefinition(); + def.Actions ??= new InteractiveActionDefinition(); + } - var options = new JsonSerializerOptions - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; + /// ID not used by another item: base, base_2, base_3… + public string MakeUniqueId(string baseId) + { + var used = new HashSet(Items.Select(i => i.ID ?? string.Empty), StringComparer.OrdinalIgnoreCase); + if (!used.Contains(baseId)) + return baseId; - return JsonSerializer.Serialize(wrapper, options); - } + var suffix = 2; + while (used.Contains($"{baseId}_{suffix}")) + suffix++; + return $"{baseId}_{suffix}"; + } - /// Parses JSON back into the controller. Returns false on failure. - public bool FromJson(string json) - { - try - { - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip }; - var wrapper = JsonSerializer.Deserialize(json, options); - if (wrapper?.Items != null && wrapper.Items.Count > 0) - { - FromDefinition(wrapper.Items[0]); - return true; - } - return false; - } - catch - { - return false; - } - } - - private class FileDataWrapper - { - public List Items { get; set; } - } - - /// Updates a polygon vertex at the given index. - public void SetPolygonVertex(int index, Vector2 value) - { - while (PolygonVertices.Count <= index) - PolygonVertices.Add(Vector2.Zero); - PolygonVertices[index] = value; - } - - /// Removes a polygon vertex at the given index. - public void RemovePolygonVertex(int index) - { - if (index >= 0 && index < PolygonVertices.Count) - PolygonVertices.RemoveAt(index); - } - - /// Adds a new polygon vertex at the default position (center of viewport). - public void AddPolygonVertex() - { - var offset = PolygonVertices.Count * 0.04f; - PolygonVertices.Add(new Vector2(0.5f + offset, 0.5f + offset)); - } - } + private static void Offset(HitAreaDefinition hit, Vector2 delta) + { + hit.X += delta.X; + hit.Y += delta.Y; + if (hit.Vertices != null) + hit.Vertices = hit.Vertices.Select(v => v + delta).ToArray(); + } + } } diff --git a/src/InteractiveEditor/InteractiveEditorHandle.cs b/src/InteractiveEditor/InteractiveEditorHandle.cs deleted file mode 100644 index b1f31e8..0000000 --- a/src/InteractiveEditor/InteractiveEditorHandle.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Cthangover.Core.Utils; -using Godot; - -namespace Cthangover.Tools.InteractiveEditor -{ - /// - /// Draggable 16x16 handle representing a collider vertex on the preview area. - /// Used for rect corners, circle radius marker, and polygon vertices. - /// Subscribes to the GuiInput C# event rather than overriding _GuiInput - /// for reliable event delivery in mod assemblies. - /// - public partial class InteractiveEditorHandle : ColorRect - { - /// Index of the vertex this handle represents (0-based). - public int VertexIndex; - /// Invoked during drag with the centre position of the handle in global coordinates. - public System.Action DragUpdate; - /// Invoked when the handle is clicked (mouse down). - public System.Action Clicked; - /// Invoked when the drag ends (mouse up). Used to sync sidebar values. - public System.Action DragEnd; - - private bool _dragging; - private Vector2 _dragOffset; - - /// Creates a handle with the given colour, size 16×16, and index for vertex identification. - public InteractiveEditorHandle(Color color, int index) - { - VertexIndex = index; - Size = new Vector2(16, 16); - Color = color; - MouseFilter = Control.MouseFilterEnum.Stop; - MouseDefaultCursorShape = Control.CursorShape.PointingHand; - GuiInput += OnGuiInput; - } - - private void OnGuiInput(InputEvent @event) - { - if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left) - { - if (mb.Pressed) - { - _dragging = true; - _dragOffset = GetGlobalMousePosition() - GlobalPosition; - GameLogger.Log("INTERACTIVE_HANDLE", $"drag start idx={VertexIndex} pos={GlobalPosition}"); - Clicked?.Invoke(); - } - else - { - _dragging = false; - GameLogger.Log("INTERACTIVE_HANDLE", $"drag end idx={VertexIndex}"); - DragEnd?.Invoke(); - } - AcceptEvent(); - } - else if (@event is InputEventMouseMotion && _dragging) - { - var center = GlobalPosition + Size / 2; - GlobalPosition = GetGlobalMousePosition() - _dragOffset; - DragUpdate?.Invoke(center); - } - } - } -} diff --git a/src/InteractiveEditor/InteractiveEditorWindow.cs b/src/InteractiveEditor/InteractiveEditorWindow.cs index a3bc352..3e2fa1d 100644 --- a/src/InteractiveEditor/InteractiveEditorWindow.cs +++ b/src/InteractiveEditor/InteractiveEditorWindow.cs @@ -1,862 +1,644 @@ using System; using System.Collections.Generic; +using System.Linq; using Cthangover.Core.Interactive; -using Cthangover.Core.Mods; -using Cthangover.Core.Mods.Resolvers; -using Cthangover.Core.Factories.Impls; using Cthangover.Core.UI.Theming; -using Cthangover.Core.UI.Tool; using Cthangover.Core.Utils; +using Cthangover.Mods.Interface.Theming; +using Cthangover.Mods.Interface.Tool; +using Cthangover.Tools.Common; using Cthangover.Tools.Services; using Godot; -using Cthangover.Mods.Interface.Tool; namespace Cthangover.Tools.InteractiveEditor { - /// - /// Tool window for creating and editing interactive object definitions: - /// renders the selected background with draggable collider handles - /// (rect, circle, polygon), edits the definition's fields, and writes the - /// resulting JSON back into a mod directory. - /// - public partial class InteractiveEditorWindow : ToolWindow - { - private static readonly Color _rectColor = UiTokens.Alpha(UiTokens.Success, 0.6f); - private static readonly Color _circleColor = UiTokens.Alpha(UiTokens.Info, 0.6f); - private static readonly Color _vertexColor = UiTokens.Alpha(UiTokens.AccentBright, 0.8f); - private static readonly Color _colliderOverlayColor = UiTokens.Alpha(UiTokens.Success, 0.15f); - private static readonly Color _colliderCircleOverlayColor = UiTokens.Alpha(UiTokens.Info, 0.15f); - private static readonly Color _colliderPolyOverlayColor = UiTokens.Alpha(UiTokens.Accent, 0.15f); - private static readonly Color _colliderPolyOutlineColor = UiTokens.Alpha(UiTokens.Accent, 0.6f); - - private InteractiveEditorController _controller = new(); - - private Control _previewContainer; - private TextureRect _preview; - private Control _colliderOverlay; - private string _currentBgId; - - private readonly List _handles = new(); - - private TextEdit _jsonEdit; - - private LineEdit _idEdit, _textureEdit; - private OptionButton _layerSelect; - private OptionButton _hitTypeSelect; - private ItemList _bgList; - - private LineEdit _rectX, _rectY, _rectW, _rectH; - private LineEdit _circleX, _circleY, _circleR; - private VBoxContainer _rectParams, _circleParams, _polyParams; - - private ColorPickerButton _highlightColorBtn; - private LineEdit _highlightScaleEdit; - - private LineEdit _clickScenario, _hoverEnter, _hoverLeave; - private TextEdit _clickCommands; - - private VBoxContainer _vertexList; - private Button _addVertexBtn; - - private bool _suppressingSidebarEvents; - private int _draggingHandleIndex = -1; - - public InteractiveEditorWindow() : base("tools/interactive_editor/title") - { - Size = new Vector2I(1280, 820); - CthangoverTheme.Apply(this); - - 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 = UiTokens.Alpha(UiTokens.Accent, 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", UiTokens.Muted); - parent.AddChild(label); - } - - private LineEdit AddLineEdit(Control parent, string placeholder) - { - var edit = new LineEdit { PlaceholderText = placeholder }; - parent.AddChild(edit); - return edit; - } - - private LineEdit AddLabeledLine(Control parent, string locKey) - { - var hbox = new HBoxContainer(); - var label = new Label { Text = Tr(locKey) }; - label.CustomMinimumSize = new Vector2(25, 0); - hbox.AddChild(label); - var edit = new LineEdit(); - edit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; - hbox.AddChild(edit); - parent.AddChild(hbox); - return edit; - } - - private void LoadBackgroundList() - { - _bgList.Clear(); - var ids = ModResourceService.GetBackgroundIds(); - foreach (var id in ids) - _bgList.AddItem(id); - } - - private void OnBgSelected(long index) - { - var id = _bgList.GetItemText((int)index); - _currentBgId = id; - var tex = ModResourceService.LoadBackgroundTexture(id); - if (tex != null) - { - _preview.Texture = tex; - } - OnParamChanged(); - } - - private void OnHitTypeChanged(long index) - { - _controller.HitType = _hitTypeSelect.GetItemText((int)index); - _rectParams.Visible = _controller.HitType == "rect"; - _circleParams.Visible = _controller.HitType == "circle"; - _polyParams.Visible = _controller.HitType == "polygon"; - OnParamChanged(); - } - - private void OnAddVertex() - { - _controller.AddPolygonVertex(); - OnParamChanged(); - } - - private void OnParamChanged() - { - if (_suppressingSidebarEvents) - { - GameLogger.Log("INTERACTIVE_EDITOR", "OnParamChanged suppressed"); - return; - } - - GameLogger.Log("INTERACTIVE_EDITOR", $"OnParamChanged hitType={_controller.HitType} bgSize=({_previewContainer.Size.X:F0}x{_previewContainer.Size.Y:F0})"); - ReadSidebarToController(); - RebuildHandles(); - UpdateColliderOverlay(); - RefreshJson(); - SetDirty(); - UpdateVertexList(); - } - - private void OnDragUpdate() - { - UpdateNonDraggedHandles(); - UpdateColliderOverlay(); - RefreshJson(); - SetDirty(); - } - - private void ReadSidebarToController() - { - _controller.Id = string.IsNullOrWhiteSpace(_idEdit.Text) ? "new_interactive" : _idEdit.Text.Trim(); - _controller.Texture = _textureEdit.Text.Trim(); - _controller.Layer = _layerSelect.GetItemText(_layerSelect.Selected); - - if (_controller.HitType == "rect") - { - float.TryParse(_rectX.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v); - _controller.RectX = v; - float.TryParse(_rectY.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.RectY = v; - float.TryParse(_rectW.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.RectW = v; - float.TryParse(_rectH.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.RectH = v; - } - else if (_controller.HitType == "circle") - { - float.TryParse(_circleX.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v); - _controller.CircleX = v; - float.TryParse(_circleY.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.CircleY = v; - float.TryParse(_circleR.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v); _controller.CircleRadius = v; - } - - _controller.HighlightColorHex = "#" + _highlightColorBtn.Color.ToHtml(false); - float.TryParse(_highlightScaleEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var scale); - _controller.HighlightScale = scale; - - _controller.OnClickScenario = _clickScenario.Text.Trim(); - _controller.OnClickCommands = _clickCommands.Text.Trim(); - _controller.OnHoverEnter = _hoverEnter.Text.Trim(); - _controller.OnHoverLeave = _hoverLeave.Text.Trim(); - } - - private Rect2 GetBackgroundRect() - { - var cw = _previewContainer.Size.X; - var ch = _previewContainer.Size.Y; - if (cw <= 0 || ch <= 0) return new Rect2(); - - var texSize = new Vector2(1920, 1024); - var tex = _preview.Texture; - if (tex != null) - { - var ts = tex.GetSize(); - if (ts.X > 0 && ts.Y > 0) texSize = ts; - } - - var scale = Mathf.Min(cw / texSize.X, ch / texSize.Y); - var rw = texSize.X * scale; - var rh = texSize.Y * scale; - var ox = (cw - rw) / 2f; - var oy = (ch - rh) / 2f; - return new Rect2(ox, oy, rw, rh); - } - - private Vector2 NormToPixel(Vector2 norm) - { - var bg = GetBackgroundRect(); - return bg.Position + norm * bg.Size; - } - - private Vector2 PixelToNorm(Vector2 pixel) - { - var bg = GetBackgroundRect(); - if (bg.Size.X <= 0 || bg.Size.Y <= 0) return Vector2.Zero; - return new Vector2( - (pixel.X - bg.Position.X) / bg.Size.X, - (pixel.Y - bg.Position.Y) / bg.Size.Y); - } - - private void WriteControllerToSidebar() - { - _suppressingSidebarEvents = true; - try - { - _idEdit.Text = _controller.Id; - _textureEdit.Text = _controller.Texture; - - for (int i = 0; i < _layerSelect.ItemCount; i++) - if (_layerSelect.GetItemText(i) == _controller.Layer) - _layerSelect.Select(i); - - for (int i = 0; i < _hitTypeSelect.ItemCount; i++) - if (_hitTypeSelect.GetItemText(i) == _controller.HitType) - _hitTypeSelect.Select(i); - - _rectX.Text = _controller.RectX.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); - _rectY.Text = _controller.RectY.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); - _rectW.Text = _controller.RectW.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); - _rectH.Text = _controller.RectH.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); - - _circleX.Text = _controller.CircleX.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); - _circleY.Text = _controller.CircleY.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); - _circleR.Text = _controller.CircleRadius.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); - - _rectParams.Visible = _controller.HitType == "rect"; - _circleParams.Visible = _controller.HitType == "circle"; - _polyParams.Visible = _controller.HitType == "polygon"; - - _highlightColorBtn.Color = new Color(_controller.HighlightColorHex); - _highlightScaleEdit.Text = _controller.HighlightScale.ToString("F2", System.Globalization.CultureInfo.InvariantCulture); - - _clickScenario.Text = _controller.OnClickScenario; - _clickCommands.Text = _controller.OnClickCommands; - _hoverEnter.Text = _controller.OnHoverEnter; - _hoverLeave.Text = _controller.OnHoverLeave; - } - finally - { - _suppressingSidebarEvents = false; - } - } - - private void RebuildHandles() - { - foreach (var h in _handles) - h.QueueFree(); - _handles.Clear(); - - var bg = GetBackgroundRect(); - if (bg.Size.X <= 0 || bg.Size.Y <= 0) - { - GameLogger.Log("INTERACTIVE_EDITOR", "RebuildHandles skipped: bg size <= 0"); - return; - } - - GameLogger.Log("INTERACTIVE_EDITOR", $"RebuildHandles hitType={_controller.HitType} bg=({bg.Position.X:F0},{bg.Position.Y:F0} {bg.Size.X:F0}x{bg.Size.Y:F0})"); - - var globalTopLeft = _previewContainer.GlobalPosition; - - switch (_controller.HitType) - { - case "rect": - { - var topLeft = NormToPixel(new Vector2(_controller.RectX, _controller.RectY)); - var bottomRight = NormToPixel(new Vector2( - _controller.RectX + _controller.RectW, - _controller.RectY + _controller.RectH)); - - AddHandleAt(globalTopLeft + topLeft, _rectColor, 0, pos => - { - var norm = PixelToNorm(pos - globalTopLeft); - _controller.RectX = Mathf.Clamp(norm.X, 0f, 1f); - _controller.RectY = Mathf.Clamp(norm.Y, 0f, 1f); - _draggingHandleIndex = 0; - OnDragUpdate(); - }); - AddHandleAt(globalTopLeft + bottomRight, _rectColor, 1, pos => - { - var norm = PixelToNorm(pos - globalTopLeft); - _controller.RectW = Mathf.Max(0.01f, norm.X - _controller.RectX); - _controller.RectH = Mathf.Max(0.01f, norm.Y - _controller.RectY); - _draggingHandleIndex = 1; - OnDragUpdate(); - }); - break; - } - case "circle": - { - var refDim = Mathf.Min(bg.Size.X, bg.Size.Y); - var center = NormToPixel(new Vector2(_controller.CircleX, _controller.CircleY)); - var radiusPx = _controller.CircleRadius * refDim; - - AddHandleAt(globalTopLeft + center, _circleColor, 0, pos => - { - var norm = PixelToNorm(pos - globalTopLeft); - _controller.CircleX = Mathf.Clamp(norm.X, 0f, 1f); - _controller.CircleY = Mathf.Clamp(norm.Y, 0f, 1f); - _draggingHandleIndex = 0; - OnDragUpdate(); - }); - AddHandleAt(globalTopLeft + new Vector2(center.X + radiusPx, center.Y), _circleColor, 1, pos => - { - var local = pos - globalTopLeft; - var centerPx = NormToPixel(new Vector2(_controller.CircleX, _controller.CircleY)); - var dist = (local - centerPx).Length(); - _controller.CircleRadius = Mathf.Max(0.005f, dist / refDim); - _draggingHandleIndex = 1; - OnDragUpdate(); - }); - break; - } - case "polygon": - { - for (int i = 0; i < _controller.PolygonVertices.Count; i++) - { - var idx = i; - var v = _controller.PolygonVertices[i]; - AddHandleAt(globalTopLeft + NormToPixel(v), _vertexColor, i, pos => - { - var norm = PixelToNorm(pos - globalTopLeft); - _controller.SetPolygonVertex(idx, new Vector2( - Mathf.Clamp(norm.X, 0f, 1f), - Mathf.Clamp(norm.Y, 0f, 1f))); - _draggingHandleIndex = idx; - OnDragUpdate(); - }); - } - break; - } - } - } - - private void UpdateNonDraggedHandles() - { - var bg = GetBackgroundRect(); - if (bg.Size.X <= 0 || bg.Size.Y <= 0) return; - - var globalTopLeft = _previewContainer.GlobalPosition; - - for (int i = 0; i < _handles.Count; i++) - { - if (i == _draggingHandleIndex) continue; - var pos = GetHandleNormPosition(i); - var pixelPos = globalTopLeft + NormToPixel(pos); - var h = _handles[i]; - if (h != null && !h.IsQueuedForDeletion()) - h.GlobalPosition = pixelPos - h.Size / 2; - } - } - - private Vector2 GetHandleNormPosition(int index) - { - switch (_controller.HitType) - { - case "rect": - if (index == 0) - return new Vector2(_controller.RectX, _controller.RectY); - return new Vector2(_controller.RectX + _controller.RectW, _controller.RectY + _controller.RectH); - case "circle": - if (index == 0) - return new Vector2(_controller.CircleX, _controller.CircleY); - return new Vector2(_controller.CircleX + _controller.CircleRadius, _controller.CircleY); - case "polygon": - if (index >= 0 && index < _controller.PolygonVertices.Count) - return _controller.PolygonVertices[index]; - return Vector2.Zero; - default: - return Vector2.Zero; - } - } - - private void UpdateColliderOverlay() - { - var bg = GetBackgroundRect(); - if (bg.Size.X <= 0 || bg.Size.Y <= 0) - { - _colliderOverlay.Visible = false; - return; - } - - _colliderOverlay.Visible = true; - _colliderOverlay.Position = bg.Position; - _colliderOverlay.Size = bg.Size; - _colliderOverlay.QueueRedraw(); - } - - private void DrawColliderShape() - { - var size = _colliderOverlay.Size; - if (size.X <= 0 || size.Y <= 0) return; - - switch (_controller.HitType) - { - case "rect": - { - var pos = NormToPixelLocal(new Vector2(_controller.RectX, _controller.RectY)); - var rectSize = new Vector2(_controller.RectW * size.X, _controller.RectH * size.Y); - _colliderOverlay.DrawRect(new Rect2(pos, rectSize), _colliderOverlayColor); - break; - } - case "circle": - { - var rPx = _controller.CircleRadius * Mathf.Min(size.X, size.Y); - var center = NormToPixelLocal(new Vector2(_controller.CircleX, _controller.CircleY)); - _colliderOverlay.DrawCircle(center, rPx, _colliderCircleOverlayColor); - break; - } - case "polygon": - { - if (_controller.PolygonVertices.Count >= 3) - { - var points = new Vector2[_controller.PolygonVertices.Count]; - for (int i = 0; i < _controller.PolygonVertices.Count; i++) - points[i] = NormToPixelLocal(_controller.PolygonVertices[i]); - - if (PolygonArea(points) > 0.0001f) - _colliderOverlay.DrawColoredPolygon(points, _colliderPolyOverlayColor); - _colliderOverlay.DrawPolyline(points, _colliderPolyOutlineColor); - } - break; - } - } - } - - private Vector2 NormToPixelLocal(Vector2 norm) - { - return norm * _colliderOverlay.Size; - } - - private static float PolygonArea(Vector2[] points) - { - float area = 0; - for (int i = 0; i < points.Length; i++) - { - var j = (i + 1) % points.Length; - area += points[i].X * points[j].Y; - area -= points[j].X * points[i].Y; - } - return Mathf.Abs(area) * 0.5f; - } - - private void AddHandleAt(Vector2 globalPos, Color color, int index, Action onDrag) - { - var handle = new InteractiveEditorHandle(color, index); - handle.DragUpdate = center => - { - onDrag(center); - }; - handle.Clicked = () => - { - SelectHandle(index); - }; - handle.DragEnd = () => - { - GameLogger.Log("INTERACTIVE_EDITOR", $"DragEnd idx={index} syncing sidebar"); - _draggingHandleIndex = -1; - _suppressingSidebarEvents = true; - WriteControllerToSidebar(); - _suppressingSidebarEvents = false; - UpdateVertexList(); - }; - handle.MouseDefaultCursorShape = Control.CursorShape.PointingHand; - _previewContainer.AddChild(handle); - handle.GlobalPosition = globalPos - handle.Size / 2; - _handles.Add(handle); - } - - private void SelectHandle(int index) - { - if (_controller.HitType == "polygon" && index >= 0 && index < _controller.PolygonVertices.Count) - { - var v = _controller.PolygonVertices[index]; - GD.Print($"Selected vertex {index}: ({v.X:F3}, {v.Y:F3})"); - } - } - - private void UpdateVertexList() - { - for (int i = _vertexList.GetChildCount() - 1; i >= 0; i--) - _vertexList.GetChild(i).QueueFree(); - - if (_controller.HitType != "polygon") return; - - for (int i = 0; i < _controller.PolygonVertices.Count; i++) - { - var idx = i; - var v = _controller.PolygonVertices[i]; - var row = new HBoxContainer(); - - var xEdit = new LineEdit(); - xEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; - xEdit.Text = v.X.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); - xEdit.TextChanged += txt => - { - if (float.TryParse(xEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var val)) - _controller.SetPolygonVertex(idx, new Vector2(val, _controller.PolygonVertices[idx].Y)); - OnParamChanged(); - }; - row.AddChild(xEdit); - - var yEdit = new LineEdit(); - yEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; - yEdit.Text = v.Y.ToString("F3", System.Globalization.CultureInfo.InvariantCulture); - yEdit.TextChanged += txt => - { - if (float.TryParse(yEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var val)) - _controller.SetPolygonVertex(idx, new Vector2(_controller.PolygonVertices[idx].X, val)); - OnParamChanged(); - }; - row.AddChild(yEdit); - - var delBtn = new Button { Text = "X" }; - delBtn.CustomMinimumSize = new Vector2(30, 0); - delBtn.Pressed += () => - { - _controller.RemovePolygonVertex(idx); - OnParamChanged(); - }; - row.AddChild(delBtn); - - _vertexList.AddChild(row); - } - } - - private void RefreshJson() - { - _jsonEdit.Text = _controller.ToJson(); - } - - private void OnLoadJson() - { - var dialog = new FileDialog(); - dialog.FileMode = FileDialog.FileModeEnum.OpenFile; - dialog.AddFilter("*.json", "JSON Files"); - dialog.AddFilter("*.interactive", "Interactive Files"); - dialog.FileSelected += path => - { - var file = FileAccess.Open(path, FileAccess.ModeFlags.Read); - if (file != null) - { - var text = file.GetAsText(); - file.Close(); - if (_controller.FromJson(text)) - { - WriteControllerToSidebar(); - UpdateVertexList(); - RebuildHandles(); - UpdateColliderOverlay(); - RefreshJson(); - MarkClean(); - } - else - { - GD.PrintErr("Failed to parse JSON"); - } - } - dialog.QueueFree(); - }; - dialog.Canceled += () => dialog.QueueFree(); - AddChild(dialog); - dialog.PopupCentered(new Vector2I(800, 600)); - } - - private void OnImportFromMod() - { - var ids = Interactives.Collect(); - if (ids.Count == 0) - { - GD.Print("No interactive definitions found in mods"); - return; - } - - var dialog = new AcceptDialog { Title = Tr("tools/interactive_editor/dlg_select_def") }; - dialog.Size = new Vector2I(400, 300); - - var list = new ItemList(); - list.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; - list.SizeFlagsVertical = Control.SizeFlags.ExpandFill; - foreach (var kv in ids) - list.AddItem(kv.Key); - list.ItemActivated += idx => - { - var id = list.GetItemText((int)idx); - var def = InteractiveFactory.Instance.Get(id); - if (def != null) - { - _controller.FromDefinition(def); - WriteControllerToSidebar(); - UpdateVertexList(); - RebuildHandles(); - UpdateColliderOverlay(); - RefreshJson(); - MarkClean(); - } - dialog.QueueFree(); - }; - dialog.AddChild(list); - AddChild(dialog); - dialog.PopupCentered(); - } - - private void OnSave() - { - var dialog = new FileDialog(); - dialog.FileMode = FileDialog.FileModeEnum.SaveFile; - dialog.AddFilter("*.json", "JSON Files"); - dialog.FileSelected += path => - { - var json = _controller.ToJson(); - var file = FileAccess.Open(path, FileAccess.ModeFlags.Write); - if (file != null) - { - file.StoreString(json); - file.Close(); - MarkClean(); - GD.Print($"Saved to {path}"); - } - dialog.QueueFree(); - }; - dialog.Canceled += () => dialog.QueueFree(); - AddChild(dialog); - dialog.PopupCentered(new Vector2I(800, 600)); - } - - private void OnParamChanged(string _ = null) => OnParamChanged(); - - protected override void Cleanup() - { - foreach (var h in _handles) - h.QueueFree(); - _handles.Clear(); - } - } + /// + /// Editor of interactive object definitions (interactives/*.json). + /// The author opens a file of a writable mod, sees every object of the + /// file over its scene background, picks one in the list or on the canvas + /// and edits its collider by dragging handles or typing numbers, plus its + /// look (texture, layer, highlight) and actions (scenario, commands, hover + /// commands). Saving writes the whole file back, other objects included. + /// + /// The background is chosen automatically from the scene whose scenario + /// adds the selected object (interactive_add), when that is known. + /// + public partial class InteractiveEditorWindow : ToolWindow + { + private const string InteractivesDir = "interactives"; + + private readonly InteractiveEditorController _controller = new(); + private readonly List _backgroundIds = new(); + private Dictionary _interactiveScenes; + + private string _modId; + private string _path; + private bool _suppress; + + private ModFileSelector _fileSelector; + private FilteredList _itemList; + private FilteredList _bgList; + private InteractiveEditorCanvas _canvas; + private CodeEdit _jsonEdit; + + private Label _propsEmpty; + private VBoxContainer _propsBody; + private LineEdit _idEdit; + private LineEdit _textureEdit; + private OptionButton _layerSelect; + private SpinBox _zIndex; + private CheckBox _enabledCheck; + private CheckBox _visibleCheck; + private OptionButton _cursorSelect; + private OptionButton _hitTypeSelect; + private VBoxContainer _rectParams; + private VBoxContainer _circleParams; + private VBoxContainer _polyParams; + private SpinBox _rectX, _rectY, _rectW, _rectH; + private SpinBox _circleX, _circleY, _circleR; + private VBoxContainer _vertexList; + private ColorPickerButton _highlightColor; + private SpinBox _highlightScale; + private LineEdit _clickScenario; + private CodeEdit _clickCommands; + private LineEdit _hoverEnter; + private LineEdit _hoverLeave; + + public InteractiveEditorWindow() : base("tools/interactive_editor/title") + { + CthangoverTheme.Apply(this); + BuildUI(); + LoadBackgrounds(); + SetHint(ToolUi.Tr("tools/interactive_editor/hint")); + _fileSelector.Refresh(); + } + + // ── UI construction ──────────────────────────────────────── + + private void BuildUI() + { + var outer = CreateFillContainer(); + AddChild(outer); + + var toolbar = CreateToolbar(); + outer.AddChild(toolbar); + _fileSelector = new ModFileSelector(InteractivesDir, ".json"); + _fileSelector.FileRequested += OnFileRequested; + toolbar.AddChild(_fileSelector); + toolbar.AddChild(ToolUi.Button("tools/common/save", Save, ButtonKind.Primary, "tools/common/save_tip")); + toolbar.AddChild(UiKit.Spacer()); + var jsonToggle = ToolUi.Button("tools/common/json", null, ButtonKind.Tab, "tools/common/json_tip"); + jsonToggle.ToggleMode = true; + jsonToggle.Toggled += on => _jsonEdit.Visible = on; + toolbar.AddChild(jsonToggle); + + var main = new HSplitContainer + { + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill + }; + outer.AddChild(main); + + var left = new VBoxContainer(); + main.AddChild(CreateSidebar(left, 260)); + BuildLeft(left); + + var centerSplit = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + main.AddChild(centerSplit); + + var canvasColumn = new VSplitContainer + { + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill + }; + centerSplit.AddChild(canvasColumn); + + var canvasPanel = new PanelContainer + { + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill + }; + canvasPanel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line)); + canvasColumn.AddChild(canvasPanel); + + _canvas = new InteractiveEditorCanvas { Controller = _controller }; + _canvas.Changed += OnCanvasChanged; + _canvas.ItemPicked += _ => RefreshAll(); + canvasPanel.AddChild(_canvas); + + _jsonEdit = ToolUi.Code(editable: false); + _jsonEdit.CustomMinimumSize = new Vector2(0, 160); + _jsonEdit.Visible = false; + canvasColumn.AddChild(_jsonEdit); + + var right = new VBoxContainer(); + centerSplit.AddChild(CreateSidebar(right, 330)); + BuildProperties(right); + } + + private void BuildLeft(VBoxContainer column) + { + column.AddChild(ToolUi.Section("tools/interactive_editor/items")); + _itemList = new FilteredList(180); + _itemList.Chosen += OnItemChosen; + column.AddChild(_itemList); + + var row = new HBoxContainer(); + row.AddThemeConstantOverride("separation", UiTokens.Space1); + column.AddChild(row); + AddExpanding(row, ToolUi.Button("tools/interactive_editor/add", OnAdd)); + AddExpanding(row, ToolUi.Button("tools/interactive_editor/duplicate", OnDuplicate)); + column.AddChild(ToolUi.Button("tools/interactive_editor/delete", OnDelete, ButtonKind.Danger)); + + column.AddChild(ToolUi.Section("tools/interactive_editor/background")); + _bgList = new FilteredList(160); + _bgList.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + _bgList.Chosen += ShowBackground; + column.AddChild(_bgList); + } + + private static void AddExpanding(Container parent, Control control) + { + control.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + parent.AddChild(control); + } + + private void BuildProperties(VBoxContainer column) + { + column.AddChild(ToolUi.Section("tools/interactive_editor/props")); + _propsEmpty = ToolUi.Hint(ToolUi.Tr("tools/interactive_editor/none_selected")); + column.AddChild(_propsEmpty); + + var body = new VBoxContainer(); + body.AddThemeConstantOverride("separation", UiTokens.Space2); + column.AddChild(body); + _propsBody = body; + + _idEdit = ToolUi.Field(body, "tools/interactive_editor/label_id", ToolUi.Text("crypt_spot", _ => OnIdChanged())); + _idEdit.AddThemeFontOverride("font", ToolUi.Mono()); + _textureEdit = ToolUi.Field(body, "tools/interactive_editor/label_texture_key", ToolUi.Text(ToolUi.Tr("tools/interactive_editor/texture_placeholder"), _ => OnFieldsChanged()), + "tools/interactive_editor/texture_tip"); + + _layerSelect = ToolUi.Field(body, "tools/interactive_editor/label_layer", new OptionButton()); + foreach (var layer in InteractiveEditorController.Layers) + _layerSelect.AddItem(layer); + _layerSelect.ItemSelected += _ => OnFieldsChanged(); + + _zIndex = ToolUi.Field(body, "tools/interactive_editor/label_z", ToolUi.Number(-100, 100, 1, _ => OnFieldsChanged())); + + _cursorSelect = ToolUi.Field(body, "tools/interactive_editor/label_cursor", new OptionButton()); + _cursorSelect.AddItem(ToolUi.Tr("tools/interactive_editor/cursor_none")); + _cursorSelect.AddItem("PointingHand"); + _cursorSelect.ItemSelected += _ => OnFieldsChanged(); + + _enabledCheck = new CheckBox { Text = ToolUi.Tr("tools/interactive_editor/enabled") }; + _enabledCheck.Toggled += _ => OnFieldsChanged(); + body.AddChild(_enabledCheck); + _visibleCheck = new CheckBox { Text = ToolUi.Tr("tools/interactive_editor/visible") }; + _visibleCheck.Toggled += _ => OnFieldsChanged(); + body.AddChild(_visibleCheck); + + body.AddChild(ToolUi.Section("tools/interactive_editor/collider")); + _hitTypeSelect = ToolUi.Field(body, "tools/interactive_editor/label_collider_type", new OptionButton()); + foreach (var type in InteractiveEditorController.HitTypes) + _hitTypeSelect.AddItem(HitTypeLabel(type)); + _hitTypeSelect.ItemSelected += OnHitTypeChanged; + + _rectParams = new VBoxContainer(); + body.AddChild(_rectParams); + _rectX = ToolUi.Field(_rectParams, "tools/interactive_editor/label_x", ToolUi.Number(0, 1, 0.001, _ => OnGeometryTyped())); + _rectY = ToolUi.Field(_rectParams, "tools/interactive_editor/label_y", ToolUi.Number(0, 1, 0.001, _ => OnGeometryTyped())); + _rectW = ToolUi.Field(_rectParams, "tools/interactive_editor/label_w", ToolUi.Number(0.001, 1, 0.001, _ => OnGeometryTyped())); + _rectH = ToolUi.Field(_rectParams, "tools/interactive_editor/label_h", ToolUi.Number(0.001, 1, 0.001, _ => OnGeometryTyped())); + + _circleParams = new VBoxContainer(); + body.AddChild(_circleParams); + _circleX = ToolUi.Field(_circleParams, "tools/interactive_editor/label_cx", ToolUi.Number(0, 1, 0.001, _ => OnGeometryTyped())); + _circleY = ToolUi.Field(_circleParams, "tools/interactive_editor/label_cy", ToolUi.Number(0, 1, 0.001, _ => OnGeometryTyped())); + _circleR = ToolUi.Field(_circleParams, "tools/interactive_editor/label_r", ToolUi.Number(0.001, 1, 0.001, _ => OnGeometryTyped())); + + _polyParams = new VBoxContainer(); + body.AddChild(_polyParams); + _polyParams.AddChild(ToolUi.Hint(ToolUi.Tr("tools/interactive_editor/polygon_hint"))); + _vertexList = new VBoxContainer(); + _vertexList.AddThemeConstantOverride("separation", UiTokens.Space1); + _polyParams.AddChild(_vertexList); + + body.AddChild(ToolUi.Section("tools/interactive_editor/highlight")); + _highlightColor = ToolUi.Field(body, "tools/interactive_editor/label_highlight_color", new ColorPickerButton { CustomMinimumSize = new Vector2(0, 28) }); + _highlightColor.ColorChanged += _ => OnFieldsChanged(); + _highlightScale = ToolUi.Field(body, "tools/interactive_editor/label_highlight_scale", ToolUi.Number(0.5, 2, 0.01, _ => OnFieldsChanged())); + + body.AddChild(ToolUi.Section("tools/interactive_editor/actions")); + _clickScenario = ToolUi.Field(body, "tools/interactive_editor/label_onclick_scenario", ToolUi.Text("scenarios/…/file.scenario", _ => OnFieldsChanged())); + body.AddChild(UiKit.Label(ToolUi.Tr("tools/interactive_editor/label_onclick_commands"), UiTokens.FontBodySmall, UiTokens.Muted)); + _clickCommands = ToolUi.Code(); + _clickCommands.GuttersDrawLineNumbers = false; + _clickCommands.CustomMinimumSize = new Vector2(0, 70); + _clickCommands.SyntaxHighlighter = ScenarioSyntaxService.CreateHighlighter(); + _clickCommands.TextChanged += OnFieldsChanged; + body.AddChild(_clickCommands); + _hoverEnter = ToolUi.Field(body, "tools/interactive_editor/label_hover_enter", ToolUi.Text("", _ => OnFieldsChanged())); + _hoverLeave = ToolUi.Field(body, "tools/interactive_editor/label_hover_leave", ToolUi.Text("", _ => OnFieldsChanged())); + } + + // ── loading & saving ─────────────────────────────────────── + + private void LoadBackgrounds() + { + _backgroundIds.AddRange(ModResourceService.GetBackgroundIds()); + _bgList.SetItems(_backgroundIds); + } + + private void ShowBackground(string id) + { + if (string.IsNullOrEmpty(id)) + return; + + _bgList.Select(id); + _canvas.SetBackground(ModResourceService.LoadBackgroundTexture(id)); + } + + /// Shows the background of the scene that adds the selected object, when known. + private void ShowSceneBackground() + { + var id = _controller.Selected?.ID; + if (id == null) + return; + + _interactiveScenes ??= SceneBackgroundService.MapInteractivesToScenes(); + if (!_interactiveScenes.TryGetValue(id, out var scene)) + return; + + var background = SceneBackgroundService.ForScene(scene); + if (background == null || background == _bgList.SelectedId) + return; + + if (ModResourceService.LoadBackgroundTexture(background) == null) + SetStatus(string.Format(ToolUi.Tr("tools/common/background_missing"), background, scene), ToolStatusKind.Error); + else + ShowBackground(background); + } + + private void OnFileRequested(string modId, string path) + { + RunDiscardingChanges(() => Load(modId, path), () => _fileSelector.Show(_modId, _path)); + } + + private void Load(string modId, string path) + { + _modId = modId; + _path = path; + _controller.Clear(); + MarkClean(); + + if (modId == null) + SetStatus(ToolUi.Tr("tools/common/no_writable_mods"), ToolStatusKind.Error); + else if (path == null) + SetStatus(string.Format(ToolUi.Tr("tools/common/no_files"), modId)); + else + { + try + { + var text = ModFileService.ReadText(modId, path); + _controller.FromJson(text); + SetStatus(string.Format(ToolUi.Tr(string.IsNullOrEmpty(text) ? "tools/common/new_file_status" : "tools/common/loaded"), modId + "/" + path)); + } + catch (Exception ex) + { + SetStatus($"{ToolUi.Tr("tools/common/load_error")}: {ex.Message}", ToolStatusKind.Error); + GameLogger.Log("INTERACTIVE_EDITOR", $"load failed for '{modId}/{path}': {ex.Message}", LogLevel.Error); + } + } + + _fileSelector.Show(_modId, _path); + RefreshAll(); + ShowSceneBackground(); + } + + /// Ctrl+S. + protected override void OnSaveShortcut() => Save(); + + private void Save() + { + if (_path == null) + { + SetStatus(ToolUi.Tr("tools/common/no_file_to_save"), ToolStatusKind.Error); + return; + } + + var problem = Validate(); + if (problem != null) + { + SetStatus(problem, ToolStatusKind.Error); + return; + } + + if (!ModFileService.WriteText(_modId, _path, _controller.ToJson())) + { + SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error); + return; + } + + MarkClean(); + _fileSelector.Show(_modId, _path); + SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), _modId + "/" + _path), ToolStatusKind.Ok); + } + + /// First blocking problem of the file (empty/duplicate ID, polygon with < 3 vertices), or null. + private string Validate() + { + foreach (var item in _controller.Items) + { + if (string.IsNullOrWhiteSpace(item.ID)) + return ToolUi.Tr("tools/interactive_editor/id_empty"); + if (item.HitArea.Type == "polygon" && (item.HitArea.Vertices?.Length ?? 0) < 3) + return string.Format(ToolUi.Tr("tools/interactive_editor/polygon_too_small"), item.ID); + } + + var duplicate = _controller.Items.GroupBy(i => i.ID, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1); + return duplicate != null ? $"{ToolUi.Tr("tools/interactive_editor/duplicate_id")} {duplicate.Key}" : null; + } + + // ── list actions ─────────────────────────────────────────── + + private void OnItemChosen(string indexText) + { + if (_suppress || !int.TryParse(indexText, out var index)) + return; + + _controller.Select(index); + RefreshAll(); + ShowSceneBackground(); + } + + private void OnAdd() + { + if (_path == null) + { + SetStatus(ToolUi.Tr("tools/common/no_file_to_save"), ToolStatusKind.Error); + return; + } + + _controller.Add(); + Changed(); + RefreshAll(); + } + + private void OnDuplicate() + { + if (_controller.DuplicateSelected() == null) + return; + Changed(); + RefreshAll(); + } + + private void OnDelete() + { + var item = _controller.Selected; + if (item == null) + return; + + ShowConfirm("tools/interactive_editor/delete", "tools/interactive_editor/delete_confirm", () => + { + _controller.RemoveSelected(); + Changed(); + RefreshAll(); + SetStatus(string.Format(ToolUi.Tr("tools/interactive_editor/deleted"), item.ID)); + }); + } + + /// Delete removes the selected object (with confirmation). + protected override bool HandleKey(InputEventKey key) + { + if (key.Keycode != Key.Delete || _controller.Selected == null) + return false; + OnDelete(); + return true; + } + + // ── property editing ─────────────────────────────────────── + + private void OnIdChanged() + { + var item = _controller.Selected; + if (_suppress || item == null) + return; + + item.ID = _idEdit.Text.Trim(); + Changed(); + RefreshItemList(); + } + + private void OnFieldsChanged() + { + var item = _controller.Selected; + if (_suppress || item == null) + return; + + item.Texture = NullIfEmpty(_textureEdit.Text); + item.Layer = InteractiveEditorController.Layers[Math.Max(0, _layerSelect.Selected)]; + item.ZIndex = (int)_zIndex.Value; + item.Enabled = _enabledCheck.ButtonPressed; + item.Visible = _visibleCheck.ButtonPressed; + item.Cursor = _cursorSelect.Selected == 1 ? "PointingHand" : null; + item.Highlight.ColorHex = "#" + _highlightColor.Color.ToHtml(true); + item.Highlight.Scale = (float)_highlightScale.Value; + + var commands = _clickCommands.Text.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray(); + var scenario = NullIfEmpty(_clickScenario.Text); + item.Actions.OnClick = scenario != null || commands.Length > 0 + ? new ClickAction { Scenario = scenario, Commands = commands.Length > 0 ? commands : null } + : null; + item.Actions.OnHoverEnter = NullIfEmpty(_hoverEnter.Text); + item.Actions.OnHoverLeave = NullIfEmpty(_hoverLeave.Text); + Changed(); + } + + private void OnHitTypeChanged(long index) + { + var item = _controller.Selected; + if (_suppress || item == null) + return; + + InteractiveEditorController.SetHitType(item, InteractiveEditorController.HitTypes[index]); + Changed(); + RefreshAll(); + } + + private void OnGeometryTyped() + { + var hit = _controller.Selected?.HitArea; + if (_suppress || hit == null) + return; + + if (hit.Type == "circle") + { + hit.X = (float)_circleX.Value; + hit.Y = (float)_circleY.Value; + hit.Radius = (float)_circleR.Value; + } + else if (hit.Type == "rect") + { + hit.X = (float)_rectX.Value; + hit.Y = (float)_rectY.Value; + hit.Width = (float)_rectW.Value; + hit.Height = (float)_rectH.Value; + } + + Changed(); + _canvas.RebuildHandles(); + _canvas.QueueRedraw(); + } + + private void OnCanvasChanged() + { + Changed(); + _suppress = true; + WriteGeometry(_controller.Selected?.HitArea); + _suppress = false; + } + + private void Changed() + { + SetDirty(); + if (_jsonEdit.Visible) + _jsonEdit.Text = _controller.ToJson(); + } + + // ── refresh ──────────────────────────────────────────────── + + private void RefreshAll() + { + RefreshItemList(); + _suppress = true; + WriteSidebar(); + _suppress = false; + _jsonEdit.Text = _controller.ToJson(); + _canvas.RebuildHandles(); + _canvas.QueueRedraw(); + } + + private void RefreshItemList() + { + var items = _controller.Items + .Select((item, i) => (i.ToString(), (string.IsNullOrEmpty(item.ID) ? "?" : item.ID) + " · " + HitTypeLabel(item.HitArea.Type), (Texture2D)null)); + _itemList.SetItems(items); + _itemList.Select(_controller.SelectedIndex >= 0 ? _controller.SelectedIndex.ToString() : null); + } + + private void WriteSidebar() + { + var item = _controller.Selected; + _propsEmpty.Visible = item == null; + _propsBody.Visible = item != null; + if (item == null) + return; + + _idEdit.Text = item.ID ?? string.Empty; + _textureEdit.Text = item.Texture ?? string.Empty; + _layerSelect.Select(Math.Max(0, Array.IndexOf(InteractiveEditorController.Layers, item.Layer))); + _zIndex.Value = item.ZIndex; + _enabledCheck.ButtonPressed = item.Enabled; + _visibleCheck.ButtonPressed = item.Visible; + _cursorSelect.Select(string.IsNullOrEmpty(item.Cursor) ? 0 : 1); + _hitTypeSelect.Select(Math.Max(0, Array.IndexOf(InteractiveEditorController.HitTypes, item.HitArea.Type))); + _highlightColor.Color = item.Highlight.Color; + _highlightScale.Value = item.Highlight.Scale; + _clickScenario.Text = item.Actions.OnClick?.Scenario ?? string.Empty; + _clickCommands.Text = item.Actions.OnClick?.Commands != null ? string.Join("\n", item.Actions.OnClick.Commands) : string.Empty; + _hoverEnter.Text = item.Actions.OnHoverEnter ?? string.Empty; + _hoverLeave.Text = item.Actions.OnHoverLeave ?? string.Empty; + WriteGeometry(item.HitArea); + } + + private void WriteGeometry(HitAreaDefinition hit) + { + if (hit == null) + return; + + _rectParams.Visible = hit.Type == "rect"; + _circleParams.Visible = hit.Type == "circle"; + _polyParams.Visible = hit.Type == "polygon"; + + _rectX.Value = hit.X; + _rectY.Value = hit.Y; + _rectW.Value = hit.Width; + _rectH.Value = hit.Height; + _circleX.Value = hit.X; + _circleY.Value = hit.Y; + _circleR.Value = hit.Radius; + RefreshVertexList(hit); + } + + /// X/Y spin rows per polygon vertex; handlers read the vertex at change time. + private void RefreshVertexList(HitAreaDefinition hit) + { + var vertices = hit.Type == "polygon" ? hit.Vertices ?? Array.Empty() : Array.Empty(); + var rows = _vertexList.GetChildren(); + + // Reuse rows while dragging so focus and scroll are not lost on every motion. + if (rows.Count == vertices.Length) + { + for (var i = 0; i < vertices.Length; i++) + { + var row = rows[i]; + ((SpinBox)row.GetChild(1)).SetValueNoSignal(vertices[i].X); + ((SpinBox)row.GetChild(2)).SetValueNoSignal(vertices[i].Y); + } + return; + } + + foreach (var child in rows) + child.QueueFree(); + + for (var i = 0; i < vertices.Length; i++) + { + var index = i; + var row = new HBoxContainer(); + row.AddThemeConstantOverride("separation", UiTokens.Space1); + var number = UiKit.Label((i + 1).ToString(), UiTokens.FontSmall, UiTokens.Faint); + number.CustomMinimumSize = new Vector2(20, 0); + row.AddChild(number); + + var x = ToolUi.Number(0, 1, 0.001, null); + x.Value = vertices[i].X; + x.ValueChanged += v => OnVertexTyped(index, (float)v, null); + row.AddChild(x); + + var y = ToolUi.Number(0, 1, 0.001, null); + y.Value = vertices[i].Y; + y.ValueChanged += v => OnVertexTyped(index, null, (float)v); + row.AddChild(y); + + _vertexList.AddChild(row); + } + } + + private void OnVertexTyped(int index, float? x, float? y) + { + var hit = _controller.Selected?.HitArea; + if (_suppress || hit?.Vertices == null || index >= hit.Vertices.Length) + return; + + var v = hit.Vertices[index]; + hit.Vertices[index] = new Vector2(x ?? v.X, y ?? v.Y); + Changed(); + _canvas.RebuildHandles(); + _canvas.QueueRedraw(); + } + + private static string HitTypeLabel(string type) => ToolUi.Tr(type switch + { + "circle" => "tools/interactive_editor/hit_circle", + "polygon" => "tools/interactive_editor/hit_polygon", + _ => "tools/interactive_editor/hit_rect" + }); + + private static string NullIfEmpty(string text) + { + var trimmed = text?.Trim(); + return string.IsNullOrEmpty(trimmed) ? null : trimmed; + } + } } - diff --git a/src/LightEditor/LightEditorCanvas.cs b/src/LightEditor/LightEditorCanvas.cs new file mode 100644 index 0000000..9403298 --- /dev/null +++ b/src/LightEditor/LightEditorCanvas.cs @@ -0,0 +1,129 @@ +using System; +using Cthangover.Core.UI.Theming; +using Cthangover.Tools.Common; +using Godot; + +namespace Cthangover.Core.UI.Tool.LightEditor +{ + /// + /// Night-time preview of a scene with its static lights. The background is + /// drawn through the game's timed_sprite shader (set as this + /// control's material), so the preview lights the picture exactly like the + /// game; the lights' positions are pushed to the shader in this canvas' + /// coordinates and their radii scaled from the 1920-px game width to the + /// drawn background width. Radius rings and handles live in a separate + /// child overlay — anything drawn by this control itself would be shaded. + /// + /// A click on a handle selects that light and drags it; a double-click on + /// empty background adds a light there. + /// + public partial class LightEditorCanvas : BackgroundCanvas + { + private readonly Control _overlay; + + /// Data being edited; set once by the window. + public LightEditorController Controller; + + /// Preview material (may be null when the shader is missing). + public ShaderMaterial PreviewMaterial + { + get => Material as ShaderMaterial; + set => Material = value; + } + + /// Raised when a light moved or was added on the canvas. + public event Action Changed; + + /// Raised when the selection changed on the canvas. + public event Action SelectionChanged; + + public LightEditorCanvas() + { + _overlay = new Control { MouseFilter = MouseFilterEnum.Ignore }; + AddChild(_overlay); + _overlay.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + _overlay.Connect("draw", Callable.From((Action)DrawRings)); + GuiInput += OnGuiInput; + } + + /// One handle per light, coloured like the light. + public override void RebuildHandles() + { + ClearHandles(); + if (Controller == null || GetBackgroundRect().Size.X <= 0f) + return; + + for (var i = 0; i < Controller.Lights.Count; i++) + { + var index = i; + var light = Controller.Lights[i]; + var handle = AddHandle(new Vector2(light.X, light.Y), light.ToColor(), i, p => + { + light.X = p.X; + light.Y = p.Y; + Refresh(); + Changed?.Invoke(); + }, index == Controller.SelectedIndex ? 20f : 16f); + handle.Clicked = () => SelectLight(index); + } + + Refresh(); + } + + /// Redraws the preview and re-uploads the shader uniforms. + public void Refresh() + { + QueueRedraw(); + _overlay.QueueRedraw(); + } + + private void SelectLight(int index) + { + if (index == Controller.SelectedIndex) + return; + + Controller.Select(index); + SelectionChanged?.Invoke(); + CallDeferred(nameof(RebuildHandles)); + } + + private void OnGuiInput(InputEvent @event) + { + if (Controller == null || @event is not InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left } mb) + return; + + if (mb.DoubleClick && Controller.Add(Clamp01(LocalToNorm(mb.Position))) != null) + { + RebuildHandles(); + Changed?.Invoke(); + SelectionChanged?.Invoke(); + } + + AcceptEvent(); + } + + protected override void DrawContent(Rect2 rect) + { + // The shader compares against world_pos (MODEL_MATRIX * VERTEX), i.e. + // canvas coordinates of the window, not this control's local ones. + Controller?.UpdatePreview(PreviewMaterial, n => GlobalPosition + NormToLocal(n), rect.Size.X / LightEditorController.GameWidth); + } + + private void DrawRings() + { + var rect = GetBackgroundRect(); + if (Controller == null || rect.Size.X <= 0f) + return; + + var scale = rect.Size.X / LightEditorController.GameWidth; + for (var i = 0; i < Controller.Lights.Count; i++) + { + var light = Controller.Lights[i]; + var selected = i == Controller.SelectedIndex; + var centre = NormToLocal(new Vector2(light.X, light.Y)); + var color = selected ? UiTokens.AccentBright : UiTokens.Alpha(light.ToColor(), 0.45f); + _overlay.DrawArc(centre, light.Radius * scale, 0f, Mathf.Tau, 64, color, selected ? 1.5f : 1f, true); + } + } + } +} diff --git a/src/LightEditor/LightEditorController.cs b/src/LightEditor/LightEditorController.cs index dca472a..6835508 100644 --- a/src/LightEditor/LightEditorController.cs +++ b/src/LightEditor/LightEditorController.cs @@ -1,176 +1,202 @@ +using System; using System.Collections.Generic; using System.Text.Json; -using Cthangover.Core.Mods; using Cthangover.Core.Mods.Resolvers; -using Cthangover.Mods.Time; using Cthangover.Core.UI.Theming; +using Cthangover.Core.Utils; +using Cthangover.Mods.Time; using Godot; namespace Cthangover.Core.UI.Tool.LightEditor { /// - /// Data controller for the light editor tool. Maintains a list of - /// objects and feeds up to 10 lights plus one - /// sentinel entry into the "timed_sprite" shader material for - /// real-time preview. Exports lights as JSON () - /// or as a scenario DSL snippet (). - /// Uses a dirty-flag pattern so shader uniform updates only occur when - /// the light list changes. + /// Data of the light editor: the static lights of one scenario and their + /// place in the scenario text. Static lights live in a light_set + /// line (light_set [{"x":…,"y":…,"radius":…,"influence":…,"color":…}]) + /// that UiLightController.SetStaticLights reads at runtime: x/y are + /// fractions of the viewport, radius is in viewport pixels at the game + /// resolution. The controller parses that line, edits the list and writes + /// the line back (or inserts one right after the scenario header), and + /// feeds the timed_sprite shader of the preview the same way the + /// game does, so the preview shows the real night lighting. /// - public class LightEditorController - { - /// The editable list of light definitions. Capped at 10 for shader preview. - public List Lights { get; } = new(); + public class LightEditorController + { + /// Static light slots the shader has (slot 0 is the dynamic light). + public const int MaxLights = 10; - 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]; + /// Viewport width the stored radii refer to. + public const float GameWidth = 1920f; - private bool dirty = true; + private const string Command = "light_set"; + private const int ShaderSlots = 11; + private static readonly Vector2 Offscreen = new(-1000f, -1000f); - /// Initialises preview arrays with out-of-bounds sentinel values. - public LightEditorController() - { - for (int i = 0; i < 11; i++) - { - previewPositions[i] = new Vector2(-1000f, -1000f); - previewRadii[i] = 0f; - previewInfluence[i] = 0f; - previewColors[i] = UiTokens.White; - } - } + /// The edited lights, in file order. + public List Lights { get; private set; } = new(); + + /// Index of the selected light, or -1. + public int SelectedIndex { get; private set; } = -1; + + /// The selected light, or null. + public LightDef Selected => SelectedIndex >= 0 && SelectedIndex < Lights.Count ? Lights[SelectedIndex] : null; + + /// Selects by index; out of range clears the selection. + public void Select(int index) => SelectedIndex = index >= 0 && index < Lights.Count ? index : -1; + + /// Adds a warm light at the given normalised point and selects it; null when all slots are used. + public LightDef Add(Vector2 position) + { + if (Lights.Count >= MaxLights) + return null; + + var light = new LightDef { X = position.X, Y = position.Y, Radius = 400f, Influence = 1f, ColorHex = "#ffcc66" }; + Lights.Add(light); + SelectedIndex = Lights.Count - 1; + return light; + } + + /// Removes the selected light. + public void RemoveSelected() + { + if (Selected == null) + return; + Lights.RemoveAt(SelectedIndex); + SelectedIndex = Math.Min(SelectedIndex, Lights.Count - 1); + } + + /// Compact JSON array exactly as written into the light_set line. + public string ToJson() => JsonSerializer.Serialize(Lights, JsonOptionsProvider.WriteCompactOptions); + + /// The scenario line for the current lights. + public string ToScenarioLine() => Command + " " + ToJson(); /// - /// Creates a using the "timed_sprite" shader - /// with night-time weights applied. Returns null if the shader is not found. + /// Loads the lights of the first light_set line of a scenario. + /// Returns false when the scenario has none (the list is then + /// empty and saving inserts a new line). Throws on malformed JSON. /// - public ShaderMaterial CreatePreviewMaterial() - { - var shader = Shaders.Resolve("timed_sprite"); - if (shader == null) - return null; + public bool LoadFromScenario(string text) + { + Lights = new List(); + SelectedIndex = -1; - 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; - } + var index = FindLine(text, out var lines); + if (index < 0) + return false; - /// Sets the background texture uniform on the preview shader material. - public void SetBackgroundForPreview(Texture2D texture, ShaderMaterial material) - { - if (material != null) - material.SetShaderParameter("background_map", texture); - } - - /// Sets the depth mask texture uniform on the preview shader material. - public void SetDepthForPreview(Texture2D texture, ShaderMaterial material) - { - if (material != null) - material.SetShaderParameter("depth_mask", texture); - } - - /// Sets the albedo texture uniform on the preview shader material. - public void SetAlbedoForPreview(Texture2D texture, ShaderMaterial material) - { - if (material != null) - material.SetShaderParameter("albedo_map", texture); - } - - /// Appends a to the list and marks the preview dirty. - public void AddLight(LightDef light) - { - Lights.Add(light); - dirty = true; - } - - /// Removes the light at . No-op if out of range. - public void RemoveLight(int index) - { - if (index < 0 || index >= Lights.Count) - return; - Lights.RemoveAt(index); - dirty = true; - } - - /// Removes all lights from the list and marks dirty. - public void Clear() - { - Lights.Clear(); - dirty = true; - } - - /// Forces the preview to be recalculated on the next call. - public void MarkDirty() - { - dirty = true; - } + var json = ArgumentOf(lines[index]); + Lights = JsonSerializer.Deserialize>(json, JsonOptionsProvider.ReadOptions) ?? new List(); + SelectedIndex = Lights.Count > 0 ? 0 : -1; + return true; + } /// - /// Packs up to 10 lights plus a sentinel into shader uniform arrays. - /// Only executes if dirty is true. Converts each light's - /// normalised position to pixel coordinates relative to the preview area. + /// Returns the scenario text with its light_set line replaced by + /// the current lights, or — when it has none — with a new line inserted + /// right after the --- header separator (at the top otherwise). /// - public void UpdatePreview(ShaderMaterial material, Vector2 previewGlobalPos, Vector2 previewSize) - { - if (material == null || !dirty) - return; + public string WriteToScenario(string text) + { + var index = FindLine(text, out var lines); + var newline = text.Contains("\r\n") ? "\r\n" : "\n"; + if (index >= 0) + { + var indent = lines[index].Substring(0, lines[index].Length - lines[index].TrimStart().Length); + lines[index] = indent + ToScenarioLine(); + } + else + { + var separator = lines.FindIndex(l => l.Trim() == "---"); + lines.Insert(separator + 1, ToScenarioLine()); + } - for (int i = 0; i < 11; i++) - { - previewPositions[i] = new Vector2(-1000f, -1000f); - previewRadii[i] = 0f; - previewInfluence[i] = 0f; - previewColors[i] = UiTokens.White; - } + return string.Join(newline, lines); + } - 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(); - } + /// True when the scenario text contains a light_set line. + public static bool HasLights(string text) => FindLine(text, out _) >= 0; - var posArray = new Godot.Collections.Array(); - var colArray = new Godot.Collections.Array(); - var radArray = new Godot.Collections.Array(); - var infArray = new Godot.Collections.Array(); - for (int i = 0; i < 11; i++) - { - posArray.Add(previewPositions[i]); - colArray.Add(previewColors[i]); - radArray.Add(previewRadii[i]); - infArray.Add(previewInfluence[i]); - } + private static int FindLine(string text, out List lines) + { + lines = new List((text ?? string.Empty).Replace("\r\n", "\n").Split('\n')); + return lines.FindIndex(l => l.TrimStart().StartsWith(Command + " ", StringComparison.Ordinal)); + } - 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); + /// Argument of a light_set line; the old quoted form ("[{\"x\":…}]") is unescaped. + private static string ArgumentOf(string line) + { + var arg = line.Trim().Substring(Command.Length).Trim(); + if (arg.Length >= 2 && arg[0] == '"' && arg[arg.Length - 1] == '"') + arg = arg.Substring(1, arg.Length - 2).Replace("\\\"", "\""); + return arg; + } - dirty = false; - } + // ── shader preview ───────────────────────────────────────── - /// Serialises the light list as a compact JSON string. - public string ExportLightsJson() - { - return JsonSerializer.Serialize(Lights, Cthangover.Core.Utils.JsonOptionsProvider.WriteCompactOptions); - } + /// + /// Material of the preview: the game's timed_sprite shader at + /// night weights. null when the shader is not installed. + /// + public static ShaderMaterial CreatePreviewMaterial() + { + var shader = Shaders.Resolve("timed_sprite"); + if (shader == null) + return null; - /// Produces a light_set scenario DSL command with the lights JSON as its argument. - public string ExportScenarioSnippet() - { - var json = ExportLightsJson(); - return $"light_set \"{json.Replace("\"", "\\\"")}\""; - } - } + var material = new ShaderMaterial { Shader = shader }; + material.SetShaderParameter("time_weights", new Vector4(0, 0, 0, 1)); + material.SetShaderParameter("use_time", true); + return material; + } + + /// Binds the background and its optional depth/albedo masks to the preview material. + public static void SetPreviewTextures(ShaderMaterial material, Texture2D depth, Texture2D albedo) + { + if (material == null) + return; + material.SetShaderParameter("depth_mask", depth); + material.SetShaderParameter("albedo_map", albedo); + } + + /// + /// Uploads the lights into the shader uniforms. + /// maps a normalised position into the preview's canvas coordinates + /// (the space of the shader's world_pos); + /// shrinks game-resolution radii to the preview size. + /// + public void UpdatePreview(ShaderMaterial material, Func toCanvas, float radiusScale) + { + if (material == null) + return; + + var positions = new Godot.Collections.Array(); + var colors = new Godot.Collections.Array(); + var radii = new Godot.Collections.Array(); + var influence = new Godot.Collections.Array(); + + // Slot 0 is the dynamic (cursor) light at runtime; keep it off. + positions.Add(Offscreen); + colors.Add(UiTokens.White); + radii.Add(0f); + influence.Add(0f); + + var count = Math.Min(Lights.Count, MaxLights); + for (var i = 0; i < ShaderSlots - 1; i++) + { + var on = i < count; + positions.Add(on ? toCanvas(new Vector2(Lights[i].X, Lights[i].Y)) : Offscreen); + colors.Add(on ? Lights[i].ToColor() : UiTokens.White); + radii.Add(on ? Lights[i].Radius : 0f); + influence.Add(on ? Lights[i].Influence : 0f); + } + + material.SetShaderParameter("light_count", count + 1); + material.SetShaderParameter("light_positions", positions); + material.SetShaderParameter("light_colors", colors); + material.SetShaderParameter("light_radii", radii); + material.SetShaderParameter("light_influence", influence); + material.SetShaderParameter("light_radius_scale", radiusScale); + } + } } diff --git a/src/LightEditor/LightEditorHandle.cs b/src/LightEditor/LightEditorHandle.cs deleted file mode 100644 index 97752dd..0000000 --- a/src/LightEditor/LightEditorHandle.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Cthangover.Mods.Time; -using Godot; - -namespace Cthangover.Core.UI.Tool.LightEditor -{ - /// - /// Draggable 24×24 coloured handle representing a light source on the preview. - /// Overrides _GuiInput (Godot virtual) for mouse drag support; the colour - /// is taken from . Raises - /// with the handle's centre position in global coordinates for the window to - /// convert back to normalised coordinates. - /// - public partial class LightEditorHandle : ColorRect - { - /// Index of the light in . - public int LightIndex; - /// Invoked during drag with the centre position in global coordinates. - public System.Action DragUpdate; - /// Invoked when the handle is clicked (mouse down). - public System.Action Clicked; - - private bool dragging; - private Vector2 dragOffset; - - /// Creates a 24×24 handle coloured to match the light's value. - public LightEditorHandle(LightDef light, int index) - { - LightIndex = index; - Size = new Vector2(24, 24); - Color = light.ToColor(); - MouseFilter = MouseFilterEnum.Stop; - } - - /// Handles mouse drag and click via Godot's virtual input system (_GuiInput). - public override void _GuiInput(InputEvent @event) - { - if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left) - { - if (mb.Pressed) - { - dragging = true; - dragOffset = GetGlobalMousePosition() - GlobalPosition; - Clicked?.Invoke(); - } - else - { - dragging = false; - } - AcceptEvent(); - } - else if (@event is InputEventMouseMotion && dragging) - { - GlobalPosition = GetGlobalMousePosition() - dragOffset; - DragUpdate?.Invoke(GlobalPosition + Size / 2); - } - } - } -} diff --git a/src/LightEditor/LightEditorWindow.cs b/src/LightEditor/LightEditorWindow.cs index 70644e1..5ef9eb2 100644 --- a/src/LightEditor/LightEditorWindow.cs +++ b/src/LightEditor/LightEditorWindow.cs @@ -1,380 +1,386 @@ using System; using System.Collections.Generic; -using System.Text.Json; +using System.Linq; using Cthangover.Core.Factories.Impls; -using Cthangover.Mods.Time; using Cthangover.Core.UI.Theming; using Cthangover.Core.Utils; +using Cthangover.Mods.Interface.Theming; +using Cthangover.Mods.Interface.Tool; +using Cthangover.Tools.Common; using Cthangover.Tools.Services; using Godot; -using Cthangover.Mods.Interface.Tool; namespace Cthangover.Core.UI.Tool.LightEditor { /// - /// Editor window for placing and configuring static lights on a background. - /// Provides a sidebar with background selection, a light list with add/delete/select, - /// per-light property editors (radius, influence, colour), draggable handles on a - /// shader-powered preview, and JSON export. Uses - /// for data management and for background textures. + /// Editor of the static lights of a scene. The author picks a scenario — + /// the ones that already set lights come first — and edits its + /// light_set line visually: drag lights on a night-time preview of + /// the scene background, double-click to add one, tune radius, influence + /// and colour. Saving rewrites that line in the scenario (or inserts it + /// after the header). The background follows the scenario's scene. /// public partial class LightEditorWindow : ToolWindow - { - private LightEditorController controller; + { + private readonly LightEditorController _controller = new(); + private readonly List _scenarios = new(); + private readonly List _backgroundIds = new(); - private ItemList backgroundList; - private VBoxContainer lightListPanel; - private Button addLightBtn; - private TextureRect preview; - private ShaderMaterial previewMaterial; + private ScenarioFileInfo _scenario; + private string _scenarioText; + private bool _suppress; - private LineEdit radiusEdit; - private LineEdit influenceEdit; - private ColorPickerButton colorPicker; - private TextEdit jsonEdit; + private OptionButton _scenarioSelect; + private LightEditorCanvas _canvas; + private ItemList _lightList; + private Button _addBtn; + private Button _deleteBtn; + private VBoxContainer _propsBody; + private Label _propsEmpty; + private SpinBox _x; + private SpinBox _y; + private SpinBox _radius; + private SpinBox _influence; + private ColorPickerButton _color; + private FilteredList _bgList; + private CodeEdit _lineEdit; - private readonly List handles = new(); - private int selectedIndex = -1; - - private readonly List bgIds = new(); - - /// Constructs the window, creates the controller, builds UI, and loads the background list. public LightEditorWindow() : base("tools/light_editor/title") - { - controller = new LightEditorController(); - CthangoverTheme.Apply(this); - BuildUI(); - LoadBackgroundList(); - } + { + CthangoverTheme.Apply(this); + BuildUI(); + LoadBackgrounds(); + LoadScenarioList(); + SetHint(ToolUi.Tr("tools/light_editor/hint")); + } - protected override void Cleanup() - { - foreach (var h in handles) - h.QueueFree(); - handles.Clear(); - } + // ── UI construction ──────────────────────────────────────── - private Vector2 PreviewSize() - { - var sz = preview.Size; - if (sz.X <= 0 || sz.Y <= 0) - sz = new Vector2(1920, 1080); - return sz; - } + private void BuildUI() + { + var outer = CreateFillContainer(); + AddChild(outer); - private void RefreshAll() - { - RepositionHandles(); - controller.MarkDirty(); - controller.UpdatePreview(previewMaterial, preview.GlobalPosition, PreviewSize()); - UpdateLightList(); - UpdateJsonEdit(); - } + var toolbar = CreateToolbar(); + outer.AddChild(toolbar); + _scenarioSelect = new OptionButton { CustomMinimumSize = new Vector2(420, 0), TooltipText = ToolUi.Tr("tools/light_editor/scenario_tip") }; + _scenarioSelect.ItemSelected += OnScenarioPicked; + toolbar.AddChild(_scenarioSelect); + toolbar.AddChild(ToolUi.Button("tools/common/save", Save, ButtonKind.Primary, "tools/light_editor/save_tip")); + toolbar.AddChild(ToolUi.Button("tools/light_editor/copy_line", CopyLine, ButtonKind.Secondary, "tools/light_editor/copy_line_tip")); + toolbar.AddChild(UiKit.Spacer()); + var lineToggle = ToolUi.Button("tools/light_editor/show_line", null, ButtonKind.Tab); + lineToggle.ToggleMode = true; + lineToggle.Toggled += on => _lineEdit.Visible = on; + toolbar.AddChild(lineToggle); - private void BuildUI() - { - var outerVBox = CreateFillContainer(); - AddChild(outerVBox); + var main = new HSplitContainer + { + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill + }; + outer.AddChild(main); - var mainHBox = new HBoxContainer(); - mainHBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; - mainHBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill; - outerVBox.AddChild(mainHBox); + var side = new VBoxContainer(); + main.AddChild(CreateSidebar(side, 300)); + BuildSidebar(side); - var sidebarVBox = new VBoxContainer(); - var sidebar = CreateSidebar(sidebarVBox); - mainHBox.AddChild(sidebar); + var canvasColumn = new VSplitContainer + { + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill + }; + main.AddChild(canvasColumn); - sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/background") }); + var canvasPanel = new PanelContainer + { + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill + }; + canvasPanel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line)); + canvasColumn.AddChild(canvasPanel); - backgroundList = new ItemList(); - backgroundList.CustomMinimumSize = new Vector2(0, 140); - backgroundList.ItemSelected += OnBackgroundSelected; - sidebarVBox.AddChild(backgroundList); + _canvas = new LightEditorCanvas + { + Controller = _controller, + PreviewMaterial = LightEditorController.CreatePreviewMaterial() + }; + _canvas.Changed += OnCanvasChanged; + _canvas.SelectionChanged += RefreshAll; + canvasPanel.AddChild(_canvas); - sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/static_lights") }); + _lineEdit = ToolUi.Code(editable: false); + _lineEdit.CustomMinimumSize = new Vector2(0, 70); + _lineEdit.WrapMode = TextEdit.LineWrappingMode.Boundary; + _lineEdit.Visible = false; + canvasColumn.AddChild(_lineEdit); + } - lightListPanel = new VBoxContainer(); - sidebarVBox.AddChild(lightListPanel); + private void BuildSidebar(VBoxContainer column) + { + column.AddChild(ToolUi.Section("tools/light_editor/static_lights")); + _lightList = new ItemList { CustomMinimumSize = new Vector2(0, 150) }; + _lightList.ItemSelected += index => { _controller.Select((int)index); RefreshAll(); }; + column.AddChild(_lightList); - addLightBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/add_light") }; - addLightBtn.Pressed += OnAddLight; - sidebarVBox.AddChild(addLightBtn); + var row = new HBoxContainer(); + row.AddThemeConstantOverride("separation", UiTokens.Space1); + column.AddChild(row); + _addBtn = ToolUi.Button("tools/light_editor/add_light", OnAdd); + _addBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + row.AddChild(_addBtn); + _deleteBtn = ToolUi.Button("tools/light_editor/delete", OnDelete, ButtonKind.Danger); + _deleteBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + row.AddChild(_deleteBtn); - sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/selected_props") }); + column.AddChild(ToolUi.Section("tools/light_editor/selected_props")); + _propsEmpty = ToolUi.Hint(ToolUi.Tr("tools/light_editor/none_selected")); + column.AddChild(_propsEmpty); + _propsBody = new VBoxContainer(); + _propsBody.AddThemeConstantOverride("separation", UiTokens.Space2); + column.AddChild(_propsBody); - 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); + _x = ToolUi.Field(_propsBody, "tools/light_editor/x", ToolUi.Number(0, 1, 0.001, _ => OnFieldsChanged())); + _y = ToolUi.Field(_propsBody, "tools/light_editor/y", ToolUi.Number(0, 1, 0.001, _ => OnFieldsChanged())); + _radius = ToolUi.Field(_propsBody, "tools/light_editor/radius", ToolUi.Number(10, 3000, 10, _ => OnFieldsChanged()), "tools/light_editor/radius_tip"); + _influence = ToolUi.Field(_propsBody, "tools/light_editor/influence", ToolUi.Number(0, 1, 0.05, _ => OnFieldsChanged()), "tools/light_editor/influence_tip"); + _color = ToolUi.Field(_propsBody, "tools/light_editor/color", new ColorPickerButton { CustomMinimumSize = new Vector2(0, 28), EditAlpha = false }); + _color.ColorChanged += _ => OnFieldsChanged(); - 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); + column.AddChild(ToolUi.Section("tools/light_editor/background")); + _bgList = new FilteredList(180); + _bgList.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + _bgList.Chosen += ShowBackground; + column.AddChild(_bgList); + } - 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); + // ── loading ──────────────────────────────────────────────── - var importBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/import_json") }; - importBtn.Pressed += OnImport; - sidebarVBox.AddChild(importBtn); + private void LoadBackgrounds() + { + _backgroundIds.AddRange(ModResourceService.GetBackgroundIds()); + _bgList.SetItems(_backgroundIds); + } - sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/norm_coords") }); + /// Scenarios with a light_set line first (marked ●), then all others. + private void LoadScenarioList() + { + var withLights = new List(); + var others = new List(); + foreach (var file in ScenarioFileService.ScanScenarioFiles().SelectMany(m => m.Groups).SelectMany(g => g.Files)) + { + var text = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath); + (text != null && LightEditorController.HasLights(text) ? withLights : others).Add(file); + } - var previewContainer = new PanelContainer(); - previewContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; - previewContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill; - mainHBox.AddChild(previewContainer); + _scenarios.Clear(); + _scenarios.AddRange(withLights); + _scenarios.AddRange(others); - 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); + _scenarioSelect.Clear(); + _scenarioSelect.AddItem(ToolUi.Tr("tools/light_editor/pick_scenario")); + foreach (var file in _scenarios) + _scenarioSelect.AddItem((withLights.Contains(file) ? "● " : " ") + file.ModId + ": " + file.RelativePath); + _scenarioSelect.Select(0); - previewMaterial = controller.CreatePreviewMaterial(); - preview.Material = previewMaterial; + if (withLights.Count > 0) + { + _scenarioSelect.Select(1); + OpenScenario(withLights[0]); + } + else + { + RefreshAll(); + } + } - 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 OnScenarioPicked(long index) + { + var file = index > 0 && index - 1 < _scenarios.Count ? _scenarios[(int)index - 1] : null; + RunDiscardingChanges(() => OpenScenario(file), () => _scenarioSelect.Select(_scenario == null ? 0 : _scenarios.IndexOf(_scenario) + 1)); + } - private void OnJsonEditChanged() - { - } + private void OpenScenario(ScenarioFileInfo file) + { + _scenario = file; + _scenarioText = null; + MarkClean(); - private void UpdateJsonEdit() - { - var json = controller.ExportLightsJson(); - if (jsonEdit.Text != json) - jsonEdit.Text = json; - } + if (file != null) + { + _scenarioText = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath) ?? string.Empty; + try + { + var found = _controller.LoadFromScenario(_scenarioText); + SetStatus(string.Format(ToolUi.Tr(found ? "tools/light_editor/loaded" : "tools/light_editor/no_lights_yet"), file.RelativePath)); + } + catch (Exception ex) + { + SetStatus($"{ToolUi.Tr("tools/common/load_error")}: {ex.Message}", ToolStatusKind.Error); + GameLogger.Log("LIGHT_EDITOR", $"light_set parse failed in '{file.Key}': {ex.Message}", LogLevel.Error); + } - private void LoadBackgroundList() - { - bgIds.Clear(); - backgroundList.Clear(); + var scene = ScenarioFileService.ParseMetadata(_scenarioText).Scene; + var background = SceneBackgroundService.ForScene(scene); + if (background != null && ModResourceService.LoadBackgroundTexture(background) == null) + SetStatus(string.Format(ToolUi.Tr("tools/common/background_missing"), background, scene), ToolStatusKind.Error); + else if (background != null) + ShowBackground(background); + } + else + { + _controller.LoadFromScenario(string.Empty); + } - var ids = ModResourceService.GetBackgroundIds(); - bgIds.AddRange(ids); - foreach (var id in ids) - backgroundList.AddItem(id); - } + RefreshAll(); + } - private void OnBackgroundSelected(long index) - { - var bgId = bgIds[(int)index]; - var tex = BackgroundFactory.Instance.Get(bgId); - preview.Texture = tex; - controller.SetBackgroundForPreview(tex, previewMaterial); + /// Shows a background with its optional _depth/_albedo masks in the shader. + private void ShowBackground(string id) + { + if (string.IsNullOrEmpty(id)) + return; - var depthTex = BackgroundFactory.Instance.Get(bgId + "_depth"); - if (depthTex != null) - controller.SetDepthForPreview(depthTex, previewMaterial); + _bgList.Select(id); + _canvas.SetBackground(ModResourceService.LoadBackgroundTexture(id)); + LightEditorController.SetPreviewTextures(_canvas.PreviewMaterial, + ModResourceService.LoadBackgroundTexture(id + "_depth"), + ModResourceService.LoadBackgroundTexture(id + "_albedo")); + _canvas.Refresh(); + } - var albedoTex = BackgroundFactory.Instance.Get(bgId + "_albedo"); - if (albedoTex != null) - controller.SetAlbedoForPreview(albedoTex, previewMaterial); - } + // ── saving ───────────────────────────────────────────────── - private void OnAddLight() - { - if (handles.Count >= 10) - return; + /// Ctrl+S. + protected override void OnSaveShortcut() => Save(); - var light = new LightDef - { - X = 0.4f, - Y = 0.3f, - Radius = 300f, - Influence = 1f, - ColorHex = "#ffff00" - }; + private void Save() + { + if (_scenario == null) + { + SetStatus(ToolUi.Tr("tools/light_editor/pick_scenario"), ToolStatusKind.Error); + return; + } - controller.AddLight(light); - AddHandle(light, controller.Lights.Count - 1); - RefreshAll(); - } + var updated = _controller.WriteToScenario(_scenarioText); + if (!ScenarioFileService.WriteAllText(_scenario.ModId, _scenario.RelativePath, updated)) + { + SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error); + return; + } - 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); - } + _scenarioText = updated; + MarkClean(); + SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), _scenario.ModId + "/" + _scenario.RelativePath), ToolStatusKind.Ok); + } - 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 CopyLine() + { + DisplayServer.ClipboardSet(_controller.ToScenarioLine()); + SetStatus(ToolUi.Tr("tools/light_editor/copied"), ToolStatusKind.Ok); + } - private void RepositionHandles() - { - for (int i = 0; i < handles.Count && i < controller.Lights.Count; i++) - RepositionHandle(handles[i], controller.Lights[i]); - } + // ── editing ──────────────────────────────────────────────── - 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; - } + private void OnAdd() + { + if (_controller.Add(new Vector2(0.5f, 0.5f)) == null) + { + SetStatus(string.Format(ToolUi.Tr("tools/light_editor/limit"), LightEditorController.MaxLights), ToolStatusKind.Error); + return; + } - var light = controller.Lights[index]; - radiusEdit.Text = light.Radius.ToString("F0"); - influenceEdit.Text = light.Influence.ToString("F2"); - colorPicker.Color = light.ToColor(); - } + SetDirty(); + RefreshAll(); + } - 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 OnDelete() + { + if (_controller.Selected == null) + return; - 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(); - } - } + _controller.RemoveSelected(); + SetDirty(); + RefreshAll(); + } - private void OnColorChanged(Color color) - { - if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count) - return; - controller.Lights[selectedIndex].ColorHex = color.ToHtml(); - RefreshAll(); - } + /// Delete removes the selected light. + protected override bool HandleKey(InputEventKey key) + { + if (key.Keycode != Key.Delete || _controller.Selected == null) + return false; + OnDelete(); + return true; + } - private void UpdateLightList() - { - foreach (var child in lightListPanel.GetChildren()) - child.QueueFree(); + private void OnFieldsChanged() + { + var light = _controller.Selected; + if (_suppress || light == null) + return; - 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); + light.X = (float)_x.Value; + light.Y = (float)_y.Value; + light.Radius = (float)_radius.Value; + light.Influence = (float)_influence.Value; + light.ColorHex = "#" + _color.Color.ToHtml(false); + SetDirty(); + RefreshList(); + _lineEdit.Text = _controller.ToScenarioLine(); + _canvas.RebuildHandles(); + } - var selBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/btn_select") }; - int idx = i; - selBtn.Pressed += () => SelectHandle(idx); - hBox.AddChild(selBtn); + private void OnCanvasChanged() + { + SetDirty(); + _suppress = true; + WriteProperties(); + _suppress = false; + RefreshList(); + _lineEdit.Text = _controller.ToScenarioLine(); + } - var delBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/btn_delete") }; - delBtn.Pressed += () => RemoveLight(idx); - hBox.AddChild(delBtn); + // ── refresh ──────────────────────────────────────────────── - lightListPanel.AddChild(hBox); - } + private void RefreshAll() + { + RefreshList(); + _suppress = true; + WriteProperties(); + _suppress = false; + _lineEdit.Text = _controller.ToScenarioLine(); + _addBtn.Disabled = _controller.Lights.Count >= LightEditorController.MaxLights; + _deleteBtn.Disabled = _controller.Selected == null; + _canvas.RebuildHandles(); + } - addLightBtn.Disabled = controller.Lights.Count >= 10; - } + private void RefreshList() + { + _lightList.Clear(); + for (var i = 0; i < _controller.Lights.Count; i++) + { + var light = _controller.Lights[i]; + var index = _lightList.AddItem($"● {i + 1}. {light.X:0.000}, {light.Y:0.000} r {light.Radius:0}"); + _lightList.SetItemCustomFgColor(index, light.ToColor()); + } - private void RemoveLight(int index) - { - if (index < 0 || index >= controller.Lights.Count) - return; + if (_controller.SelectedIndex >= 0) + _lightList.Select(_controller.SelectedIndex); + } - controller.RemoveLight(index); + private void WriteProperties() + { + var light = _controller.Selected; + _propsEmpty.Visible = light == null; + _propsBody.Visible = light != null; + if (light == null) + return; - if (index < handles.Count) - { - handles[index].QueueFree(); - handles.RemoveAt(index); - } - - for (int i = 0; i < handles.Count; i++) - handles[i].LightIndex = i; - - if (selectedIndex == index) - selectedIndex = -1; - else if (selectedIndex > index) - selectedIndex--; - - RefreshAll(); - } - - private void OnImport() - { - var json = jsonEdit.Text; - if (string.IsNullOrWhiteSpace(json)) - return; - - try - { - var lights = JsonSerializer.Deserialize>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions); - if (lights == null || lights.Count == 0) - return; - - foreach (var h in handles) - h.QueueFree(); - handles.Clear(); - controller.Clear(); - - foreach (var light in lights) - { - controller.AddLight(light); - AddHandle(light, controller.Lights.Count - 1); - } - - selectedIndex = -1; - RefreshAll(); - } - catch (Exception ex) - { - GameLogger.Log("LIGHT_EDITOR", $"Import JSON failed: {ex.Message}", LogLevel.Error); - } - } - } + _x.Value = light.X; + _y.Value = light.Y; + _radius.Value = light.Radius; + _influence.Value = light.Influence; + _color.Color = light.ToColor(); + } + } } diff --git a/src/MapEditor/MapEditorCanvas.cs b/src/MapEditor/MapEditorCanvas.cs index c3b06c3..5b9e5dc 100644 --- a/src/MapEditor/MapEditorCanvas.cs +++ b/src/MapEditor/MapEditorCanvas.cs @@ -3,38 +3,34 @@ using System.Collections.Generic; using Cthangover.Core.Mods.Resolvers; using Cthangover.Core.UI.Theming; using Cthangover.Mods.Map; +using Cthangover.Tools.Common; using Godot; namespace Cthangover.Tools.MapEditor { /// - /// Interactive preview of the edited map: draws the background with the - /// exact aspect-preserving layout used by MapWidget, overlays every - /// zone polygon, marker and text label, and hosts the draggable handles. - /// Implements the two editor modes: + /// Interactive preview of the edited map: the background (laid out by + /// exactly like MapWidget), every zone + /// polygon, marker and text label, and the draggable handles of the + /// selected zone. Two modes: /// - /// select — clicking picks the topmost zone (polygon first, + /// select — a click picks the topmost zone (polygon first, /// then marker/label dots); handles move vertices and positions; - /// draw — clicking appends polygon vertices to the selected - /// region and double-click finishes the polygon and returns to select - /// mode. + /// draw — clicks append polygon vertices to the selected + /// region while a dashed rubber band shows the next edge; a double-click + /// (or Enter/Esc via the window) finishes the polygon. /// - /// Right-click deletes a polygon vertex in either mode: the vertex nearest - /// to the pointer. While drawing, a click that misses every vertex removes - /// the last one ("undo point"); in select mode only a targeted vertex is - /// deleted. - /// The canvas owns no data: it reads and mutates - /// and raises so the - /// window can refresh the sidebar and JSON preview. Drawing is connected - /// through the "draw" signal and input through the GuiInput event, because - /// virtual method overrides are unreliable in mod assemblies. + /// Right-click deletes the polygon vertex nearest to the pointer; while + /// drawing, a click that misses every vertex removes the last one ("undo + /// point"), in select mode only a targeted vertex is deleted. /// - /// Zone fills and outlines are painted through — - /// the same definition the game's MapZonesLayer and the window's - /// game-like preview use — so a zone the author sees here and the same zone - /// on the live map are guaranteed to look identical. + /// The canvas owns no data: it reads and mutates + /// and raises so + /// the window can refresh the sidebar and JSON. Zone fills and outlines are + /// painted through — the definition the game's + /// MapZonesLayer uses — so the editor and the live map look the same. /// - public partial class MapEditorCanvas : Control + public partial class MapEditorCanvas : BackgroundCanvas { /// Mode constant: clicking selects zones and drags handles. public const string ModeSelect = "select"; @@ -42,129 +38,97 @@ namespace Cthangover.Tools.MapEditor /// Mode constant: clicking appends polygon vertices. public const string ModeDraw = "draw"; + /// Handle index of the marker position. + public const int MarkerHandle = -2; + + /// Handle index of the label position. + public const int LabelHandle = -3; + + private const float PickRadius = 24f; + private static readonly Color VertexColor = MapZoneStyle.VertexHandle; private static readonly Color MarkerColor = MapZoneStyle.MarkerHandle; private static readonly Color LabelColor = MapZoneStyle.LabelHandle; - private readonly List _handles = new(); - /// Data being edited; set once by the window. public MapEditorController Controller; - /// Reference background; null falls back to a flat rectangle. - public Texture2D Background; - /// Current interaction mode, one of / . public string Mode { get; private set; } = ModeSelect; - /// Raised whenever the canvas mutates controller data (drag, draw, pick). + /// Raised whenever the canvas mutates controller data (drag, draw, delete). public event Action Changed; /// Raised with the newly picked zone index (-1 for empty space). public event Action ZonePicked; + /// Raised when the canvas leaves draw mode on its own (double-click finishing a polygon). + public event Action ModeChanged; + + private Vector2? _pointer; + public MapEditorCanvas() { - MouseFilter = MouseFilterEnum.Stop; - ClipContents = true; MapZoneStyle.Prepare(this); GuiInput += OnGuiInput; MouseExited += OnMouseExited; - Resized += OnResized; - Connect("draw", Callable.From((Action)DrawAll)); } /// Switches the interaction mode and refreshes the handles. public void SetMode(string mode) { Mode = mode == ModeDraw ? ModeDraw : ModeSelect; + MouseDefaultCursorShape = Mode == ModeDraw ? CursorShape.Cross : CursorShape.Arrow; RebuildHandles(); QueueRedraw(); } - /// Assigns the background texture and rebuilds the layout. - public void SetBackground(Texture2D texture) + /// Leaves draw mode (double-click, Enter, Esc) and tells the window. + public void FinishDrawing() { - Background = texture; - RebuildHandles(); - QueueRedraw(); + if (Mode != ModeDraw) + return; + + SetMode(ModeSelect); + _pointer = null; + ModeChanged?.Invoke(); } /// Recreates the handles from the current selection. Call after structural changes. - public void RebuildHandles() + public override void RebuildHandles() { - foreach (var handle in _handles) - handle.QueueFree(); - _handles.Clear(); + ClearHandles(); var zone = Controller?.Selected; - if (zone == null) - return; - - var rect = GetBackgroundRect(); - if (rect.Size.X <= 0f || rect.Size.Y <= 0f) + if (zone == null || GetBackgroundRect().Size.X <= 0f) return; if (zone.Points != null) { for (var i = 0; i < zone.Points.Count; i++) - AddHandle(rect, zone.Points[i], VertexColor, i); + { + var index = i; + var handle = AddHandle(zone.Points[i], VertexColor, i, p => OnHandleDrag(index, p)); + handle.RightClicked = () => RemoveVertexAt(index); + handle.DragEnd = () => Changed?.Invoke(); + } } if (HasMarker(zone)) - AddHandle(rect, zone.ImagePos, MarkerColor, MapEditorHandle.MarkerHandle); + AddHandle(zone.ImagePos, MarkerColor, MarkerHandle, p => OnHandleDrag(MarkerHandle, p), 18f).DragEnd = () => Changed?.Invoke(); if (zone.LabelPos.HasValue) - AddHandle(rect, zone.LabelPos.Value, LabelColor, MapEditorHandle.LabelHandle); + AddHandle(zone.LabelPos.Value, LabelColor, LabelHandle, p => OnHandleDrag(LabelHandle, p), 18f).DragEnd = () => Changed?.Invoke(); } - /// - /// Aspect-preserving rectangle the background occupies inside the - /// canvas. Must stay identical to the runtime layout: the saved - /// normalised coordinates mean nothing if the editor and the game - /// disagree about this rectangle. - /// - public Rect2 GetBackgroundRect() - { - var controlSize = Size; - if (controlSize.X <= 0f || controlSize.Y <= 0f) - return new Rect2(); - - var textureSize = Background?.GetSize() ?? new Vector2(1920, 1080); - if (textureSize.X <= 0f || textureSize.Y <= 0f) - textureSize = new Vector2(1920, 1080); - - var scale = Mathf.Min(controlSize.X / textureSize.X, controlSize.Y / textureSize.Y); - var size = textureSize * scale; - return new Rect2((controlSize - size) / 2f, size); - } - - private void AddHandle(Rect2 rect, Vector2 normalized, Color color, int index) - { - var handle = new MapEditorHandle(color, index); - handle.DragUpdate = center => OnHandleDrag(index, center); - handle.DragEnd = () => Changed?.Invoke(); - handle.RightClicked = () => RemoveVertexHandle(index); - AddChild(handle); - handle.GlobalPosition = GlobalPosition + rect.Position + Clamp01(normalized) * rect.Size - handle.Size / 2f; - _handles.Add(handle); - } - - private void OnHandleDrag(int index, Vector2 globalCenter) + private void OnHandleDrag(int index, Vector2 normalized) { if (Controller == null) return; - var rect = GetBackgroundRect(); - if (rect.Size.X <= 0f || rect.Size.Y <= 0f) - return; - - var local = globalCenter - GlobalPosition - rect.Position; - var normalized = Clamp01(new Vector2(local.X / rect.Size.X, local.Y / rect.Size.Y)); - - if (index == MapEditorHandle.MarkerHandle) + if (index == MarkerHandle) Controller.SetImagePos(normalized); - else if (index == MapEditorHandle.LabelHandle) + else if (index == LabelHandle) Controller.SetLabelPos(normalized); else Controller.SetVertex(index, normalized); @@ -175,19 +139,39 @@ namespace Cthangover.Tools.MapEditor private void OnGuiInput(InputEvent @event) { - if (Controller == null || @event is not InputEventMouseButton mb || !mb.Pressed) + if (Controller == null) + return; + + if (@event is InputEventMouseMotion motion) + { + if (Mode == ModeDraw) + { + _pointer = motion.Position; + QueueRedraw(); + } + return; + } + + if (@event is not InputEventMouseButton mb || !mb.Pressed) return; if (mb.ButtonIndex == MouseButton.Left) { if (Mode == ModeDraw) { - Controller.AddVertex(Clamp01(PixelToNorm(mb.Position))); + // The first click of a double-click already placed the + // vertex; the double-click itself only finishes the polygon. if (mb.DoubleClick) - Mode = ModeSelect; - RebuildHandles(); - Changed?.Invoke(); - QueueRedraw(); + { + FinishDrawing(); + } + else + { + Controller.AddVertex(Clamp01(LocalToNorm(mb.Position))); + RebuildHandles(); + Changed?.Invoke(); + QueueRedraw(); + } } else { @@ -197,7 +181,6 @@ namespace Cthangover.Tools.MapEditor Controller.Select(hit); RebuildHandles(); ZonePicked?.Invoke(hit); - Changed?.Invoke(); QueueRedraw(); } } @@ -213,12 +196,7 @@ namespace Cthangover.Tools.MapEditor private void OnMouseExited() { - QueueRedraw(); - } - - private void OnResized() - { - RebuildHandles(); + _pointer = null; QueueRedraw(); } @@ -238,35 +216,42 @@ namespace Cthangover.Tools.MapEditor for (var i = zones.Count - 1; i >= 0; i--) { var zone = zones[i]; - if (HasMarker(zone) && (local - ToPixel(zone.ImagePos, rect)).Length() <= 24f) + if (HasMarker(zone) && (local - ToPixel(zone.ImagePos, rect)).Length() <= PickRadius) return i; - if (zone.LabelPos.HasValue && (local - ToPixel(zone.LabelPos.Value, rect)).Length() <= 24f) + if (zone.LabelPos.HasValue && (local - ToPixel(zone.LabelPos.Value, rect)).Length() <= PickRadius) return i; } return -1; } - private void DrawAll() + protected override void DrawContent(Rect2 rect) { - var rect = GetBackgroundRect(); - if (rect.Size.X <= 0f || rect.Size.Y <= 0f) - return; - - if (Background != null) - DrawTextureRect(Background, rect, false); - else - DrawRect(rect, UiTokens.Surface2); - var zones = Controller?.Zones; if (zones == null) return; for (var i = 0; i < zones.Count; i++) - { - var selected = i == Controller.SelectedIndex; - DrawZone(zones[i], rect, selected); - } + DrawZone(zones[i], rect, i == Controller.SelectedIndex); + + DrawRubberBand(rect); + } + + /// + /// In draw mode shows where the next vertex goes: a dashed segment from + /// the last vertex to the pointer and back to the first one, so the + /// author sees the polygon closing before clicking. + /// + private void DrawRubberBand(Rect2 rect) + { + var points = Controller.Selected?.Points; + if (Mode != ModeDraw || !_pointer.HasValue || points == null || points.Count == 0) + return; + + var pointer = _pointer.Value; + DrawDashedLine(ToPixel(points[points.Count - 1], rect), pointer, UiTokens.AccentBright, 1.5f, 6f); + if (points.Count >= 2) + DrawDashedLine(pointer, ToPixel(points[0], rect), UiTokens.AccentLine, 1f, 6f); } private void DrawZone(MapZone zone, Rect2 rect, bool selected) @@ -340,41 +325,31 @@ namespace Cthangover.Tools.MapEditor return rect.Position + normalized * rect.Size; } - private Vector2 PixelToNorm(Vector2 local) - { - var rect = GetBackgroundRect(); - if (rect.Size.X <= 0f || rect.Size.Y <= 0f) - return Vector2.Zero; - - return new Vector2((local.X - rect.Position.X) / rect.Size.X, (local.Y - rect.Position.Y) / rect.Size.Y); - } - - private static Vector2 Clamp01(Vector2 value) - { - return new Vector2(Mathf.Clamp(value.X, 0f, 1f), Mathf.Clamp(value.Y, 0f, 1f)); - } - /// /// A zone is marker-capable when it has an icon, or when it is still a /// bare marker placeholder — no polygon and no text label. The /// placeholder keeps a freshly created marker visible and draggable /// before an icon is picked; once the zone grows a region or becomes a - /// pure inscription, the marker is only shown when an icon exists. + /// pure inscription, the marker is only shown when an icon exists. The + /// region currently being drawn is excluded while it has no vertices. /// - private static bool HasMarker(MapZone zone) + private bool HasMarker(MapZone zone) { if (!string.IsNullOrEmpty(zone.Image)) return true; + if (Mode == ModeDraw && zone == Controller?.Selected) + return false; + return (zone.Points == null || zone.Points.Count == 0) && !zone.LabelPos.HasValue; } /// - /// Right-click handler: removes the polygon vertex nearest to the - /// pointer. While drawing, a click that misses every vertex removes the - /// last one instead, which makes right-click a natural "undo point"; in - /// select mode only a targeted vertex is deleted, so a stray click - /// cannot destroy geometry. + /// Right-click on empty canvas: removes the polygon vertex nearest to + /// the pointer. While drawing, a click that misses every vertex removes + /// the last one instead, which makes right-click a natural "undo + /// point"; in select mode only a targeted vertex is deleted, so a stray + /// click cannot destroy geometry. /// private void RemoveVertexAtPointer(Vector2 local, bool fallbackToLast) { @@ -386,7 +361,13 @@ namespace Cthangover.Tools.MapEditor var index = FindNearestVertex(zone, local, rect); if (index < 0 && fallbackToLast) index = zone.Points.Count - 1; - if (index < 0) + if (index >= 0) + RemoveVertexAt(index); + } + + private void RemoveVertexAt(int index) + { + if (Controller == null) return; Controller.RemoveVertex(index); @@ -398,7 +379,7 @@ namespace Cthangover.Tools.MapEditor private static int FindNearestVertex(MapZone zone, Vector2 local, Rect2 rect) { var best = -1; - var bestDistance = 24f; + var bestDistance = PickRadius; for (var i = 0; i < zone.Points.Count; i++) { @@ -412,20 +393,5 @@ namespace Cthangover.Tools.MapEditor return best; } - - /// - /// Deletes a vertex from its handle's right-click. Marker and label - /// handles (negative indexes) are not vertices and are ignored. - /// - private void RemoveVertexHandle(int index) - { - if (index < 0 || Controller == null) - return; - - Controller.RemoveVertex(index); - RebuildHandles(); - Changed?.Invoke(); - QueueRedraw(); - } } } diff --git a/src/MapEditor/MapEditorController.cs b/src/MapEditor/MapEditorController.cs index 2a07147..6dbe94c 100644 --- a/src/MapEditor/MapEditorController.cs +++ b/src/MapEditor/MapEditorController.cs @@ -70,6 +70,22 @@ namespace Cthangover.Tools.MapEditor return Append(zone); } + /// + /// Creates a region without vertices and selects it — the start of + /// drawing a polygon point by point on the canvas. + /// + public MapZone StartRegion() + { + var zone = new MapZone + { + ID = MakeUniqueId("region"), + Title = string.Empty, + Points = new List(), + }; + + return Append(zone); + } + /// /// Creates an icon marker at the map centre with no icon chosen yet and /// selects it. The author assigns image and scene in the diff --git a/src/MapEditor/MapEditorHandle.cs b/src/MapEditor/MapEditorHandle.cs deleted file mode 100644 index d1493df..0000000 --- a/src/MapEditor/MapEditorHandle.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Cthangover.Core.Utils; -using Godot; - -namespace Cthangover.Tools.MapEditor -{ - /// - /// Draggable 16x16 handle on the map editor canvas. One handle represents - /// either a polygon vertex, the marker position or the label position, - /// distinguished by with negative values reserved for - /// the position handles. Subscribes to the GuiInput C# event rather than - /// overriding _GuiInput for reliable event delivery in mod - /// assemblies, following InteractiveEditorHandle. - /// - public partial class MapEditorHandle : ColorRect - { - /// Handle role: 0..n — vertex index, or . - public const int MarkerHandle = -2; - - /// Handle role used for the label position handle. - public const int LabelHandle = -3; - - /// Vertex index or one of the negative role constants. - public int Index; - - /// Invoked during drag with the handle centre in global coordinates. - public System.Action DragUpdate; - - /// Invoked when the handle is clicked (mouse down). - public System.Action Clicked; - - /// Invoked when the drag ends (mouse up). - public System.Action DragEnd; - - /// - /// Invoked on a right-click over the handle. Handles stop mouse input, - /// so without this hook a right-click aimed exactly at a vertex would - /// never reach the canvas and the vertex could not be deleted there. - /// - public System.Action RightClicked; - - private bool _dragging; - private Vector2 _dragOffset; - - /// Creates a handle with the given colour and role index. - public MapEditorHandle(Color color, int index) - { - Index = index; - Size = new Vector2(16, 16); - Color = color; - MouseFilter = MouseFilterEnum.Stop; - MouseDefaultCursorShape = 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; - Clicked?.Invoke(); - } - else - { - _dragging = false; - DragEnd?.Invoke(); - } - AcceptEvent(); - } - else if (@event is InputEventMouseButton right && right.ButtonIndex == MouseButton.Right && right.Pressed) - { - RightClicked?.Invoke(); - AcceptEvent(); - } - else if (@event is InputEventMouseMotion && _dragging) - { - var center = GlobalPosition + Size / 2; - GlobalPosition = GetGlobalMousePosition() - _dragOffset; - DragUpdate?.Invoke(center); - } - } - } -} diff --git a/src/MapEditor/MapEditorWindow.cs b/src/MapEditor/MapEditorWindow.cs index bb657f4..6f82337 100644 --- a/src/MapEditor/MapEditorWindow.cs +++ b/src/MapEditor/MapEditorWindow.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using Cthangover.Core.Localization; using Cthangover.Core.Mods.Resolvers; @@ -8,83 +7,93 @@ using Cthangover.Core.Scenes; using Cthangover.Core.Settings; using Cthangover.Core.UI; using Cthangover.Core.UI.Theming; -using Cthangover.Mods.Map; -using Cthangover.Core.UI.Tool; using Cthangover.Core.Utils; +using Cthangover.Mods.Interface.Theming; +using Cthangover.Mods.Interface.Tool; +using Cthangover.Mods.Map; +using Cthangover.Tools.Common; using Cthangover.Tools.Services; using Godot; -using Cthangover.Mods.Interface.Tool; namespace Cthangover.Tools.MapEditor { /// - /// Editor window for the world map. Authors pick a folder-backed mod and a - /// file under its map/ directory, then draw polygons, place icon - /// markers and drop free-standing text labels over a background preview. + /// Editor window for the world map. The author picks a writable mod and a + /// file under its map/ folder (or creates one), then draws region + /// polygons, places icon markers and free-standing text labels over a + /// background preview. + /// + /// Layout: toolbar (file, save, mode, background, preview), zone list on + /// the left, canvas in the middle, properties of the selected zone on the + /// right, and an optional read-only JSON view below the canvas. Switching + /// file or mod with unsaved edits asks first. /// /// The editor edits raw authoring data only: the produced JSON matches - /// MapZone (polygon points, marker image/image_pos, - /// label label_pos, initial locked/hidden flags). - /// Runtime state — what scenarios open or reveal — is not touched; after a - /// successful save the editor asks the running map widget to reload so the - /// edit is visible immediately. - /// - /// Layout mirrors the other tool windows: toolbar for file and mode actions, - /// sidebar with the zone list and property editors, centre preview, and a - /// read-only JSON preview at the bottom for quick inspection. + /// MapZone. Runtime state — what scenarios open or reveal — is not + /// touched; after a successful save the running map widget is asked to + /// reload so the edit is visible immediately. /// public partial class MapEditorWindow : ToolWindow { private readonly MapEditorController _controller = new(); - private readonly List _writableMods = new(); private readonly List _backgroundIds = new(); private readonly List _sceneIds = new(); - private readonly List _textureIds = new(); - private OptionButton _modSelect; - private LineEdit _fileEdit; - private OptionButton _modeSelect; + private string _modId; + private string _path; + + private ModFileSelector _fileSelector; + private Button _selectModeBtn; + private Button _drawModeBtn; private OptionButton _backgroundSelect; - private Label _statusLabel; - private ItemList _zoneList; + private FilteredList _zoneList; + private Button _deleteBtn; + + private VBoxContainer _props; + private Label _propsEmpty; + private VBoxContainer _propsBody; private LineEdit _idEdit; private LineEdit _titleEdit; private Label _titlePreview; private OptionButton _sceneSelect; private LineEdit _descEdit; private LineEdit _imageEdit; - private ItemList _iconList; + private TextureRect _imagePreview; + private FilteredList _iconList; + private SpinBox _posX; + private SpinBox _posY; + private CheckBox _labelCheck; + private SpinBox _labelX; + private SpinBox _labelY; private CheckBox _lockedCheck; private CheckBox _hiddenCheck; private Label _availabilityLabel; - private CheckBox _labelCheck; - private LineEdit _posX; - private LineEdit _posY; - private LineEdit _labelX; - private LineEdit _labelY; private VBoxContainer _vertexList; + private CollapsibleSection _markerSection; + private CollapsibleSection _labelSection; + private CollapsibleSection _vertexSection; + private MapZone _sectionsShownFor; private MapEditorCanvas _canvas; private TextEdit _jsonEdit; - private Button _previewButton; + private Button _jsonToggle; private MapWidget _preview; private bool _suppress; public MapEditorWindow() : base("tools/map_editor/title") { - Size = new Vector2I(1280, 820); CthangoverTheme.Apply(this); ReloadLocalization(); BuildUI(); - LoadModList(); LoadSceneList(); LoadTextureList(); LoadBackgroundList(); - RefreshAll(); + SetHint(ToolUi.Tr("tools/map_editor/hint")); + _fileSelector.Refresh(); } // ── UI construction ──────────────────────────────────────── @@ -96,223 +105,204 @@ namespace Cthangover.Tools.MapEditor outer.AddChild(BuildToolbar()); - var main = new HBoxContainer + var main = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; outer.AddChild(main); - var sidebarContent = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; - main.AddChild(CreateSidebar(sidebarContent, 320)); - BuildSidebar(sidebarContent); + var zonesColumn = new VBoxContainer(); + main.AddChild(CreateSidebar(zonesColumn, 240)); + BuildZoneList(zonesColumn); - var previewPanel = new PanelContainer + var centerSplit = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + main.AddChild(centerSplit); + + var canvasColumn = new VSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; - main.AddChild(previewPanel); + centerSplit.AddChild(canvasColumn); + + var canvasPanel = new PanelContainer + { + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill + }; + canvasPanel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line)); + canvasColumn.AddChild(canvasPanel); _canvas = new MapEditorCanvas { Controller = _controller }; _canvas.Changed += OnCanvasChanged; _canvas.ZonePicked += OnCanvasZonePicked; - previewPanel.AddChild(_canvas); + _canvas.ModeChanged += SyncModeButtons; + canvasPanel.AddChild(_canvas); - _jsonEdit = new TextEdit - { - CustomMinimumSize = new Vector2(0, 150), - Editable = false, - WrapMode = TextEdit.LineWrappingMode.Boundary - }; - _jsonEdit.AddThemeFontOverride("font", GetMonospaceFont()); - outer.AddChild(_jsonEdit); + _jsonEdit = ToolUi.Code(editable: false); + _jsonEdit.CustomMinimumSize = new Vector2(0, 160); + _jsonEdit.Visible = false; + canvasColumn.AddChild(_jsonEdit); + + _props = new VBoxContainer(); + centerSplit.AddChild(CreateSidebar(_props, 320)); + BuildProperties(_props); } private HBoxContainer BuildToolbar() { var toolbar = CreateToolbar(); - toolbar.AddChild(new Label { Text = Tr("tools/map_editor/mod") }); - _modSelect = new OptionButton(); - _modSelect.ItemSelected += _ => OnFileChanged(); - toolbar.AddChild(_modSelect); + _fileSelector = new ModFileSelector(MapFileService.MapDir, ".json"); + _fileSelector.FileRequested += OnFileRequested; + toolbar.AddChild(_fileSelector); - toolbar.AddChild(new Label { Text = Tr("tools/map_editor/file") }); - _fileEdit = new LineEdit { Text = "map.json", CustomMinimumSize = new Vector2(160, 0) }; - toolbar.AddChild(_fileEdit); + toolbar.AddChild(ToolUi.Button("tools/common/save", Save, ButtonKind.Primary, "tools/common/save_tip")); + toolbar.AddChild(ToolUi.Button("tools/map_editor/reload_map", OnReloadMap, ButtonKind.Secondary, "tools/map_editor/reload_map_tip")); + toolbar.AddChild(ToolUi.Divider()); - var loadBtn = new Button { Text = Tr("tools/map_editor/load") }; - loadBtn.Pressed += OnLoad; - toolbar.AddChild(loadBtn); + var modeGroup = new ButtonGroup(); + _selectModeBtn = ModeButton("tools/map_editor/select_mode", modeGroup, MapEditorCanvas.ModeSelect); + _selectModeBtn.ButtonPressed = true; + toolbar.AddChild(_selectModeBtn); + _drawModeBtn = ModeButton("tools/map_editor/draw_region", modeGroup, MapEditorCanvas.ModeDraw); + toolbar.AddChild(_drawModeBtn); + toolbar.AddChild(ToolUi.Divider()); - var saveBtn = new Button { Text = Tr("tools/map_editor/save") }; - saveBtn.Pressed += OnSave; - toolbar.AddChild(saveBtn); - - var reloadBtn = new Button { Text = Tr("tools/map_editor/reload_map") }; - reloadBtn.Pressed += OnReloadMap; - toolbar.AddChild(reloadBtn); - - _modeSelect = new OptionButton(); - _modeSelect.AddItem(Tr("tools/map_editor/select_mode")); - _modeSelect.AddItem(Tr("tools/map_editor/draw_region")); - _modeSelect.ItemSelected += index => _canvas.SetMode(index == 1 ? MapEditorCanvas.ModeDraw : MapEditorCanvas.ModeSelect); - toolbar.AddChild(_modeSelect); - - toolbar.AddChild(new Label { Text = Tr("tools/map_editor/background") }); - _backgroundSelect = new OptionButton(); + _backgroundSelect = new OptionButton { TooltipText = ToolUi.Tr("tools/map_editor/background"), CustomMinimumSize = new Vector2(160, 0) }; _backgroundSelect.ItemSelected += OnBackgroundSelected; toolbar.AddChild(_backgroundSelect); - _previewButton = new Button { Text = Tr("tools/map_editor/preview") }; - _previewButton.TooltipText = Tr("tools/map_editor/preview_tip"); - _previewButton.Pressed += OnPreviewPressed; - toolbar.AddChild(_previewButton); + toolbar.AddChild(UiKit.Spacer()); - _statusLabel = new Label { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, HorizontalAlignment = HorizontalAlignment.Right }; - _statusLabel.AddThemeColorOverride("font_color", UiTokens.Muted); - toolbar.AddChild(_statusLabel); + _jsonToggle = ToolUi.Button("tools/common/json", null, ButtonKind.Tab, "tools/common/json_tip"); + _jsonToggle.ToggleMode = true; + _jsonToggle.Toggled += on => _jsonEdit.Visible = on; + toolbar.AddChild(_jsonToggle); + toolbar.AddChild(ToolUi.Button("tools/map_editor/preview", OnPreviewPressed, ButtonKind.Secondary, "tools/map_editor/preview_tip")); return toolbar; } - private void BuildSidebar(VBoxContainer sidebar) + private Button ModeButton(string textKey, ButtonGroup group, string mode) { - sidebar.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; - - sidebar.AddChild(new Label { Text = Tr("tools/map_editor/zones") }); - - _zoneList = new ItemList { CustomMinimumSize = new Vector2(0, 140) }; - _zoneList.ItemSelected += OnZoneSelected; - sidebar.AddChild(_zoneList); - - var addRow = new HBoxContainer(); - sidebar.AddChild(addRow); - AddButton(addRow, "tools/map_editor/add_region", OnAddRegion); - AddButton(addRow, "tools/map_editor/add_marker", OnAddMarker); - AddButton(addRow, "tools/map_editor/add_label", OnAddLabel); - - var deleteBtn = new Button { Text = Tr("tools/map_editor/delete") }; - deleteBtn.Pressed += OnDeleteZone; - sidebar.AddChild(deleteBtn); - - sidebar.AddChild(new Label { Text = Tr("tools/map_editor/props") }); - - sidebar.AddChild(new Label { Text = Tr("tools/map_editor/id") }); - _idEdit = new LineEdit(); - _idEdit.TextChanged += _ => OnIdChanged(); - sidebar.AddChild(_idEdit); - - sidebar.AddChild(new Label { Text = Tr("tools/map_editor/title_key") }); - _titleEdit = new LineEdit(); - _titleEdit.TextChanged += _ => OnTitleChanged(); - sidebar.AddChild(_titleEdit); - - _titlePreview = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart }; - _titlePreview.AddThemeColorOverride("font_color", UiTokens.Faint); - sidebar.AddChild(_titlePreview); - - sidebar.AddChild(new Label { Text = Tr("tools/map_editor/scene") }); - _sceneSelect = new OptionButton(); - _sceneSelect.ItemSelected += _ => OnFieldsChanged(); - sidebar.AddChild(_sceneSelect); - - sidebar.AddChild(new Label { Text = Tr("tools/map_editor/description") }); - _descEdit = new LineEdit(); - _descEdit.TextChanged += _ => OnFieldsChanged(); - sidebar.AddChild(_descEdit); - - sidebar.AddChild(new Label { Text = Tr("tools/map_editor/image") }); - _imageEdit = new LineEdit(); - _imageEdit.TextChanged += _ => OnImageChanged(); - sidebar.AddChild(_imageEdit); - - _iconList = new ItemList { CustomMinimumSize = new Vector2(0, 100) }; - _iconList.ItemSelected += OnIconSelected; - sidebar.AddChild(_iconList); - - sidebar.AddChild(new Label { Text = Tr("tools/map_editor/image_pos") }); - var posRow = new HBoxContainer(); - sidebar.AddChild(posRow); - _posX = AddLabeledLine(posRow, "tools/map_editor/x"); - _posY = AddLabeledLine(posRow, "tools/map_editor/y"); - _posX.TextChanged += _ => OnPositionChanged(); - _posY.TextChanged += _ => OnPositionChanged(); - - _labelCheck = new CheckBox { Text = Tr("tools/map_editor/label") }; - _labelCheck.Toggled += OnLabelToggled; - sidebar.AddChild(_labelCheck); - - var labelRow = new HBoxContainer(); - sidebar.AddChild(labelRow); - _labelX = AddLabeledLine(labelRow, "tools/map_editor/x"); - _labelY = AddLabeledLine(labelRow, "tools/map_editor/y"); - _labelX.TextChanged += _ => OnPositionChanged(); - _labelY.TextChanged += _ => OnPositionChanged(); - - _lockedCheck = new CheckBox { Text = Tr("tools/map_editor/locked") }; - _lockedCheck.Toggled += _ => OnFieldsChanged(); - sidebar.AddChild(_lockedCheck); - - _hiddenCheck = new CheckBox { Text = Tr("tools/map_editor/hidden") }; - _hiddenCheck.Toggled += _ => OnFieldsChanged(); - sidebar.AddChild(_hiddenCheck); - - _availabilityLabel = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart }; - _availabilityLabel.AddThemeColorOverride("font_color", UiTokens.Faint); - sidebar.AddChild(_availabilityLabel); - - sidebar.AddChild(new Label { Text = Tr("tools/map_editor/vertices") }); - - var vertexButtons = new HBoxContainer(); - sidebar.AddChild(vertexButtons); - var addVertexBtn = new Button { Text = Tr("tools/map_editor/add_vertex") }; - addVertexBtn.Pressed += OnAddVertex; - vertexButtons.AddChild(addVertexBtn); - - _vertexList = new VBoxContainer(); - sidebar.AddChild(_vertexList); - } - - private static Button AddButton(Container parent, string localeKey, Action pressed) - { - var button = new Button { Text = Tr(localeKey) }; - button.Pressed += pressed; - parent.AddChild(button); + var button = ToolUi.Button(textKey, null, ButtonKind.Tab); + button.ToggleMode = true; + button.ButtonGroup = group; + button.Toggled += on => + { + if (on && !_suppress && _canvas != null) + _canvas.SetMode(mode); + }; return button; } - private static LineEdit AddLabeledLine(Container parent, string localeKey) + private void BuildZoneList(VBoxContainer column) { - var label = new Label { Text = Tr(localeKey), CustomMinimumSize = new Vector2(20, 0) }; - parent.AddChild(label); + column.AddChild(ToolUi.Section("tools/map_editor/zones")); - var edit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; - parent.AddChild(edit); - return edit; + _zoneList = new FilteredList(240); + _zoneList.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + _zoneList.Chosen += OnZoneChosen; + column.AddChild(_zoneList); + + var addRow = new HBoxContainer(); + addRow.AddThemeConstantOverride("separation", UiTokens.Space1); + column.AddChild(addRow); + AddExpanding(addRow, ToolUi.Button("tools/map_editor/add_region", OnAddRegion, ButtonKind.Secondary, "tools/map_editor/add_region_tip")); + AddExpanding(addRow, ToolUi.Button("tools/map_editor/add_marker", OnAddMarker)); + AddExpanding(addRow, ToolUi.Button("tools/map_editor/add_label", OnAddLabel)); + + _deleteBtn = ToolUi.Button("tools/map_editor/delete", OnDeleteZone, ButtonKind.Danger, "tools/map_editor/delete_tip"); + column.AddChild(_deleteBtn); + } + + private static void AddExpanding(Container parent, Control control) + { + control.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + parent.AddChild(control); + } + + private void BuildProperties(VBoxContainer column) + { + column.AddChild(ToolUi.Section("tools/map_editor/props")); + + _propsEmpty = ToolUi.Hint(ToolUi.Tr("tools/map_editor/zone_missing")); + column.AddChild(_propsEmpty); + + var body = new VBoxContainer(); + body.AddThemeConstantOverride("separation", UiTokens.Space2); + column.AddChild(body); + _propsBody = body; + + _idEdit = ToolUi.Field(body, "tools/map_editor/id", ToolUi.Text("region", _ => OnIdChanged())); + _idEdit.AddThemeFontOverride("font", ToolUi.Mono()); + _titleEdit = ToolUi.Field(body, "tools/map_editor/title_key", ToolUi.Text("ui/map/…", _ => OnTitleChanged())); + _titlePreview = ToolUi.Hint(string.Empty); + body.AddChild(_titlePreview); + _descEdit = ToolUi.Field(body, "tools/map_editor/description", ToolUi.Text("ui/map/…", _ => OnFieldsChanged())); + + _sceneSelect = ToolUi.Field(body, "tools/map_editor/scene", new OptionButton()); + _sceneSelect.ItemSelected += _ => OnFieldsChanged(); + + body.AddChild(ToolUi.Section("tools/map_editor/availability")); + _lockedCheck = new CheckBox { Text = ToolUi.Tr("tools/map_editor/locked") }; + _lockedCheck.Toggled += _ => OnFieldsChanged(); + body.AddChild(_lockedCheck); + _hiddenCheck = new CheckBox { Text = ToolUi.Tr("tools/map_editor/hidden") }; + _hiddenCheck.Toggled += _ => OnFieldsChanged(); + body.AddChild(_hiddenCheck); + _availabilityLabel = ToolUi.Hint(string.Empty); + body.AddChild(_availabilityLabel); + + _markerSection = new CollapsibleSection("tools/map_editor/marker"); + body.AddChild(_markerSection); + var marker = _markerSection.Body; + var imageRow = new HBoxContainer(); + _imagePreview = new TextureRect + { + CustomMinimumSize = new Vector2(32, 32), + ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize, + StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered + }; + _imageEdit = ToolUi.Text(ToolUi.Tr("tools/map_editor/image_placeholder"), _ => OnImageChanged()); + _imageEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + imageRow.AddChild(_imageEdit); + imageRow.AddChild(_imagePreview); + ToolUi.Field(marker, "tools/map_editor/image", imageRow); + + _iconList = new FilteredList(110, "tools/map_editor/icon_search"); + _iconList.Chosen += OnIconChosen; + marker.AddChild(_iconList); + + _posX = ToolUi.Field(marker, "tools/map_editor/x", ToolUi.Number(0, 1, 0.001, _ => OnPositionChanged())); + _posY = ToolUi.Field(marker, "tools/map_editor/y", ToolUi.Number(0, 1, 0.001, _ => OnPositionChanged())); + + _labelSection = new CollapsibleSection("tools/map_editor/label_section"); + body.AddChild(_labelSection); + _labelCheck = new CheckBox { Text = ToolUi.Tr("tools/map_editor/label") }; + _labelCheck.Toggled += OnLabelToggled; + _labelSection.Body.AddChild(_labelCheck); + _labelX = ToolUi.Field(_labelSection.Body, "tools/map_editor/x", ToolUi.Number(0, 1, 0.001, _ => OnPositionChanged())); + _labelY = ToolUi.Field(_labelSection.Body, "tools/map_editor/y", ToolUi.Number(0, 1, 0.001, _ => OnPositionChanged())); + + _vertexSection = new CollapsibleSection("tools/map_editor/vertices"); + body.AddChild(_vertexSection); + _vertexSection.Body.AddChild(ToolUi.Hint(ToolUi.Tr("tools/map_editor/vertices_hint"))); + _vertexList = new VBoxContainer(); + _vertexList.AddThemeConstantOverride("separation", UiTokens.Space1); + _vertexSection.Body.AddChild(_vertexList); + _vertexSection.Body.AddChild(ToolUi.Button("tools/map_editor/add_vertex", OnAddVertex)); } // ── data loading ─────────────────────────────────────────── - private void LoadModList() - { - _writableMods.Clear(); - _modSelect.Clear(); - - _writableMods.AddRange(MapFileService.ListWritableMods()); - foreach (var modId in _writableMods) - _modSelect.AddItem(modId); - - if (_writableMods.Count == 0) - SetStatus(Tr("tools/map_editor/no_mods")); - } - private void LoadSceneList() { _sceneIds.Clear(); _sceneSelect.Clear(); - _sceneSelect.AddItem(Tr("tools/map_editor/scene_none")); + _sceneSelect.AddItem(ToolUi.Tr("tools/map_editor/scene_none")); var defs = SceneDefinitions.Collect(); if (defs != null) @@ -324,12 +314,7 @@ namespace Cthangover.Tools.MapEditor private void LoadTextureList() { - _textureIds.Clear(); - _iconList.Clear(); - - _textureIds.AddRange(ModResourceService.GetTextureIds()); - foreach (var id in _textureIds) - _iconList.AddItem(id); + _iconList.SetItems(ModResourceService.GetIconIds()); } private void LoadBackgroundList() @@ -352,83 +337,83 @@ namespace Cthangover.Tools.MapEditor } } - private string SelectedMod => _modSelect.Selected >= 0 && _modSelect.Selected < _writableMods.Count - ? _writableMods[_modSelect.Selected] - : null; + // ── file actions ─────────────────────────────────────────── - private string SelectedFileName + private void OnFileRequested(string modId, string path) { - get - { - var name = _fileEdit.Text.Trim().Replace('\\', '/'); - if (string.IsNullOrEmpty(name)) - name = "map.json"; - if (!name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) - name += ".json"; - return name; - } + RunDiscardingChanges(() => Load(modId, path), () => _fileSelector.Show(_modId, _path)); } - private void OnFileChanged() + private void Load(string modId, string path) { - OnLoad(); - } + _modId = modId; + _path = path; + _controller.Clear(); + MarkClean(); - // ── toolbar actions ──────────────────────────────────────── - - private void OnLoad() - { - var modId = SelectedMod; if (modId == null) { - SetStatus(Tr("tools/map_editor/no_mods")); - return; + SetStatus(ToolUi.Tr("tools/common/no_writable_mods"), ToolStatusKind.Error); } - - var path = "map/" + SelectedFileName; - try + else if (path == null) { - var text = MapFileService.ReadFile(modId, path); - if (string.IsNullOrEmpty(text)) + SetStatus(string.Format(ToolUi.Tr("tools/common/no_files"), modId), ToolStatusKind.Info); + } + else + { + try { - _controller.Clear(); - MarkClean(); - SetStatus(Tr("tools/map_editor/no_file")); + var text = ModFileService.ReadText(modId, path); + if (!string.IsNullOrEmpty(text)) + _controller.FromJson(text); + SetStatus(string.Format(ToolUi.Tr(string.IsNullOrEmpty(text) ? "tools/common/new_file_status" : "tools/common/loaded"), modId + "/" + path)); } - else + catch (Exception ex) { - _controller.FromJson(text); - MarkClean(); - SetStatus(path); + SetStatus($"{ToolUi.Tr("tools/common/load_error")}: {ex.Message}", ToolStatusKind.Error); + GameLogger.Log("MAP_EDITOR", $"load failed for '{modId}/{path}': {ex.Message}", LogLevel.Error); } } - catch (Exception ex) - { - SetStatus($"{Tr("tools/map_editor/load_error")}: {ex.Message}"); - GameLogger.Log("MAP_EDITOR", $"load failed for '{modId}/{path}': {ex.Message}", LogLevel.Error); - } + _fileSelector.Show(_modId, _path); + _canvas.FinishDrawing(); RefreshAll(); } - private void OnSave() + /// Ctrl+S. + protected override void OnSaveShortcut() => Save(); + + private void Save() { - var zone = _controller.Selected; - if (zone != null && !ValidateId(zone.ID, out var error)) + if (_path == null) { - SetStatus(error); + SetStatus(ToolUi.Tr("tools/common/no_file_to_save"), ToolStatusKind.Error); return; } - var modId = SelectedMod; - if (modId == null || !MapFileService.WriteFile(modId, "map/" + SelectedFileName, _controller.ToJson())) + var invalid = _controller.Zones.FirstOrDefault(z => string.IsNullOrWhiteSpace(z.ID)); + if (invalid != null) { - SetStatus(Tr("tools/map_editor/readonly")); + SetStatus(ToolUi.Tr("tools/map_editor/id_empty"), ToolStatusKind.Error); + return; + } + + var duplicate = _controller.Zones.GroupBy(z => z.ID, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1); + if (duplicate != null) + { + SetStatus($"{ToolUi.Tr("tools/map_editor/duplicate_id")} {duplicate.Key}", ToolStatusKind.Error); + return; + } + + if (!ModFileService.WriteText(_modId, _path, _controller.ToJson())) + { + SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error); return; } MarkClean(); - SetStatus(Tr("tools/map_editor/save_ok")); + _fileSelector.Show(_modId, _path); + SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), _modId + "/" + _path), ToolStatusKind.Ok); ReloadRunningMap(); } @@ -454,7 +439,32 @@ namespace Cthangover.Tools.MapEditor private void OnReloadMap() { ReloadRunningMap(); - SetStatus(Tr("tools/map_editor/reloaded")); + SetStatus(ToolUi.Tr("tools/map_editor/reloaded"), ToolStatusKind.Ok); + } + + // ── shortcuts ────────────────────────────────────────────── + + /// Esc/Enter finish drawing, Delete removes the selected zone (with confirmation). + protected override bool HandleKey(InputEventKey key) + { + if (_preview != null) + return false; + + switch (key.Keycode) + { + case Key.Escape: + case Key.Enter: + case Key.KpEnter: + if (_canvas.Mode != MapEditorCanvas.ModeDraw) + return false; + _canvas.FinishDrawing(); + return true; + case Key.Delete: + OnDeleteZone(); + return true; + default: + return false; + } } // ── game-like preview ────────────────────────────────────── @@ -481,7 +491,7 @@ namespace Cthangover.Tools.MapEditor var widget = TscnScenes.LoadAndBuild("scenes/ui/map_widget.tscn") as MapWidget; if (widget == null) { - SetStatus(Tr("tools/map_editor/preview_missing")); + SetStatus(ToolUi.Tr("tools/map_editor/preview_missing"), ToolStatusKind.Error); return false; } @@ -493,7 +503,7 @@ namespace Cthangover.Tools.MapEditor widget.EnsureConstructed(); widget.Show(); - SetStatus(Tr("tools/map_editor/preview_on")); + SetStatus(ToolUi.Tr("tools/map_editor/preview_on")); return true; } @@ -514,7 +524,7 @@ namespace Cthangover.Tools.MapEditor UpdateTitlePreview(); _canvas.QueueRedraw(); - SetStatus(Tr("tools/map_editor/preview_off")); + SetStatus(ToolUi.Tr("tools/map_editor/preview_off")); } private void OnPreviewPressed() @@ -541,7 +551,6 @@ namespace Cthangover.Tools.MapEditor try { LocaleLoader.LoadCurrentLanguage(); - GameLogger.Log("MAP_EDITOR", "localisation reloaded"); } catch (Exception ex) { @@ -561,18 +570,18 @@ namespace Cthangover.Tools.MapEditor private void OnAddRegion() { - _controller.AddRegion(); - _modeSelect.Select(1); + _controller.StartRegion(); _canvas.SetMode(MapEditorCanvas.ModeDraw); + SyncModeButtons(); SetDirty(); RefreshAll(); + SetStatus(ToolUi.Tr("tools/map_editor/draw_started")); } private void OnAddMarker() { _controller.AddMarker(); - _modeSelect.Select(0); - _canvas.SetMode(MapEditorCanvas.ModeSelect); + _canvas.FinishDrawing(); SetDirty(); RefreshAll(); } @@ -580,64 +589,74 @@ namespace Cthangover.Tools.MapEditor private void OnAddLabel() { _controller.AddLabel(); - _modeSelect.Select(0); - _canvas.SetMode(MapEditorCanvas.ModeSelect); + _canvas.FinishDrawing(); SetDirty(); RefreshAll(); } private void OnDeleteZone() { - if (_controller.SelectedIndex < 0) + var zone = _controller.Selected; + if (zone == null) { - SetStatus(Tr("tools/map_editor/zone_missing")); + SetStatus(ToolUi.Tr("tools/map_editor/zone_missing"), ToolStatusKind.Error); return; } - _controller.RemoveAt(_controller.SelectedIndex); - SetDirty(); - RefreshAll(); + var index = _controller.SelectedIndex; + ShowConfirm("tools/map_editor/delete", "tools/map_editor/delete_confirm", () => + { + _controller.RemoveAt(index); + _canvas.FinishDrawing(); + SetDirty(); + RefreshAll(); + SetStatus(string.Format(ToolUi.Tr("tools/map_editor/deleted"), zone.ID)); + }); } - private void OnZoneSelected(long index) + private void OnZoneChosen(string indexText) { - if (_suppress) + if (_suppress || !int.TryParse(indexText, out var index)) return; - _controller.Select((int)index); + _canvas.FinishDrawing(); + _controller.Select(index); RefreshAll(); } private void OnCanvasZonePicked(int index) { - if (_suppress) - return; - - _controller.Select(index); RefreshSelectionOnly(); } private void OnCanvasChanged() { - if (_suppress) - return; - SetDirty(); RefreshValuesOnly(); } + private void SyncModeButtons() + { + _suppress = true; + if (_canvas.Mode == MapEditorCanvas.ModeDraw) + _drawModeBtn.ButtonPressed = true; + else + _selectModeBtn.ButtonPressed = true; + _suppress = false; + } + private void OnAddVertex() { var zone = _controller.Selected; if (zone == null) { - SetStatus(Tr("tools/map_editor/zone_missing")); + SetStatus(ToolUi.Tr("tools/map_editor/zone_missing"), ToolStatusKind.Error); return; } _controller.AddVertex(zone.Points != null && zone.Points.Count > 0 - ? zone.Points[zone.Points.Count - 1] - : new Godot.Vector2(0.5f, 0.5f)); + ? zone.Points[zone.Points.Count - 1] + new Vector2(0.02f, 0.02f) + : new Vector2(0.5f, 0.5f)); SetDirty(); RefreshAll(); } @@ -665,14 +684,17 @@ namespace Cthangover.Tools.MapEditor { zone.ID = id; SetDirty(); + SetStatus(string.Empty); + _idEdit.RemoveThemeColorOverride("font_color"); } else { - SetStatus(error); + SetStatus(error, ToolStatusKind.Error); + _idEdit.AddThemeColorOverride("font_color", UiTokens.DangerText); } RefreshJson(); - UpdateZoneListLabel(); + RefreshZoneList(); } private bool ValidateId(string id, out string error) @@ -680,7 +702,7 @@ namespace Cthangover.Tools.MapEditor error = null; if (string.IsNullOrWhiteSpace(id)) { - error = Tr("tools/map_editor/id_empty"); + error = ToolUi.Tr("tools/map_editor/id_empty"); return false; } @@ -689,7 +711,7 @@ namespace Cthangover.Tools.MapEditor if (!ReferenceEquals(zone, _controller.Selected) && string.Equals(zone.ID, id, StringComparison.OrdinalIgnoreCase)) { - error = Tr("tools/map_editor/duplicate_id"); + error = ToolUi.Tr("tools/map_editor/duplicate_id"); return false; } } @@ -699,17 +721,14 @@ namespace Cthangover.Tools.MapEditor private void OnTitleChanged() { - if (_suppress) + if (_suppress || _controller.Selected == null) return; - var zone = _controller.Selected; - if (zone == null) - return; - - zone.Title = _titleEdit.Text.Trim(); + _controller.Selected.Title = _titleEdit.Text.Trim(); UpdateTitlePreview(); SetDirty(); RefreshJson(); + _canvas.QueueRedraw(); } private void OnFieldsChanged() @@ -721,45 +740,42 @@ namespace Cthangover.Tools.MapEditor if (zone == null) return; - zone.Scene = _sceneSelect.Selected <= 0 ? string.Empty : _sceneIds[(int)_sceneSelect.Selected - 1]; + zone.Scene = _sceneSelect.Selected <= 0 ? string.Empty : _sceneIds[_sceneSelect.Selected - 1]; zone.Description = _descEdit.Text.Trim(); zone.Locked = _lockedCheck.ButtonPressed; zone.Hidden = _hiddenCheck.ButtonPressed; SetDirty(); RefreshJson(); - UpdateZoneListLabel(); UpdateAvailability(zone); _canvas.QueueRedraw(); } private void OnImageChanged() { - if (_suppress) + if (_suppress || _controller.Selected == null) return; - var zone = _controller.Selected; - if (zone == null) - return; - - zone.Image = _imageEdit.Text.Trim(); - SetDirty(); - _canvas.RebuildHandles(); - _canvas.QueueRedraw(); - RefreshJson(); + _controller.Selected.Image = _imageEdit.Text.Trim(); + _iconList.Select(_controller.Selected.Image); + AfterImageChanged(); } - private void OnIconSelected(long index) + private void OnIconChosen(string id) { - if (_suppress || index < 0 || index >= _textureIds.Count) + if (_suppress || _controller.Selected == null || id == null) return; - var zone = _controller.Selected; - if (zone == null) - return; + _controller.Selected.Image = id; + _suppress = true; + _imageEdit.Text = id; + _suppress = false; + AfterImageChanged(); + } - zone.Image = _textureIds[(int)index]; - _imageEdit.Text = zone.Image; + private void AfterImageChanged() + { + UpdateImagePreview(_controller.Selected); SetDirty(); _canvas.RebuildHandles(); _canvas.QueueRedraw(); @@ -775,17 +791,12 @@ namespace Cthangover.Tools.MapEditor if (zone == null) return; - if (TryParseFloat(_posX.Text, out var mx) && TryParseFloat(_posY.Text, out var my)) - zone.ImagePos = Clamp01(new Godot.Vector2(mx, my)); - - if (zone.LabelPos.HasValue - && TryParseFloat(_labelX.Text, out var lx) - && TryParseFloat(_labelY.Text, out var ly)) - { - zone.LabelPos = Clamp01(new Godot.Vector2(lx, ly)); - } + zone.ImagePos = new Vector2((float)_posX.Value, (float)_posY.Value); + if (zone.LabelPos.HasValue) + zone.LabelPos = new Vector2((float)_labelX.Value, (float)_labelY.Value); SetDirty(); + _canvas.RebuildHandles(); _canvas.QueueRedraw(); RefreshJson(); } @@ -796,21 +807,10 @@ namespace Cthangover.Tools.MapEditor return; _controller.SetLabelEnabled(enabled); - _labelX.Editable = enabled; - _labelY.Editable = enabled; - _suppress = true; - try - { - WritePositionsToSidebar(_controller.Selected); - } - finally - { - _suppress = false; - } SetDirty(); + RefreshValuesOnly(); _canvas.RebuildHandles(); _canvas.QueueRedraw(); - RefreshJson(); } // ── refresh ──────────────────────────────────────────────── @@ -845,31 +845,29 @@ namespace Cthangover.Tools.MapEditor RefreshJson(); } + /// Zone rows are "kind glyph + id"; the row id is the zone index. private void RefreshZoneList() { - _suppress = true; - try + var items = new List<(string, string, Texture2D)>(); + for (var i = 0; i < _controller.Zones.Count; i++) { - _zoneList.Clear(); - foreach (var zone in _controller.Zones) - _zoneList.AddItem(string.IsNullOrEmpty(zone.ID) ? "?" : zone.ID); + var zone = _controller.Zones[i]; + var id = string.IsNullOrEmpty(zone.ID) ? "?" : zone.ID; + items.Add((i.ToString(), KindGlyph(zone) + " " + id, null)); + } - if (_controller.SelectedIndex >= 0) - _zoneList.Select(_controller.SelectedIndex); - } - finally - { - _suppress = false; - } + _zoneList.SetItems(items); + _zoneList.Select(_controller.SelectedIndex >= 0 ? _controller.SelectedIndex.ToString() : null); + _deleteBtn.Disabled = _controller.Selected == null; } - private void UpdateZoneListLabel() + private static string KindGlyph(MapZone zone) { - var index = _controller.SelectedIndex; - if (index < 0 || index >= _zoneList.ItemCount) - return; - - _zoneList.SetItemText(index, _controller.Selected?.ID ?? "?"); + if (zone.Points != null && zone.Points.Count > 0) + return "▱"; + if (!string.IsNullOrEmpty(zone.Image) || !zone.LabelPos.HasValue) + return "◆"; + return "T"; } private void WriteSidebarFromController() @@ -877,53 +875,60 @@ namespace Cthangover.Tools.MapEditor var zone = _controller.Selected; var hasZone = zone != null; - _idEdit.Editable = hasZone; - _titleEdit.Editable = hasZone; - _descEdit.Editable = hasZone; - _imageEdit.Editable = hasZone; - _sceneSelect.Disabled = !hasZone; - _lockedCheck.Disabled = !hasZone; - _hiddenCheck.Disabled = !hasZone; - _labelCheck.Disabled = !hasZone; - _posX.Editable = hasZone; - _posY.Editable = hasZone; - _labelX.Editable = hasZone && zone.LabelPos.HasValue; - _labelY.Editable = hasZone && zone.LabelPos.HasValue; + _propsEmpty.Visible = !hasZone; + _propsBody.Visible = hasZone; if (!hasZone) - { - _idEdit.Text = string.Empty; - _titleEdit.Text = string.Empty; - _descEdit.Text = string.Empty; - _imageEdit.Text = string.Empty; - _titlePreview.Text = string.Empty; - _posX.Text = string.Empty; - _posY.Text = string.Empty; - _labelX.Text = string.Empty; - _labelY.Text = string.Empty; - _labelCheck.ButtonPressed = false; - _availabilityLabel.Text = string.Empty; return; - } _idEdit.Text = zone.ID ?? string.Empty; + _idEdit.RemoveThemeColorOverride("font_color"); _titleEdit.Text = zone.Title ?? string.Empty; _descEdit.Text = zone.Description ?? string.Empty; _imageEdit.Text = zone.Image ?? string.Empty; _lockedCheck.ButtonPressed = zone.Locked; _hiddenCheck.ButtonPressed = zone.Hidden; _labelCheck.ButtonPressed = zone.LabelPos.HasValue; + _labelX.Editable = zone.LabelPos.HasValue; + _labelY.Editable = zone.LabelPos.HasValue; var sceneIndex = _sceneIds.FindIndex(id => string.Equals(id, zone.Scene, StringComparison.OrdinalIgnoreCase)); _sceneSelect.Select(sceneIndex >= 0 ? sceneIndex + 1 : 0); - var iconIndex = _textureIds.FindIndex(id => string.Equals(id, zone.Image, StringComparison.OrdinalIgnoreCase)); - if (iconIndex >= 0) - _iconList.Select(iconIndex); + _iconList.Select(zone.Image); + UpdateImagePreview(zone); + + _posX.Value = zone.ImagePos.X; + _posY.Value = zone.ImagePos.Y; + var labelPos = zone.LabelPos ?? Vector2.Zero; + _labelX.Value = labelPos.X; + _labelY.Value = labelPos.Y; - WritePositionsToSidebar(zone); UpdateTitlePreview(); UpdateAvailability(zone); + ExpandRelevantSections(zone); + } + + /// + /// On a new selection expands only the sections that matter for the + /// zone's kind (polygon → vertices, marker → icon, label → label); the + /// author may open the others by hand, which sticks until the next pick. + /// + private void ExpandRelevantSections(MapZone zone) + { + if (ReferenceEquals(zone, _sectionsShownFor)) + return; + + _sectionsShownFor = zone; + var hasPolygon = zone.Points is { Count: > 0 } || _canvas.Mode == MapEditorCanvas.ModeDraw; + _vertexSection.Expanded = hasPolygon; + _labelSection.Expanded = zone.LabelPos.HasValue; + _markerSection.Expanded = !string.IsNullOrEmpty(zone.Image) || (!hasPolygon && !zone.LabelPos.HasValue); + } + + private void UpdateImagePreview(MapZone zone) + { + _imagePreview.Texture = zone == null || string.IsNullOrEmpty(zone.Image) ? null : Textures.Resolve(zone.Image); } /// @@ -942,31 +947,23 @@ namespace Cthangover.Tools.MapEditor } string state; + var color = UiTokens.DangerText; if (zone.Hidden) - state = Tr("tools/map_editor/effective_hidden"); + state = ToolUi.Tr("tools/map_editor/effective_hidden"); else if (zone.Locked) - state = Tr("tools/map_editor/effective_locked"); + state = ToolUi.Tr("tools/map_editor/effective_locked"); else if (string.IsNullOrEmpty(zone.Scene)) - state = Tr("tools/map_editor/effective_no_scene"); + state = ToolUi.Tr("tools/map_editor/effective_no_scene"); else if (MapZoneRules.IsLocked(zone, true)) - state = Tr("tools/map_editor/effective_unknown_scene"); + state = ToolUi.Tr("tools/map_editor/effective_unknown_scene"); else - state = Tr("tools/map_editor/effective_open"); + { + state = ToolUi.Tr("tools/map_editor/effective_open"); + color = UiTokens.Ok; + } - _availabilityLabel.Text = $"{Tr("tools/map_editor/effective")} {state}"; - } - - private void WritePositionsToSidebar(MapZone zone) - { - if (zone == null) - return; - - _posX.Text = zone.ImagePos.X.ToString("F3", CultureInfo.InvariantCulture); - _posY.Text = zone.ImagePos.Y.ToString("F3", CultureInfo.InvariantCulture); - - var labelPos = zone.LabelPos ?? Godot.Vector2.Zero; - _labelX.Text = labelPos.X.ToString("F3", CultureInfo.InvariantCulture); - _labelY.Text = labelPos.Y.ToString("F3", CultureInfo.InvariantCulture); + _availabilityLabel.Text = $"{ToolUi.Tr("tools/map_editor/effective")} {state}"; + _availabilityLabel.AddThemeColorOverride("font_color", color); } private void UpdateTitlePreview() @@ -980,10 +977,14 @@ namespace Cthangover.Tools.MapEditor var translated = TranslationServer.Translate(key).ToString(); _titlePreview.Text = string.Equals(translated, key, StringComparison.Ordinal) - ? Tr("tools/map_editor/no_translation") - : translated; + ? ToolUi.Tr("tools/map_editor/no_translation") + : "« " + translated + " »"; } + /// + /// One row per vertex with X/Y spin boxes. Handlers read the vertex at + /// change time — capturing the original point made editing Y revert X. + /// private void RefreshVertexList() { foreach (var child in _vertexList.GetChildren()) @@ -996,30 +997,24 @@ namespace Cthangover.Tools.MapEditor for (var i = 0; i < zone.Points.Count; i++) { var index = i; - var point = zone.Points[i]; var row = new HBoxContainer(); + row.AddThemeConstantOverride("separation", UiTokens.Space1); - var xEdit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; - xEdit.Text = point.X.ToString("F3", CultureInfo.InvariantCulture); - xEdit.TextChanged += text => - { - if (TryParseFloat(text, out var value)) - _controller.SetVertex(index, new Godot.Vector2(value, point.Y)); - OnVertexEdited(); - }; - row.AddChild(xEdit); + var number = UiKit.Label((i + 1).ToString(), UiTokens.FontSmall, UiTokens.Faint); + number.CustomMinimumSize = new Vector2(20, 0); + row.AddChild(number); - var yEdit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; - yEdit.Text = point.Y.ToString("F3", CultureInfo.InvariantCulture); - yEdit.TextChanged += text => - { - if (TryParseFloat(text, out var value)) - _controller.SetVertex(index, new Godot.Vector2(point.X, value)); - OnVertexEdited(); - }; - row.AddChild(yEdit); + var xSpin = ToolUi.Number(0, 1, 0.001, null); + xSpin.Value = zone.Points[i].X; + xSpin.ValueChanged += value => OnVertexEdited(index, (float)value, null); + row.AddChild(xSpin); - var deleteBtn = new Button { Text = "X", CustomMinimumSize = new Vector2(32, 0) }; + var ySpin = ToolUi.Number(0, 1, 0.001, null); + ySpin.Value = zone.Points[i].Y; + ySpin.ValueChanged += value => OnVertexEdited(index, null, (float)value); + row.AddChild(ySpin); + + var deleteBtn = UiKit.IconButton("✕", ToolUi.Tr("tools/map_editor/delete_vertex")); deleteBtn.Pressed += () => RemoveVertex(index); row.AddChild(deleteBtn); @@ -1027,8 +1022,14 @@ namespace Cthangover.Tools.MapEditor } } - private void OnVertexEdited() + private void OnVertexEdited(int index, float? x, float? y) { + var zone = _controller.Selected; + if (zone?.Points == null || index >= zone.Points.Count) + return; + + var current = zone.Points[index]; + _controller.SetVertex(index, new Vector2(x ?? current.X, y ?? current.Y)); SetDirty(); _canvas.RebuildHandles(); _canvas.QueueRedraw(); @@ -1039,22 +1040,5 @@ namespace Cthangover.Tools.MapEditor { _jsonEdit.Text = _controller.ToJson(); } - - private void SetStatus(string text) - { - _statusLabel.Text = text ?? string.Empty; - } - - private static string Tr(string key) => TranslationServer.Translate(key); - - private static bool TryParseFloat(string text, out float value) - { - return float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value); - } - - private static Godot.Vector2 Clamp01(Godot.Vector2 value) - { - return new Godot.Vector2(Mathf.Clamp(value.X, 0f, 1f), Mathf.Clamp(value.Y, 0f, 1f)); - } } } diff --git a/src/ScenarioEditor/ScenarioEditTabs.cs b/src/ScenarioEditor/ScenarioEditTabs.cs index be4e2f7..366a112 100644 --- a/src/ScenarioEditor/ScenarioEditTabs.cs +++ b/src/ScenarioEditor/ScenarioEditTabs.cs @@ -2,42 +2,34 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Cthangover.Tools.Common; using Cthangover.Tools.Services; using Cthangover.Core.Utils; using Godot; namespace Cthangover.Core.UI.Tool.ScenarioEditor { - /// State of one open scenario file tab: identity, saved text and dirty flag. - public sealed class TabData - { - public string Key; - public string ModId; - public string RelativePath; - public string FileName; - public string SavedText; - public bool IsDirty; - } - /// /// Tabbed editor area of the scenario editor: a permanent graph tab plus /// one text tab per opened .scenario file, with dirty tracking and a - /// monospace syntax-highlighted code editor. + /// monospace syntax-highlighted code editor with line numbers. Only one + /// exists; switching tabs swaps its text with the + /// tab's , which therefore holds unsaved edits + /// of background tabs. /// public partial class ScenarioEditTabs : VBoxContainer { private const int GraphTabIndex = 0; private TabBar _tabBar; - private TextEdit _textEdit; + private CodeEdit _textEdit; private ScenarioGraphView _graphView; - private Font _monospaceFont; private readonly List _tabs = new(); private int _activeTabIndex = -1; private bool _suppressTextChanged; - public TextEdit CodeEditor => _textEdit; + public CodeEdit CodeEditor => _textEdit; public string CurrentText => _textEdit.Text; public string CurrentKey => _activeTabIndex >= 0 && _activeTabIndex < _tabs.Count ? _tabs[_activeTabIndex].Key : null; @@ -54,10 +46,12 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor public event Action DirtyChanged; public event Action GraphFileRequested; - public ScenarioEditTabs(ScenarioGraphView graphView, Font monospaceFont) + /// Raised on every user edit of the active text tab. + public event Action BufferChanged; + + public ScenarioEditTabs(ScenarioGraphView graphView) { _graphView = graphView; - _monospaceFont = monospaceFont; SizeFlagsHorizontal = SizeFlags.ExpandFill; SizeFlagsVertical = SizeFlags.ExpandFill; @@ -69,15 +63,8 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor _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 = ToolUi.Code(); + _textEdit.SyntaxHighlighter = ScenarioSyntaxService.CreateHighlighter(); _textEdit.TextChanged += OnTextChanged; AddChild(_textEdit); @@ -115,12 +102,13 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor ModId = modId, RelativePath = relativePath, FileName = Path.GetFileNameWithoutExtension(relativePath), - SavedText = text, + Buffer = text, IsDirty = false }; _tabs.Add(tab); _tabBar.AddTab(tab.FileName); + _tabBar.SetTabTooltip(_tabBar.TabCount - 1, ScenarioFileService.GetDisplayPath(modId, relativePath)); SwitchToTextTab(_tabs.Count - 1); } @@ -137,7 +125,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor return; var tab = _tabs[_activeTabIndex]; - tab.SavedText = _textEdit.Text; + tab.Buffer = _textEdit.Text; if (tab.IsDirty) { tab.IsDirty = false; @@ -218,16 +206,16 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor return; if (_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count) - _tabs[_activeTabIndex].SavedText = _textEdit.Text; + _tabs[_activeTabIndex].Buffer = _textEdit.Text; _activeTabIndex = index; _tabBar.CurrentTab = index + 1; var tab = _tabs[index]; - SetTextSilent(tab.SavedText); + SetTextSilent(tab.Buffer); - TextTabSelected?.Invoke(tab.SavedText); + TextTabSelected?.Invoke(tab.Buffer); } private void CloseTextTab(int index) @@ -296,6 +284,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor UpdateTabTitle(_activeTabIndex); UpdateGlobalDirty(); } + BufferChanged?.Invoke(); } private void UpdateTabTitle(int index) @@ -315,7 +304,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor private void SaveActiveTextEditContent() { if (_activeTabIndex >= 0 && _activeTabIndex < _tabs.Count) - _tabs[_activeTabIndex].SavedText = _textEdit.Text; + _tabs[_activeTabIndex].Buffer = _textEdit.Text; } private void OnGraphScenarioFileRequested(string filePath) diff --git a/src/ScenarioEditor/ScenarioEditorWindow.cs b/src/ScenarioEditor/ScenarioEditorWindow.cs index ede46fb..a4a9a70 100644 --- a/src/ScenarioEditor/ScenarioEditorWindow.cs +++ b/src/ScenarioEditor/ScenarioEditorWindow.cs @@ -2,32 +2,38 @@ using System; using Cthangover.Core.Scenes; using Cthangover.Core.UI.Theming; using Cthangover.Core.Utils; +using Cthangover.Mods.Interface.Theming; +using Cthangover.Mods.Interface.Tool; +using Cthangover.Tools.Common; using Cthangover.Tools.Services; using Godot; -using Cthangover.Mods.Interface.Tool; namespace Cthangover.Core.UI.Tool.ScenarioEditor { /// - /// Tool window hosting the scenario editor: a file tree of every mod's - /// .scenario files, tabbed text/graph editors, metadata and thumbnail - /// panels, plus a play-test mode that runs the current scenario in-game. + /// Tool window hosting the scenario editor: a searchable tree of every + /// mod's .scenario files, tabbed code/graph editors, a metadata panel that + /// follows the text as it is typed, background thumbnails, plus a + /// play-test mode that runs the current scenario in-game. + /// + /// Shortcuts: Ctrl+S saves the active file, F5 play-tests it. /// public partial class ScenarioEditorWindow : ToolWindow { + private const double InfoRefreshDelay = 0.4; + private Control _editLayout; - private Control _playLayout; private ScenarioFileTreePanel _fileTreePanel; private ScenarioEditTabs _editTabs; private ScenarioInfoPanel _infoPanel; private ScenarioThumbnailPanel _thumbPanel; + private Timer _infoTimer; private Button _saveBtn; private Button _openFileBtn; private Button _playBtn; private Label _filePathLabel; - private Label _statusLabel; private bool _playActive; private CanvasLayer _stopOverlay; @@ -36,6 +42,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor { CthangoverTheme.Apply(this); BuildUI(); + SetHint(ToolUi.Tr("tools/scenario_editor/hint")); } /// Opens with the graph view so the editor starts on the cross-scene map. @@ -44,10 +51,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor _editTabs.SetViewToGraph(); } - protected override void Cleanup() - { - } - /// Returns from play-test mode as soon as the in-game test play ends. public override void _Process(double delta) { @@ -60,8 +63,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor _editLayout = new Control { AnchorRight = 1f, - AnchorBottom = 1f, - Visible = true + AnchorBottom = 1f }; AddChild(_editLayout); @@ -71,111 +73,70 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor var toolbar = CreateToolbar(); outerVBox.AddChild(toolbar); - _saveBtn = new Button - { - Text = TranslationServer.Translate("tools/scenario_editor/save"), - Disabled = true - }; - _saveBtn.Pressed += OnSavePressed; + _saveBtn = ToolUi.Button("tools/common/save", OnSavePressed, ButtonKind.Primary, "tools/common/save_tip"); 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; + _playBtn = ToolUi.Button("tools/scenario_editor/play", OnPlayPressed, ButtonKind.Secondary, "tools/scenario_editor/play_tooltip"); toolbar.AddChild(_playBtn); + _openFileBtn = ToolUi.Button("tools/scenario_editor/open_file", OnOpenFilePressed, ButtonKind.Secondary, "tools/scenario_editor/open_file_tip"); + toolbar.AddChild(_openFileBtn); + toolbar.AddChild(ToolUi.Divider()); + toolbar.AddChild(ToolUi.Button("tools/scenario_editor/rescan", OnRescanPressed, ButtonKind.Secondary, "tools/scenario_editor/rescan_tip")); - _filePathLabel = new Label { Text = "" }; - _filePathLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + _filePathLabel = new Label + { + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis + }; + _filePathLabel.AddThemeFontOverride("font", ToolUi.Mono()); + _filePathLabel.AddThemeColorOverride("font_color", UiTokens.Muted); + _filePathLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall); toolbar.AddChild(_filePathLabel); - var mainSplit = new HSplitContainer(); - mainSplit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; - mainSplit.SizeFlagsVertical = Control.SizeFlags.ExpandFill; - outerVBox.AddChild(mainSplit); - - var leftPanel = new PanelContainer + var mainSplit = new HSplitContainer { - CustomMinimumSize = new Vector2(340, 0), + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + outerVBox.AddChild(mainSplit); + + var leftVBox = new VBoxContainer { SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + var leftPanel = CreateSidebar(leftVBox, 320); mainSplit.AddChild(leftPanel); - var leftVBox = new VBoxContainer(); - leftPanel.AddChild(leftVBox); - - var tabsContainer = new TabContainer(); - tabsContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; - tabsContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill; + var tabsContainer = new TabContainer + { + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + SizeFlagsVertical = Control.SizeFlags.ExpandFill, + CustomMinimumSize = new Vector2(0, 420) + }; leftVBox.AddChild(tabsContainer); _fileTreePanel = (ScenarioFileTreePanel)TscnScenes.LoadAndBuild("scenes/scenario_file_tree_panel.tscn"); _fileTreePanel.FileSelected += OnFileSelected; tabsContainer.AddChild(_fileTreePanel); - tabsContainer.SetTabTitle(0, TranslationServer.Translate("tools/scenario_editor/tab_files")); + tabsContainer.SetTabTitle(0, ToolUi.Tr("tools/scenario_editor/tab_files")); _infoPanel = (ScenarioInfoPanel)TscnScenes.LoadAndBuild("scenes/scenario_info_panel.tscn"); tabsContainer.AddChild(_infoPanel); - tabsContainer.SetTabTitle(1, TranslationServer.Translate("tools/scenario_editor/tab_info")); - - leftVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/scenario_editor/bg_previews") + ":" }); + tabsContainer.SetTabTitle(1, ToolUi.Tr("tools/scenario_editor/tab_info")); + leftVBox.AddChild(ToolUi.Section("tools/scenario_editor/bg_previews")); _thumbPanel = (ScenarioThumbnailPanel)TscnScenes.LoadAndBuild("scenes/scenario_thumbnail_panel.tscn"); leftVBox.AddChild(_thumbPanel); - var rightPanel = new PanelContainer - { - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - SizeFlagsVertical = Control.SizeFlags.ExpandFill - }; - mainSplit.AddChild(rightPanel); - - _editTabs = new ScenarioEditTabs(new ScenarioGraphView(), GetMonospaceFont()); + _editTabs = new ScenarioEditTabs(new ScenarioGraphView()); _editTabs.TextTabSelected += OnTextTabSelected; _editTabs.GraphTabSelected += OnGraphTabSelected; _editTabs.DirtyChanged += OnDirtyChanged; _editTabs.GraphFileRequested += OnGraphFileRequested; - rightPanel.AddChild(_editTabs); + _editTabs.BufferChanged += () => _infoTimer.Start(); + mainSplit.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); + _infoTimer = new Timer { OneShot = true, WaitTime = InfoRefreshDelay }; + _infoTimer.Timeout += () => _infoPanel.Update(_editTabs.CurrentText); + AddChild(_infoTimer); } private void OnFileSelected(string filePath) @@ -188,15 +149,19 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor _fileTreePanel.SelectFile(filePath); } + private void OnRescanPressed() + { + _fileTreePanel.Reload(); + _editTabs.RefreshGraph(); + SetStatus(ToolUi.Tr("tools/scenario_editor/rescanned")); + } + private void OnTextTabSelected(string text) { - _filePathLabel.Text = _editTabs.CurrentKey != null + var hasFile = _editTabs.CurrentKey != null; + _filePathLabel.Text = hasFile ? ScenarioFileService.GetDisplayPath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath) : ""; - _saveBtn.Disabled = false; - _openFileBtn.Disabled = false; - _playBtn.Disabled = false; - _statusLabel.Text = _editTabs.IsCurrentDirty - ? TranslationServer.Translate("tools/scenario_editor/status_modified") : ""; + SetFileButtonsEnabled(hasFile); _infoPanel.Update(text); _thumbPanel.Load(text); } @@ -204,26 +169,38 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor private void OnGraphTabSelected() { _filePathLabel.Text = ""; - _saveBtn.Disabled = true; - _openFileBtn.Disabled = true; - _playBtn.Disabled = true; - _statusLabel.Text = ""; + SetFileButtonsEnabled(false); _infoPanel.Clear(); _thumbPanel.Clear(); _editTabs.RefreshGraph(); } + private void SetFileButtonsEnabled(bool enabled) + { + _saveBtn.Disabled = !enabled; + _openFileBtn.Disabled = !enabled; + _playBtn.Disabled = !enabled; + } + private void OnDirtyChanged(bool anyDirty) { if (anyDirty) - { SetDirty(); - _statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/status_modified"); - } else - { MarkClean(); - } + } + + /// Ctrl+S. + protected override void OnSaveShortcut() => OnSavePressed(); + + /// F5 play-tests the active scenario. + protected override bool HandleKey(InputEventKey key) + { + if (key.Keycode != Key.F5 || !_editTabs.HasActiveFile) + return false; + + OnPlayPressed(); + return true; } private void OnSavePressed() @@ -238,22 +215,20 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor { if (!ScenarioFileService.WriteAllText(modId, relativePath, text)) { - _statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip"); + SetStatus(ToolUi.Tr("tools/scenario_editor/readonly_zip"), ToolStatusKind.Error); return; } _editTabs.SaveCurrentFile(); - _statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/status_saved"); + SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), ScenarioFileService.GetDisplayPath(modId, relativePath)), ToolStatusKind.Ok); _infoPanel.Update(text); _thumbPanel.Load(text); - _editTabs.RefreshGraph(); GameLogger.Log("SCENARIO_EDITOR", $"Saved: {modId}/{relativePath}"); } 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); + SetStatus(string.Format(ToolUi.Tr("tools/scenario_editor/save_error"), ex.Message), ToolStatusKind.Error); } } @@ -265,14 +240,16 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor var path = ScenarioFileService.GetAbsolutePath(_editTabs.CurrentModId, _editTabs.CurrentRelativePath); if (string.IsNullOrEmpty(path)) { - _statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip"); + SetStatus(ToolUi.Tr("tools/scenario_editor/readonly_zip"), ToolStatusKind.Error); return; } - GD.Print($"[ScenarioEditor] Opening: {path}"); var err = OS.ShellOpen(path); if (err != Error.Ok) - GameLogger.Log("SCENARIO_EDITOR", $"ShellOpen error: {err}", LogLevel.Error); + { + GameLogger.Log("SCENARIO_EDITOR", $"ShellOpen '{path}' failed: {err}", LogLevel.Error); + SetStatus($"{ToolUi.Tr("tools/scenario_editor/open_file_error")}: {err}", ToolStatusKind.Error); + } } private void OnPlayPressed() @@ -285,7 +262,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor if (metadata.Scene == "-") { - _statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/no_scene_error"); + SetStatus(ToolUi.Tr("tools/scenario_editor/no_scene_error"), ToolStatusKind.Error); return; } @@ -299,7 +276,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor } else { - _statusLabel.Text = TranslationServer.Translate("tools/scenario_editor/readonly_zip"); GameLogger.Log("SCENARIO_EDITOR", $"Test play: '{modId}/{relativePath}' is read-only, playing from the editor buffer", LogLevel.Warning); @@ -307,14 +283,10 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor } catch (Exception ex) { - _statusLabel.Text = string.Format( - TranslationServer.Translate("tools/scenario_editor/save_error"), ex.Message); + SetStatus(string.Format(ToolUi.Tr("tools/scenario_editor/save_error"), ex.Message), ToolStatusKind.Error); return; } - _editLayout.Visible = false; - _playLayout.Visible = true; - SceneManager.IsTestPlayActive = true; SceneManager.TestScenarioText = text; @@ -348,8 +320,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor private void ReturnFromPlay() { _playActive = false; - _playLayout.Visible = false; - _editLayout.Visible = true; Visible = true; if (_stopOverlay != null) @@ -360,22 +330,21 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor var sceneService = GetNodeOrNull("/root/GodotSceneService"); sceneService?.SwitchToMenu(); + SetStatus(ToolUi.Tr("tools/scenario_editor/play_stopped")); } + /// Stop button shown over the running game while the scenario is play-tested. private CanvasLayer CreateStopOverlay() { var layer = new CanvasLayer { Layer = 128 }; - var btn = new Button - { - Text = TranslationServer.Translate("tools/scenario_editor/stop"), - CustomMinimumSize = new Vector2(120, 40) - }; - btn.Pressed += OnStopPressed; + var btn = ToolUi.Button("tools/scenario_editor/stop", OnStopPressed, ButtonKind.Danger); + btn.Theme = ToolTheme.Get(); + btn.CustomMinimumSize = new Vector2(120, 36); var margin = new MarginContainer(); - margin.AddThemeConstantOverride("margin_top", 8); - margin.AddThemeConstantOverride("margin_left", 8); + margin.AddThemeConstantOverride("margin_top", UiTokens.Space3); + margin.AddThemeConstantOverride("margin_left", UiTokens.Space3); margin.AddChild(btn); layer.AddChild(margin); diff --git a/src/ScenarioEditor/ScenarioFileTreePanel.cs b/src/ScenarioEditor/ScenarioFileTreePanel.cs index 88a1f83..b3054b1 100644 --- a/src/ScenarioEditor/ScenarioFileTreePanel.cs +++ b/src/ScenarioEditor/ScenarioFileTreePanel.cs @@ -6,82 +6,138 @@ using Godot; namespace Cthangover.Core.UI.Tool.ScenarioEditor { - /// Left-hand tree of every mod's scenario files, grouped by mod and subdirectory; raises on picks. + /// + /// Left-hand tree of every mod's scenario files, grouped by mod and + /// subdirectory, with a search field that keeps only matching files (and + /// expands their groups). Raises with the + /// scenario key on picks. + /// + /// Built from scenario_file_tree_panel.tscn: the tscn builder creates + /// the script node through its constructor before the child nodes exist, + /// so children are looked up lazily () — not in the + /// constructor, which is what used to crash the whole editor on open. + /// public partial class ScenarioFileTreePanel : VBoxContainer { private Tree _fileTree; - private readonly Dictionary _fileItemToKey = new(); - private readonly Dictionary _keyToTreeItem = new(); + private LineEdit _search; + private List _mods = new(); + private readonly Dictionary _itemToKey = new(); + private readonly Dictionary _keyToItem = new(); private bool _isProgrammaticSelection; + private string _selectedKey; public event Action FileSelected; - public ScenarioFileTreePanel() + /// Binds child nodes and fills the tree once the panel enters the tree. + public override void _Ready() { + Reload(); + } + + /// Re-scans the mods' scenario files and rebuilds the tree. + public void Reload() + { + Bind(); + _mods = ScenarioFileService.ScanScenarioFiles(); + Populate(); + } + + private void Bind() + { + if (_fileTree != null) + return; + _fileTree = GetNode("FileTree"); _fileTree.CellSelected += OnCellSelected; - - Populate(); + _search = GetNode("Search"); + _search.PlaceholderText = TranslationServer.Translate("tools/scenario_editor/search_files"); + _search.TextChanged += _ => Populate(); } private void Populate() { _fileTree.Clear(); - _fileItemToKey.Clear(); - _keyToTreeItem.Clear(); + _itemToKey.Clear(); + _keyToItem.Clear(); + var filter = _search.Text.Trim(); var root = _fileTree.CreateItem(); - var mods = ScenarioFileService.ScanScenarioFiles(); - foreach (var mod in mods) + foreach (var mod in _mods) { - var modItem = _fileTree.CreateItem(root); - modItem.SetText(0, $"[{mod.ModId}]"); - modItem.SetSelectable(0, false); - modItem.SetCustomColor(0, UiTokens.Info); - + TreeItem modItem = null; 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); - } - + TreeItem groupItem = null; foreach (var file in group.Files) { - var fileItem = _fileTree.CreateItem(parentItem); + if (filter.Length > 0 && file.RelativePath.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0) + continue; + + if (modItem == null) + { + modItem = _fileTree.CreateItem(root); + modItem.SetText(0, mod.ModId); + modItem.SetSelectable(0, false); + modItem.SetCustomColor(0, UiTokens.AccentBright); + } + + if (groupItem == null) + { + if (string.IsNullOrEmpty(group.DirectoryPath)) + { + groupItem = modItem; + } + else + { + groupItem = _fileTree.CreateItem(modItem); + groupItem.SetText(0, group.DirectoryPath + "/"); + groupItem.SetSelectable(0, false); + groupItem.SetCustomColor(0, UiTokens.Muted); + groupItem.Collapsed = filter.Length == 0; + } + } + + var fileItem = _fileTree.CreateItem(groupItem); fileItem.SetText(0, file.Name); - fileItem.SetMetadata(0, Variant.From("scenario")); - _fileItemToKey[GetItemKey(fileItem)] = file.Key; - _keyToTreeItem[file.Key] = fileItem; + fileItem.SetTooltipText(0, ScenarioFileService.GetDisplayPath(file.ModId, file.RelativePath)); + _itemToKey[fileItem] = file.Key; + _keyToItem[file.Key] = fileItem; } } } + + if (_selectedKey != null && _keyToItem.TryGetValue(_selectedKey, out var selected)) + SelectSilently(selected); } - private static string GetItemKey(TreeItem item) - { - return item.GetInstanceId().ToString(); - } - - /// Programmatically selects the tree row for a scenario key (no selection event). + /// Selects the tree row of a scenario key and raises . public void SelectFile(string key) { - if (!_keyToTreeItem.TryGetValue(key, out var item)) - return; + Bind(); + _selectedKey = key; + if (!_keyToItem.TryGetValue(key, out var item)) + { + _search.Text = string.Empty; + Populate(); + if (!_keyToItem.TryGetValue(key, out item)) + return; + } + + SelectSilently(item); + FileSelected?.Invoke(key); + } + + private void SelectSilently(TreeItem item) + { + for (var parent = item.GetParent(); parent != null; parent = parent.GetParent()) + parent.Collapsed = false; _isProgrammaticSelection = true; item.Select(0); + _fileTree.ScrollToItem(item); _isProgrammaticSelection = false; - - FileSelected?.Invoke(key); } private void OnCellSelected() @@ -90,18 +146,11 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor return; var item = _fileTree.GetSelected(); - if (item == null) + if (item == null || !_itemToKey.TryGetValue(item, out var key)) return; - var meta = item.GetMetadata(0); - if (meta.VariantType != Variant.Type.String || meta.AsString() != "scenario") - return; - - var itemKey = GetItemKey(item); - if (!_fileItemToKey.TryGetValue(itemKey, out var fileKey)) - return; - - FileSelected?.Invoke(fileKey); + _selectedKey = key; + FileSelected?.Invoke(key); } } } diff --git a/src/ScenarioEditor/ScenarioGraphLayout.cs b/src/ScenarioEditor/ScenarioGraphLayout.cs new file mode 100644 index 0000000..352d3f8 --- /dev/null +++ b/src/ScenarioEditor/ScenarioGraphLayout.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Core.UI.Tool.ScenarioEditor +{ + /// + /// Layered left-to-right layout of the scene graph: a scene's column is + /// its distance (in scene switches) from the start scenes — the + /// start_scene node, or every scene nothing switches to when there + /// is none — so the story reads from left to right. Scenes no start + /// reaches are stacked in one extra column on the right. Inside a column + /// scenes are sorted by name, which keeps the picture stable between + /// rebuilds. + /// + public static class ScenarioGraphLayout + { + private const float ColumnWidth = 300f; + private const float RowHeight = 130f; + private const float Margin = 40f; + + /// Canvas position per lower-cased scene name. + public static Dictionary Compute(SceneGraphData data) + { + var names = data.Nodes.Select(n => n.SceneName.ToLowerInvariant()).Distinct().ToList(); + var outgoing = names.ToDictionary(n => n, _ => new List()); + var incoming = new HashSet(); + foreach (var edge in data.Edges) + { + var from = edge.FromScene.ToLowerInvariant(); + var to = edge.ToScene.ToLowerInvariant(); + if (!outgoing.ContainsKey(from) || !outgoing.ContainsKey(to) || from == to) + continue; + outgoing[from].Add(to); + incoming.Add(to); + } + + var starts = data.Nodes.Where(n => n.IsStartScene).Select(n => n.SceneName.ToLowerInvariant()).ToList(); + if (starts.Count == 0) + starts = names.Where(n => !incoming.Contains(n)).ToList(); + + var depth = new Dictionary(); + var queue = new Queue(); + foreach (var start in starts) + { + depth[start] = 0; + queue.Enqueue(start); + } + while (queue.Count > 0) + { + var current = queue.Dequeue(); + foreach (var next in outgoing[current]) + { + if (depth.ContainsKey(next)) + continue; + depth[next] = depth[current] + 1; + queue.Enqueue(next); + } + } + + var unreachableColumn = depth.Count > 0 ? depth.Values.Max() + 1 : 0; + var result = new Dictionary(); + foreach (var column in names.GroupBy(n => depth.TryGetValue(n, out var d) ? d : unreachableColumn).OrderBy(g => g.Key)) + { + var row = 0; + foreach (var name in column.OrderBy(n => n, StringComparer.Ordinal)) + result[name] = new Vector2(Margin + column.Key * ColumnWidth, Margin + row++ * RowHeight); + } + return result; + } + } +} diff --git a/src/ScenarioEditor/ScenarioGraphView.cs b/src/ScenarioEditor/ScenarioGraphView.cs index 58ce70e..deae56a 100644 --- a/src/ScenarioEditor/ScenarioGraphView.cs +++ b/src/ScenarioEditor/ScenarioGraphView.cs @@ -41,23 +41,22 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor _graph.NodeDeselected += OnNodeDeselected; AddChild(_graph); - var panel = new VBoxContainer { CustomMinimumSize = new Vector2(250, 0) }; + var panel = new VBoxContainer { CustomMinimumSize = new Vector2(260, 0) }; panel.SizeFlagsVertical = SizeFlags.ExpandFill; AddChild(panel); - panel.AddChild(new Label - { - Text = TranslationServer.Translate("tools/scenario_editor/graph_select_node"), - AutowrapMode = TextServer.AutowrapMode.WordSmart - }); + var hint = UiKit.Hint(TranslationServer.Translate("tools/scenario_editor/graph_select_node")); + hint.AutowrapMode = TextServer.AutowrapMode.WordSmart; + panel.AddChild(hint); _infoLabel = new RichTextLabel { BbcodeEnabled = true, - FitContent = true, - ScrollFollowing = true, SizeFlagsVertical = SizeFlags.ExpandFill }; + _infoLabel.AddThemeFontSizeOverride("normal_font_size", UiTokens.FontBody); + _infoLabel.AddThemeFontSizeOverride("bold_font_size", UiTokens.FontBodyLarge); + _infoLabel.AddThemeColorOverride("default_color", UiTokens.Body); _infoLabel.MetaClicked += OnLinkClicked; panel.AddChild(_infoLabel); } @@ -71,13 +70,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor _nodes = new Dictionary(); _lookup = new Dictionary(); - int x = 40, y = 40; - foreach (var pos in savedPositions.Values) - { - int bottom = (int)pos.Y + 160 + 20; - if (bottom > y) - y = bottom; - } + var layout = ScenarioGraphLayout.Compute(data); foreach (var nodeData in data.Nodes) { @@ -90,7 +83,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor var position = savedPositions.TryGetValue(key, out var saved) ? saved - : new Vector2(x, y); + : layout[key]; var gn = new GraphNode { @@ -99,12 +92,15 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor PositionOffset = position }; - gn.AddChild(new Label + var nodeLabel = new Label { - Text = $"Mod: {nodeData.ModId}\nScenarios: {nodeData.ScenarioCount}" + - (bgCount > 0 ? $"\nBg: {firstBg}" + + Text = string.Format(Tr("tools/scenario_editor/graph_node"), nodeData.ModId, nodeData.ScenarioCount) + + (bgCount > 0 ? "\n" + string.Format(Tr("tools/scenario_editor/graph_node_bg"), firstBg) + (bgCount > 1 ? $" +{bgCount - 1}" : "") : "") - }); + }; + nodeLabel.AddThemeColorOverride("font_color", UiTokens.Body); + nodeLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall); + gn.AddChild(nodeLabel); var color = UiTokens.White; if (nodeData.IsStartScene) @@ -123,11 +119,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor _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) @@ -138,6 +129,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor continue; _graph.ConnectNode(_nodes[srcK].Name, 0, _nodes[dstK].Name, 0); } + } private Dictionary SaveNodePositions() @@ -181,12 +173,12 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor return; var sb = new System.Text.StringBuilder(); - sb.AppendLine($"Scene: [b]{nodeData.SceneName}[/b]"); - sb.AppendLine($"Mod: {nodeData.ModId}"); + sb.AppendLine($"[b]{nodeData.SceneName}[/b]"); + sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_detail_mod"), nodeData.ModId)); if (nodeData.Backgrounds.Count > 0) - sb.AppendLine($"Backgrounds: {string.Join(", ", nodeData.Backgrounds)}"); + sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_node_bg"), string.Join(", ", nodeData.Backgrounds))); sb.AppendLine(); - sb.AppendLine($"Scenarios ({nodeData.Scenarios.Count}):"); + sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_detail_scenarios"), nodeData.Scenarios.Count)); foreach (var sc in nodeData.Scenarios.OrderBy(s => s.Priority)) { @@ -202,6 +194,8 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor _infoLabel.Text = ""; } + private static string Tr(string key) => TranslationServer.Translate(key); + private void OnLinkClicked(Variant meta) { var path = Uri.UnescapeDataString(meta.AsString()); diff --git a/src/ScenarioEditor/ScenarioInfoPanel.cs b/src/ScenarioEditor/ScenarioInfoPanel.cs index ae0f64b..1e9535d 100644 --- a/src/ScenarioEditor/ScenarioInfoPanel.cs +++ b/src/ScenarioEditor/ScenarioInfoPanel.cs @@ -1,5 +1,7 @@ using System.Linq; using Cthangover.Tools.Services; +using Cthangover.Core.UI.Theming; +using Cthangover.Mods.Interface.Tool; using Godot; namespace Cthangover.Core.UI.Tool.ScenarioEditor @@ -14,26 +16,59 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor private Label _infoLocaleKeys; private Label _infoQuestRefs; - public ScenarioInfoPanel() + /// Binds the rows once the panel enters the tree (see ). + public override void _Ready() { - GetNode