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