Tool windows redesign and the Novel Editor workspace

Tool windows:
- Every editor (scenario, scene builder, map, interactive objects,
  lighting, catalog export) is rebuilt on the interface ToolWindow chrome
  and the denser ToolTheme: header with title/close, status bar, Ctrl+S,
  dirty-state confirmation.
- Shared building blocks in src/Common: ToolUi (rows, buttons, sections),
  CollapsibleSection, FilteredList, ModFileSelector, DragHandle and
  BackgroundCanvas (scene background with pan/zoom).
- The map, interactive and light editors draw on canvases
  (MapEditorCanvas, InteractiveEditorCanvas, LightEditorCanvas) instead of
  one Control "handle" node per object; the *Handle classes are removed.
- Scenario editor: tab data model (TabData), automatic graph layout
  (ScenarioGraphLayout), file tree/info/thumbnail panel updates.
- Services: ModFileService (mod file listing and writing),
  SceneBackgroundService; MapFileService/ModResourceService follow.
- Catalog export: per-language name/description editing.
- Locale (en/ru) regrouped under tools/*; unused keys removed.

Novel Editor:
- collections/novel_editor.json: a "game" whose entry scene is an
  authoring workspace over the novel engine and the story content.
- NovelEditorHub (scenes/novel_editor_hub.tscn) lists every installed
  tool through IToolProvider.AddMenuButton; ToolsInitializer registers it
  as the logical scene "novel_editor".
This commit is contained in:
cthangover committed 2026-10-17 14:29:14 +00:00
1 parent 578ba7c5d5
commit 8742aa3da5
45 files changed
+4717 -3462

No files matched your search

+50
View File
@@ -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"]
}
]
}
+3
View File
@@ -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
+3
View File
@@ -1,2 +1,5 @@
# Catalog export tool — дополнительные ключи
mods/catalog_select_mod = Выберите мод:
mods/catalog_add_locale = Добавить язык
mods/catalog_locale_name = Название на этом языке
mods/catalog_locale_desc = Описание на этом языке
+224 -150
View File
@@ -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
+224 -149
View File
@@ -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 = Главное меню
+12
View File
@@ -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")
+4
View File
@@ -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
+3 -2
View File
@@ -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"]
+137 -270
View File
@@ -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
{
/// <summary>
@@ -17,11 +18,11 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
/// mods menu so that mod-management UI can live in the <c>mod_manager</c>
/// mod while this authoring utility stays a tool.
/// <para/>
/// 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 <see cref="ModCatalogExporter"/>.
/// 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
/// <see cref="ModCatalogExporter"/> (archive and hashes are computed there).
/// </summary>
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<CheckBox> _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<CatalogEntryDownload>
{
new()
{
Version = entry.Version,
Urls = new List<string> { _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<CatalogLocaleEntry>();
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<string> { _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<string> 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);
}
}
}
+137
View File
@@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// 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".
/// <para/>
/// Subclasses paint their overlay in <see cref="DrawContent"/> and rebuild
/// their <see cref="DragHandle"/>s in <see cref="RebuildHandles"/>, which is
/// also called whenever the canvas is resized or the background changes.
/// </summary>
public abstract partial class BackgroundCanvas : Control
{
private static readonly Vector2 FallbackSize = new(1920, 1024);
/// <summary>Live handles of the current selection.</summary>
protected readonly List<DragHandle> _handles = new();
/// <summary>Reference background; <c>null</c> draws a flat placeholder.</summary>
public Texture2D Background { get; private set; }
protected BackgroundCanvas()
{
MouseFilter = MouseFilterEnum.Stop;
ClipContents = true;
Resized += OnResized;
Connect("draw", Callable.From((Action)DrawAll));
}
/// <summary>Assigns the background texture and relayouts handles.</summary>
public void SetBackground(Texture2D texture)
{
Background = texture;
RebuildHandles();
QueueRedraw();
}
/// <summary>Recreates the handles from the current selection.</summary>
public abstract void RebuildHandles();
/// <summary>Paints the overlay above the background inside <paramref name="rect"/>.</summary>
protected abstract void DrawContent(Rect2 rect);
/// <summary>
/// Aspect-preserving rectangle the background occupies inside the
/// canvas — identical to the runtime "fit" layout.
/// </summary>
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);
}
/// <summary>Canvas-local pixel of a normalised point.</summary>
public Vector2 NormToLocal(Vector2 normalized)
{
var rect = GetBackgroundRect();
return rect.Position + normalized * rect.Size;
}
/// <summary>Normalised point of a canvas-local pixel (not clamped).</summary>
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;
}
/// <summary>Clamps both axes into [0..1].</summary>
public static Vector2 Clamp01(Vector2 value)
{
return new Vector2(Mathf.Clamp(value.X, 0f, 1f), Mathf.Clamp(value.Y, 0f, 1f));
}
/// <summary>Frees every handle.</summary>
protected void ClearHandles()
{
foreach (var handle in _handles)
handle.QueueFree();
_handles.Clear();
}
/// <summary>
/// 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). <paramref name="drag"/>
/// receives the clamped normalised point while dragging.
/// </summary>
protected DragHandle AddHandle(Vector2 normalized, Color color, int index, Action<Vector2> 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);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Sidebar section with a clickable uppercase caption (style guide
/// <c>.ct-section-title</c> plus a ▸/▾ marker) that shows or hides its
/// <see cref="Body"/>. 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.
/// </summary>
public partial class CollapsibleSection : VBoxContainer
{
private readonly Button _header;
private readonly string _title;
/// <summary>Container for the section's controls.</summary>
public VBoxContainer Body { get; }
/// <summary>Whether the body is shown.</summary>
public bool Expanded
{
get => Body.Visible;
set
{
Body.Visible = value;
_header.Text = (value ? "▾ " : "▸ ") + _title;
}
}
/// <summary>Creates a section titled by <paramref name="titleKey"/>.</summary>
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;
}
}
}
+102
View File
@@ -0,0 +1,102 @@
using System;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// 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 <c>GuiInput</c> event and drawing through the <c>draw</c>
/// signal — the forms that dispatch reliably for mod assemblies.
/// <para/>
/// <see cref="Index"/> is free for the owner: a vertex index or a negative
/// role constant (marker, label, rect corner...).
/// </summary>
public partial class DragHandle : Control
{
/// <summary>Owner-defined identity of the handle.</summary>
public int Index;
/// <summary>Called while dragging with the handle centre in global coordinates.</summary>
public Action<Vector2> DragUpdate;
/// <summary>Called on left press.</summary>
public Action Clicked;
/// <summary>Called on left release after a press.</summary>
public Action DragEnd;
/// <summary>Called on right press.</summary>
public Action RightClicked;
private readonly Color _fill;
private bool _dragging;
private bool _hovered;
private Vector2 _dragOffset;
/// <summary>Creates a handle of the given fill colour, role index and diameter.</summary>
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));
}
/// <summary>Places the handle so that its centre sits at <paramref name="globalCenter"/>; call after it is in the tree.</summary>
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();
}
}
}
}
+136
View File
@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// 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 <see cref="Chosen"/> /
/// <see cref="Activated"/> with the id. <see cref="Select"/> is silent so
/// windows can mirror their model into the list without feedback loops.
/// </summary>
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<string> _visibleIds = new();
private string _selectedId;
private bool _silent;
/// <summary>Raised when the user selects a row (click or keyboard).</summary>
public event Action<string> Chosen;
/// <summary>Raised on double-click / Enter.</summary>
public event Action<string> Activated;
/// <summary>Currently selected id, or <c>null</c>.</summary>
public string SelectedId => _selectedId;
/// <summary>Creates the list; <paramref name="minHeight"/> is the list part's minimum height.</summary>
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);
}
/// <summary>Turns rows into an icon grid of <paramref name="iconSize"/> px thumbnails.</summary>
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;
}
/// <summary>Replaces all items; keeps the current selection when its id is still present.</summary>
public void SetItems(IEnumerable<(string Id, string Text, Texture2D Icon)> items)
{
_items.Clear();
_items.AddRange(items);
Rebuild();
}
/// <summary>Replaces all items with text-only rows where text equals id.</summary>
public void SetItems(IEnumerable<string> ids)
{
_items.Clear();
foreach (var id in ids)
_items.Add((id, id, null));
Rebuild();
}
/// <summary>Selects the row with <paramref name="id"/> (null clears) without raising events.</summary>
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;
}
/// <summary>Number of items before filtering.</summary>
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;
}
}
+180
View File
@@ -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
{
/// <summary>
/// Toolbar group "mod ▾ file ▾ +" for editors that own one kind of file
/// inside a mod folder (<c>map/*.json</c>, <c>interactives/*.json</c>).
/// 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.
/// <para/>
/// The selector never loads anything itself: it raises
/// <see cref="FileRequested"/> and the window decides (usually after an
/// "unsaved changes" confirmation). When the window declines, it calls
/// <see cref="Show"/> with the file still open to roll the dropdowns back.
/// A <c>null</c> path means "this mod has no such file yet".
/// </summary>
public partial class ModFileSelector : HBoxContainer
{
private readonly string _directory;
private readonly string _extension;
private readonly OptionButton _modSelect;
private readonly OptionButton _fileSelect;
private readonly List<string> _mods = new();
private readonly List<string> _files = new();
private bool _silent;
/// <summary>Raised with (modId, mod-relative path or null) when the author picks or creates a file.</summary>
public event Action<string, string> FileRequested;
/// <summary>Mod shown in the selector.</summary>
public string CurrentMod => _modSelect.Selected >= 0 && _modSelect.Selected < _mods.Count ? _mods[_modSelect.Selected] : null;
/// <summary>File shown in the selector, or <c>null</c>.</summary>
public string CurrentPath => _fileSelect.Selected >= 0 && _fileSelect.Selected < _files.Count ? _files[_fileSelect.Selected] : null;
/// <summary>Creates the selector for files in <paramref name="directory"/> with <paramref name="extension"/>.</summary>
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);
}
/// <summary>Re-reads mods and files and requests the first file of the first mod that has any.</summary>
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);
}
/// <summary>Silently shows <paramref name="modId"/>/<paramref name="path"/> (a not-yet-saved path is added).</summary>
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);
}
}
}
+145
View File
@@ -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
{
/// <summary>
/// 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 <see cref="UiKit"/>. 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.
/// </summary>
public static class ToolUi
{
/// <summary>Width of the label column in <see cref="Field"/> rows.</summary>
public const int LabelWidth = 110;
/// <summary>Translates a locale key.</summary>
public static string Tr(string key) => TranslationServer.Translate(key);
/// <summary>Uppercase muted section caption (style guide <c>.ct-section-title</c>) with a little air above.</summary>
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;
}
/// <summary>
/// Adds a "label | control" row to <paramref name="parent"/>: the label is
/// muted, fixed width and carries the optional tooltip; the control fills
/// the rest. Returns the control for chaining.
/// </summary>
public static T Field<T>(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;
}
/// <summary>
/// Numeric field. Values are typed and shown culture-independently by
/// <see cref="SpinBox"/>, which also clamps and steps them, so the editors
/// no longer parse free text ("0,5" vs "0.5").
/// </summary>
public static SpinBox Number(double min, double max, double step, Action<double> 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;
}
/// <summary>Single-line text field with a placeholder and an optional change handler.</summary>
public static LineEdit Text(string placeholder = "", Action<string> changed = null)
{
var edit = new LineEdit { PlaceholderText = placeholder ?? string.Empty };
if (changed != null)
edit.TextChanged += text => changed(text);
return edit;
}
/// <summary>Token button with a localized caption, optional tooltip and kind.</summary>
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;
}
/// <summary>Thin vertical divider between toolbar groups.</summary>
public static VSeparator Divider() => new() { CustomMinimumSize = new Vector2(UiTokens.Space3, 0) };
/// <summary>Faint helper text that wraps inside narrow sidebars.</summary>
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;
}
/// <summary>Monospace font for IDs, numbers and code (style guide <c>--ct-mono</c>).</summary>
public static Font Mono() => ToolWindow.GetMonospaceFont();
/// <summary>Code editor preset: monospace, line numbers, current-line highlight, no wrap.</summary>
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;
}
}
}
+79
View File
@@ -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;
/// <summary>
/// Launcher screen of the <c>novel_editor</c> collection, entered as its
/// logical entry scene. Lists every registered tool the same way the main
/// menu does — each <see cref="IToolProvider"/> appends its own button via
/// <see cref="IToolProvider.AddMenuButton"/> — 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).
/// </summary>
public partial class NovelEditorHub : Control
{
/// <summary>Logical scene name; the collection's <c>entry.scene</c>.</summary>
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<GodotSceneService>("/root/GodotSceneService")?.SwitchToMenu();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace Cthangover.Tools.Hub
{
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
/// <summary>
/// Lifecycle entry point of the tools mod. Publishes the logical scene
/// <see cref="NovelEditorHub.SceneName"/>, the entry scene of the
/// <c>novel_editor</c> 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.
/// </summary>
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);
}
}
}
@@ -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
{
/// <summary>
/// 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:
/// <list type="bullet">
/// <item>rect — top-left and bottom-right corners plus a centre handle
/// that moves the whole rect;</item>
/// <item>circle — centre and a radius handle;</item>
/// <item>polygon — one handle per vertex (right-click deletes it); a click
/// on empty background appends a vertex.</item>
/// </list>
/// Geometry is in the same normalised background space as
/// <c>InteractiveObject</c>: x/y/width/height are fractions of the
/// background size, a circle radius is a fraction of its shorter side.
/// </summary>
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;
/// <summary>Data being edited; set once by the window.</summary>
public InteractiveEditorController Controller;
/// <summary>Raised whenever geometry of the selected item changes.</summary>
public event Action Changed;
/// <summary>Raised when a click on the canvas selects another item (-1: none).</summary>
public event Action<int> ItemPicked;
public InteractiveEditorCanvas()
{
GuiInput += OnGuiInput;
}
/// <summary>Recreates the handles of the selected item's collider.</summary>
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<Vector2>();
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(); };
}
/// <summary>Keeps the other handles glued to the shape while one is dragged.</summary>
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<Vector2>();
selected.HitArea.Vertices = vertices.Append(Clamp01(norm)).ToArray();
RebuildHandles();
QueueRedraw();
Changed?.Invoke();
}
AcceptEvent();
}
/// <summary>Topmost item whose collider contains the normalised point, or -1.</summary>
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<Vector2>();
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);
}
}
}
@@ -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
{
/// <summary>
/// Data model for the interactive editor. Holds interactive definition
/// fields, manages collider vertex list for polygon editing, and produces
/// the output JSON.
/// </summary>
public class InteractiveEditorController
{
/// <summary>Unique identifier for the interactive object resource.</summary>
public string Id { get; set; } = "new_interactive";
/// <summary>Texture key mapped to a sprite resource by the resource system.</summary>
public string Texture { get; set; } = "";
/// <summary>Render layer: <c>"foreground"</c>, <c>"background"</c>, or <c>"ui"</c>.</summary>
public string Layer { get; set; } = "foreground";
/// <summary>Z-index for draw order within the layer.</summary>
public int ZIndex { get; set; }
/// <summary>Whether the interactive is initially enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Whether the interactive is visible.</summary>
public bool Visible { get; set; } = true;
/// <summary>Mouse cursor override name, e.g. <c>"PointingHand"</c>. Empty for default.</summary>
public string Cursor { get; set; } = "";
/// <summary>Collider shape type: <c>"rect"</c>, <c>"circle"</c>, or <c>"polygon"</c>.</summary>
public string HitType { get; set; } = "rect";
/// <summary>
/// Data model of the interactive editor: every definition of one
/// <c>interactives/*.json</c> 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. <see cref="Ensure"/> fills the optional sub-objects the editor
/// needs (hit area, highlight, actions) before a definition is shown.
/// </summary>
public class InteractiveEditorController
{
/// <summary>Collider kinds understood by <c>InteractiveObject</c>.</summary>
public static readonly string[] HitTypes = { "rect", "circle", "polygon" };
/// <summary>Normalised X position of the rect collider (0–1).</summary>
public float RectX { get; set; }
/// <summary>Normalised Y position of the rect collider (0–1).</summary>
public float RectY { get; set; }
/// <summary>Normalised width of the rect collider (0–1).</summary>
public float RectW { get; set; } = 0.1f;
/// <summary>Normalised height of the rect collider (0–1).</summary>
public float RectH { get; set; } = 0.1f;
/// <summary>Render layers understood by the interactive layer host.</summary>
public static readonly string[] Layers = { "foreground", "background", "ui" };
/// <summary>Normalised X centre of the circle collider (0–1).</summary>
public float CircleX { get; set; } = 0.5f;
/// <summary>Normalised Y centre of the circle collider (0–1).</summary>
public float CircleY { get; set; } = 0.5f;
/// <summary>Normalised radius of the circle collider (0–1).</summary>
public float CircleRadius { get; set; } = 0.05f;
/// <summary>Definitions of the open file, in file order.</summary>
public List<InteractiveDefinition> Items { get; private set; } = new();
/// <summary>List of normalised (0–1) vertices defining the polygon collider.</summary>
public List<Vector2> PolygonVertices { get; set; } = new();
/// <summary>Index of the selected definition, or -1.</summary>
public int SelectedIndex { get; private set; } = -1;
/// <summary>Hex colour string (e.g. <c>"#FFFF0033"</c>) for the highlight overlay.</summary>
public string HighlightColorHex { get; set; } = "#FFFF0033";
/// <summary>Scale multiplier applied to the sprite when highlighted.</summary>
public float HighlightScale { get; set; } = 1.02f;
/// <summary>The selected definition, or <c>null</c>.</summary>
public InteractiveDefinition Selected => SelectedIndex >= 0 && SelectedIndex < Items.Count ? Items[SelectedIndex] : null;
/// <summary>Scenario file path executed on click.</summary>
public string OnClickScenario { get; set; } = "";
/// <summary>Inline DSL commands executed on click, one per line.</summary>
public string OnClickCommands { get; set; } = "";
/// <summary>DSL commands executed when the mouse enters the interactive area.</summary>
public string OnHoverEnter { get; set; } = "";
/// <summary>DSL commands executed when the mouse leaves the interactive area.</summary>
public string OnHoverLeave { get; set; } = "";
/// <summary>Replaces the items with the contents of a <c>{"Items": [...]}</c> file. Throws on malformed JSON.</summary>
public void FromJson(string json)
{
var data = string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<FileData<InteractiveDefinition>>(json, JsonOptionsProvider.ReadOptions);
Items = data?.Items ?? new List<InteractiveDefinition>();
foreach (var item in Items)
Ensure(item);
SelectedIndex = Items.Count > 0 ? 0 : -1;
}
/// <summary>Builds an InteractiveDefinition from current state.</summary>
public InteractiveDefinition ToDefinition()
{
var hitArea = new HitAreaDefinition { Type = HitType };
/// <summary>Serialises all items into the standard envelope (indented, nulls omitted).</summary>
public string ToJson()
{
var data = new FileData<InteractiveDefinition> { 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;
}
/// <summary>Drops all items.</summary>
public void Clear()
{
Items = new List<InteractiveDefinition>();
SelectedIndex = -1;
}
var clickAction = new ClickAction();
/// <summary>Selects by index; out of range clears the selection.</summary>
public void Select(int index)
{
SelectedIndex = index >= 0 && index < Items.Count ? index : -1;
}
if (!string.IsNullOrWhiteSpace(OnClickScenario))
clickAction.Scenario = OnClickScenario.Trim();
/// <summary>Appends a new clickable rect in the middle of the background and selects it.</summary>
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<string>();
foreach (var line in lines)
{
var trimmed = line.Trim();
if (!string.IsNullOrEmpty(trimmed))
cleanLines.Add(trimmed);
}
if (cleanLines.Count > 0)
clickAction.Commands = cleanLines.ToArray();
}
/// <summary>Appends a deep copy of the selected definition with a fresh ID, slightly offset.</summary>
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<InteractiveDefinition>(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;
}
/// <summary>Loads state from an InteractiveDefinition.</summary>
public void FromDefinition(InteractiveDefinition def)
{
if (def == null) return;
/// <summary>Removes the selected definition and keeps a sensible selection.</summary>
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<Vector2>(def.HitArea.Vertices)
: new List<Vector2>();
}
else
{
HitType = "rect";
}
/// <summary>
/// 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).
/// </summary>
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 ?? "";
}
/// <summary>
/// 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).
/// </summary>
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);
}
}
/// <summary>Serializes the definition to formatted JSON (FileData envelope).</summary>
public string ToJson()
{
var def = ToDefinition();
var wrapper = new
{
Items = new[] { def }
};
/// <summary>Makes sure the optional parts the editor binds to exist.</summary>
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
};
/// <summary>ID not used by another item: <c>base</c>, <c>base_2</c>, <c>base_3</c>…</summary>
public string MakeUniqueId(string baseId)
{
var used = new HashSet<string>(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}";
}
/// <summary>Parses JSON back into the controller. Returns false on failure.</summary>
public bool FromJson(string json)
{
try
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip };
var wrapper = JsonSerializer.Deserialize<FileDataWrapper>(json, options);
if (wrapper?.Items != null && wrapper.Items.Count > 0)
{
FromDefinition(wrapper.Items[0]);
return true;
}
return false;
}
catch
{
return false;
}
}
private class FileDataWrapper
{
public List<InteractiveDefinition> Items { get; set; }
}
/// <summary>Updates a polygon vertex at the given index.</summary>
public void SetPolygonVertex(int index, Vector2 value)
{
while (PolygonVertices.Count <= index)
PolygonVertices.Add(Vector2.Zero);
PolygonVertices[index] = value;
}
/// <summary>Removes a polygon vertex at the given index.</summary>
public void RemovePolygonVertex(int index)
{
if (index >= 0 && index < PolygonVertices.Count)
PolygonVertices.RemoveAt(index);
}
/// <summary>Adds a new polygon vertex at the default position (center of viewport).</summary>
public void AddPolygonVertex()
{
var offset = PolygonVertices.Count * 0.04f;
PolygonVertices.Add(new Vector2(0.5f + offset, 0.5f + offset));
}
}
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();
}
}
}
@@ -1,64 +0,0 @@
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.InteractiveEditor
{
/// <summary>
/// Draggable 16x16 handle representing a collider vertex on the preview area.
/// Used for rect corners, circle radius marker, and polygon vertices.
/// Subscribes to the GuiInput C# event rather than overriding _GuiInput
/// for reliable event delivery in mod assemblies.
/// </summary>
public partial class InteractiveEditorHandle : ColorRect
{
/// <summary>Index of the vertex this handle represents (0-based).</summary>
public int VertexIndex;
/// <summary>Invoked during drag with the centre position of the handle in global coordinates.</summary>
public System.Action<Vector2> DragUpdate;
/// <summary>Invoked when the handle is clicked (mouse down).</summary>
public System.Action Clicked;
/// <summary>Invoked when the drag ends (mouse up). Used to sync sidebar values.</summary>
public System.Action DragEnd;
private bool _dragging;
private Vector2 _dragOffset;
/// <summary>Creates a handle with the given colour, size 16×16, and index for vertex identification.</summary>
public InteractiveEditorHandle(Color color, int index)
{
VertexIndex = index;
Size = new Vector2(16, 16);
Color = color;
MouseFilter = Control.MouseFilterEnum.Stop;
MouseDefaultCursorShape = Control.CursorShape.PointingHand;
GuiInput += OnGuiInput;
}
private void OnGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
{
_dragging = true;
_dragOffset = GetGlobalMousePosition() - GlobalPosition;
GameLogger.Log("INTERACTIVE_HANDLE", $"drag start idx={VertexIndex} pos={GlobalPosition}");
Clicked?.Invoke();
}
else
{
_dragging = false;
GameLogger.Log("INTERACTIVE_HANDLE", $"drag end idx={VertexIndex}");
DragEnd?.Invoke();
}
AcceptEvent();
}
else if (@event is InputEventMouseMotion && _dragging)
{
var center = GlobalPosition + Size / 2;
GlobalPosition = GetGlobalMousePosition() - _dragOffset;
DragUpdate?.Invoke(center);
}
}
}
}
File diff suppressed because it is too large. Load diff
+129
View File
@@ -0,0 +1,129 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>
/// Night-time preview of a scene with its static lights. The background is
/// drawn through the game's <c>timed_sprite</c> 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.
/// <para/>
/// A click on a handle selects that light and drags it; a double-click on
/// empty background adds a light there.
/// </summary>
public partial class LightEditorCanvas : BackgroundCanvas
{
private readonly Control _overlay;
/// <summary>Data being edited; set once by the window.</summary>
public LightEditorController Controller;
/// <summary>Preview material (may be null when the shader is missing).</summary>
public ShaderMaterial PreviewMaterial
{
get => Material as ShaderMaterial;
set => Material = value;
}
/// <summary>Raised when a light moved or was added on the canvas.</summary>
public event Action Changed;
/// <summary>Raised when the selection changed on the canvas.</summary>
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;
}
/// <summary>One handle per light, coloured like the light.</summary>
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();
}
/// <summary>Redraws the preview and re-uploads the shader uniforms.</summary>
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);
}
}
}
}
+173 -147
View File
@@ -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
{
/// <summary>
/// Data controller for the light editor tool. Maintains a list of
/// <see cref="LightDef"/> objects and feeds up to 10 lights plus one
/// sentinel entry into the <c>"timed_sprite"</c> shader material for
/// real-time preview. Exports lights as JSON (<see cref="ExportLightsJson"/>)
/// or as a scenario DSL snippet (<see cref="ExportScenarioSnippet"/>).
/// Uses a dirty-flag pattern so shader uniform updates only occur when
/// the light list changes.
/// Data of the light editor: the static lights of one scenario and their
/// place in the scenario text. Static lights live in a <c>light_set</c>
/// line (<c>light_set [{"x":…,"y":…,"radius":…,"influence":…,"color":…}]</c>)
/// that <c>UiLightController.SetStaticLights</c> 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 <c>timed_sprite</c> shader of the preview the same way the
/// game does, so the preview shows the real night lighting.
/// </summary>
public class LightEditorController
{
/// <summary>The editable list of light definitions. Capped at 10 for shader preview.</summary>
public List<LightDef> Lights { get; } = new();
public class LightEditorController
{
/// <summary>Static light slots the shader has (slot 0 is the dynamic light).</summary>
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];
/// <summary>Viewport width the stored radii refer to.</summary>
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);
/// <summary>Initialises preview arrays with out-of-bounds sentinel values.</summary>
public LightEditorController()
{
for (int i = 0; i < 11; i++)
{
previewPositions[i] = new Vector2(-1000f, -1000f);
previewRadii[i] = 0f;
previewInfluence[i] = 0f;
previewColors[i] = UiTokens.White;
}
}
/// <summary>The edited lights, in file order.</summary>
public List<LightDef> Lights { get; private set; } = new();
/// <summary>Index of the selected light, or -1.</summary>
public int SelectedIndex { get; private set; } = -1;
/// <summary>The selected light, or <c>null</c>.</summary>
public LightDef Selected => SelectedIndex >= 0 && SelectedIndex < Lights.Count ? Lights[SelectedIndex] : null;
/// <summary>Selects by index; out of range clears the selection.</summary>
public void Select(int index) => SelectedIndex = index >= 0 && index < Lights.Count ? index : -1;
/// <summary>Adds a warm light at the given normalised point and selects it; null when all slots are used.</summary>
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;
}
/// <summary>Removes the selected light.</summary>
public void RemoveSelected()
{
if (Selected == null)
return;
Lights.RemoveAt(SelectedIndex);
SelectedIndex = Math.Min(SelectedIndex, Lights.Count - 1);
}
/// <summary>Compact JSON array exactly as written into the <c>light_set</c> line.</summary>
public string ToJson() => JsonSerializer.Serialize(Lights, JsonOptionsProvider.WriteCompactOptions);
/// <summary>The scenario line for the current lights.</summary>
public string ToScenarioLine() => Command + " " + ToJson();
/// <summary>
/// Creates a <see cref="ShaderMaterial"/> using the <c>"timed_sprite"</c> shader
/// with night-time weights applied. Returns <c>null</c> if the shader is not found.
/// Loads the lights of the first <c>light_set</c> line of a scenario.
/// Returns <c>false</c> when the scenario has none (the list is then
/// empty and saving inserts a new line). Throws on malformed JSON.
/// </summary>
public ShaderMaterial CreatePreviewMaterial()
{
var shader = Shaders.Resolve("timed_sprite");
if (shader == null)
return null;
public bool LoadFromScenario(string text)
{
Lights = new List<LightDef>();
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;
/// <summary>Sets the background texture uniform on the preview shader material.</summary>
public void SetBackgroundForPreview(Texture2D texture, ShaderMaterial material)
{
if (material != null)
material.SetShaderParameter("background_map", texture);
}
/// <summary>Sets the depth mask texture uniform on the preview shader material.</summary>
public void SetDepthForPreview(Texture2D texture, ShaderMaterial material)
{
if (material != null)
material.SetShaderParameter("depth_mask", texture);
}
/// <summary>Sets the albedo texture uniform on the preview shader material.</summary>
public void SetAlbedoForPreview(Texture2D texture, ShaderMaterial material)
{
if (material != null)
material.SetShaderParameter("albedo_map", texture);
}
/// <summary>Appends a <see cref="LightDef"/> to the list and marks the preview dirty.</summary>
public void AddLight(LightDef light)
{
Lights.Add(light);
dirty = true;
}
/// <summary>Removes the light at <paramref name="index"/>. No-op if out of range.</summary>
public void RemoveLight(int index)
{
if (index < 0 || index >= Lights.Count)
return;
Lights.RemoveAt(index);
dirty = true;
}
/// <summary>Removes all lights from the list and marks dirty.</summary>
public void Clear()
{
Lights.Clear();
dirty = true;
}
/// <summary>Forces the preview to be recalculated on the next <see cref="UpdatePreview"/> call.</summary>
public void MarkDirty()
{
dirty = true;
}
var json = ArgumentOf(lines[index]);
Lights = JsonSerializer.Deserialize<List<LightDef>>(json, JsonOptionsProvider.ReadOptions) ?? new List<LightDef>();
SelectedIndex = Lights.Count > 0 ? 0 : -1;
return true;
}
/// <summary>
/// Packs up to 10 lights plus a sentinel into shader uniform arrays.
/// Only executes if <c>dirty</c> is <c>true</c>. Converts each light's
/// normalised position to pixel coordinates relative to the preview area.
/// Returns the scenario text with its <c>light_set</c> line replaced by
/// the current lights, or — when it has none — with a new line inserted
/// right after the <c>---</c> header separator (at the top otherwise).
/// </summary>
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();
}
/// <summary>True when the scenario text contains a <c>light_set</c> line.</summary>
public static bool HasLights(string text) => FindLine(text, out _) >= 0;
var posArray = new Godot.Collections.Array<Vector2>();
var colArray = new Godot.Collections.Array<Color>();
var radArray = new Godot.Collections.Array<float>();
var infArray = new Godot.Collections.Array<float>();
for (int i = 0; i < 11; i++)
{
posArray.Add(previewPositions[i]);
colArray.Add(previewColors[i]);
radArray.Add(previewRadii[i]);
infArray.Add(previewInfluence[i]);
}
private static int FindLine(string text, out List<string> lines)
{
lines = new List<string>((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);
/// <summary>Argument of a <c>light_set</c> line; the old quoted form (<c>"[{\"x\":…}]"</c>) is unescaped.</summary>
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 ─────────────────────────────────────────
/// <summary>Serialises the light list as a compact JSON string.</summary>
public string ExportLightsJson()
{
return JsonSerializer.Serialize(Lights, Cthangover.Core.Utils.JsonOptionsProvider.WriteCompactOptions);
}
/// <summary>
/// Material of the preview: the game's <c>timed_sprite</c> shader at
/// night weights. <c>null</c> when the shader is not installed.
/// </summary>
public static ShaderMaterial CreatePreviewMaterial()
{
var shader = Shaders.Resolve("timed_sprite");
if (shader == null)
return null;
/// <summary>Produces a <c>light_set</c> scenario DSL command with the lights JSON as its argument.</summary>
public string ExportScenarioSnippet()
{
var json = ExportLightsJson();
return $"light_set \"{json.Replace("\"", "\\\"")}\"";
}
}
var material = new ShaderMaterial { Shader = shader };
material.SetShaderParameter("time_weights", new Vector4(0, 0, 0, 1));
material.SetShaderParameter("use_time", true);
return material;
}
/// <summary>Binds the background and its optional depth/albedo masks to the preview material.</summary>
public static void SetPreviewTextures(ShaderMaterial material, Texture2D depth, Texture2D albedo)
{
if (material == null)
return;
material.SetShaderParameter("depth_mask", depth);
material.SetShaderParameter("albedo_map", albedo);
}
/// <summary>
/// Uploads the lights into the shader uniforms. <paramref name="toCanvas"/>
/// maps a normalised position into the preview's canvas coordinates
/// (the space of the shader's <c>world_pos</c>); <paramref name="radiusScale"/>
/// shrinks game-resolution radii to the preview size.
/// </summary>
public void UpdatePreview(ShaderMaterial material, Func<Vector2, Vector2> toCanvas, float radiusScale)
{
if (material == null)
return;
var positions = new Godot.Collections.Array<Vector2>();
var colors = new Godot.Collections.Array<Color>();
var radii = new Godot.Collections.Array<float>();
var influence = new Godot.Collections.Array<float>();
// 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);
}
}
}
-58
View File
@@ -1,58 +0,0 @@
using Cthangover.Mods.Time;
using Godot;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>
/// Draggable 24×24 coloured handle representing a light source on the preview.
/// Overrides <c>_GuiInput</c> (Godot virtual) for mouse drag support; the colour
/// is taken from <see cref="LightDef.ToColor"/>. Raises <see cref="DragUpdate"/>
/// with the handle's centre position in global coordinates for the window to
/// convert back to normalised coordinates.
/// </summary>
public partial class LightEditorHandle : ColorRect
{
/// <summary>Index of the light in <see cref="LightEditorController.Lights"/>.</summary>
public int LightIndex;
/// <summary>Invoked during drag with the centre position in global coordinates.</summary>
public System.Action<Vector2> DragUpdate;
/// <summary>Invoked when the handle is clicked (mouse down).</summary>
public System.Action Clicked;
private bool dragging;
private Vector2 dragOffset;
/// <summary>Creates a 24×24 handle coloured to match the light's <see cref="LightDef.ToColor"/> value.</summary>
public LightEditorHandle(LightDef light, int index)
{
LightIndex = index;
Size = new Vector2(24, 24);
Color = light.ToColor();
MouseFilter = MouseFilterEnum.Stop;
}
/// <summary>Handles mouse drag and click via Godot's virtual input system (<c>_GuiInput</c>).</summary>
public override void _GuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
{
dragging = true;
dragOffset = GetGlobalMousePosition() - GlobalPosition;
Clicked?.Invoke();
}
else
{
dragging = false;
}
AcceptEvent();
}
else if (@event is InputEventMouseMotion && dragging)
{
GlobalPosition = GetGlobalMousePosition() - dragOffset;
DragUpdate?.Invoke(GlobalPosition + Size / 2);
}
}
}
}
+320 -314
View File
@@ -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
{
/// <summary>
/// Editor window for placing and configuring static lights on a background.
/// Provides a sidebar with background selection, a light list with add/delete/select,
/// per-light property editors (radius, influence, colour), draggable handles on a
/// shader-powered preview, and JSON export. Uses <see cref="LightEditorController"/>
/// for data management and <see cref="ModResourceService"/> for background textures.
/// Editor of the static lights of a scene. The author picks a scenario —
/// the ones that already set lights come first — and edits its
/// <c>light_set</c> 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.
/// </summary>
public partial class LightEditorWindow : ToolWindow
{
private LightEditorController controller;
{
private readonly LightEditorController _controller = new();
private readonly List<ScenarioFileInfo> _scenarios = new();
private readonly List<string> _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<LightEditorHandle> handles = new();
private int selectedIndex = -1;
private readonly List<string> bgIds = new();
/// <summary>Constructs the window, creates the controller, builds UI, and loads the background list.</summary>
public LightEditorWindow() : base("tools/light_editor/title")
{
controller = new LightEditorController();
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") });
/// <summary>Scenarios with a <c>light_set</c> line first (marked ●), then all others.</summary>
private void LoadScenarioList()
{
var withLights = new List<ScenarioFileInfo>();
var others = new List<ScenarioFileInfo>();
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);
/// <summary>Shows a background with its optional <c>_depth</c>/<c>_albedo</c> masks in the shader.</summary>
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;
/// <summary>Ctrl+S.</summary>
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();
}
/// <summary>Delete removes the selected light.</summary>
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<List<LightDef>>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
if (lights == null || lights.Count == 0)
return;
foreach (var h in handles)
h.QueueFree();
handles.Clear();
controller.Clear();
foreach (var light in lights)
{
controller.AddLight(light);
AddHandle(light, controller.Lights.Count - 1);
}
selectedIndex = -1;
RefreshAll();
}
catch (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();
}
}
}
+124 -158
View File
@@ -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
{
/// <summary>
/// Interactive preview of the edited map: draws the background with the
/// exact aspect-preserving layout used by <c>MapWidget</c>, 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
/// <see cref="BackgroundCanvas"/> exactly like <c>MapWidget</c>), every zone
/// polygon, marker and text label, and the draggable handles of the
/// selected zone. Two modes:
/// <list type="bullet">
/// <item><b>select</b> — clicking picks the topmost zone (polygon first,
/// <item><b>select</b> — a click picks the topmost zone (polygon first,
/// then marker/label dots); handles move vertices and positions;</item>
/// <item><b>draw</b> — clicking appends polygon vertices to the selected
/// region and double-click finishes the polygon and returns to select
/// mode.</item>
/// <item><b>draw</b> — 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.</item>
/// </list>
/// 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
/// <see cref="MapEditorController"/> and raises <see cref="Changed"/> 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.
/// <para/>
/// Zone fills and outlines are painted through <see cref="MapZoneStyle"/> —
/// the same definition the game's <c>MapZonesLayer</c> 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
/// <see cref="MapEditorController"/> and raises <see cref="Changed"/> so
/// the window can refresh the sidebar and JSON. Zone fills and outlines are
/// painted through <see cref="MapZoneStyle"/> — the definition the game's
/// <c>MapZonesLayer</c> uses — so the editor and the live map look the same.
/// </summary>
public partial class MapEditorCanvas : Control
public partial class MapEditorCanvas : BackgroundCanvas
{
/// <summary>Mode constant: clicking selects zones and drags handles.</summary>
public const string ModeSelect = "select";
@@ -42,129 +38,97 @@ namespace Cthangover.Tools.MapEditor
/// <summary>Mode constant: clicking appends polygon vertices.</summary>
public const string ModeDraw = "draw";
/// <summary>Handle index of the marker position.</summary>
public const int MarkerHandle = -2;
/// <summary>Handle index of the label position.</summary>
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<MapEditorHandle> _handles = new();
/// <summary>Data being edited; set once by the window.</summary>
public MapEditorController Controller;
/// <summary>Reference background; <c>null</c> falls back to a flat rectangle.</summary>
public Texture2D Background;
/// <summary>Current interaction mode, one of <see cref="ModeSelect"/> / <see cref="ModeDraw"/>.</summary>
public string Mode { get; private set; } = ModeSelect;
/// <summary>Raised whenever the canvas mutates controller data (drag, draw, pick).</summary>
/// <summary>Raised whenever the canvas mutates controller data (drag, draw, delete).</summary>
public event Action Changed;
/// <summary>Raised with the newly picked zone index (-1 for empty space).</summary>
public event Action<int> ZonePicked;
/// <summary>Raised when the canvas leaves draw mode on its own (double-click finishing a polygon).</summary>
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));
}
/// <summary>Switches the interaction mode and refreshes the handles.</summary>
public void SetMode(string mode)
{
Mode = mode == ModeDraw ? ModeDraw : ModeSelect;
MouseDefaultCursorShape = Mode == ModeDraw ? CursorShape.Cross : CursorShape.Arrow;
RebuildHandles();
QueueRedraw();
}
/// <summary>Assigns the background texture and rebuilds the layout.</summary>
public void SetBackground(Texture2D texture)
/// <summary>Leaves draw mode (double-click, Enter, Esc) and tells the window.</summary>
public void FinishDrawing()
{
Background = texture;
RebuildHandles();
QueueRedraw();
if (Mode != ModeDraw)
return;
SetMode(ModeSelect);
_pointer = null;
ModeChanged?.Invoke();
}
/// <summary>Recreates the handles from the current selection. Call after structural changes.</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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));
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Deletes a vertex from its handle's right-click. Marker and label
/// handles (negative indexes) are not vertices and are ignored.
/// </summary>
private void RemoveVertexHandle(int index)
{
if (index < 0 || Controller == null)
return;
Controller.RemoveVertex(index);
RebuildHandles();
Changed?.Invoke();
QueueRedraw();
}
}
}
+16
View File
@@ -70,6 +70,22 @@ namespace Cthangover.Tools.MapEditor
return Append(zone);
}
/// <summary>
/// Creates a region without vertices and selects it — the start of
/// drawing a polygon point by point on the canvas.
/// </summary>
public MapZone StartRegion()
{
var zone = new MapZone
{
ID = MakeUniqueId("region"),
Title = string.Empty,
Points = new List<Vector2>(),
};
return Append(zone);
}
/// <summary>
/// Creates an icon marker at the map centre with no icon chosen yet and
/// selects it. The author assigns <c>image</c> and <c>scene</c> in the
-85
View File
@@ -1,85 +0,0 @@
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.MapEditor
{
/// <summary>
/// Draggable 16x16 handle on the map editor canvas. One handle represents
/// either a polygon vertex, the marker position or the label position,
/// distinguished by <see cref="Index"/> with negative values reserved for
/// the position handles. Subscribes to the GuiInput C# event rather than
/// overriding <c>_GuiInput</c> for reliable event delivery in mod
/// assemblies, following <c>InteractiveEditorHandle</c>.
/// </summary>
public partial class MapEditorHandle : ColorRect
{
/// <summary>Handle role: 0..n — vertex index, <see cref="MarkerHandle"/> or <see cref="LabelHandle"/>.</summary>
public const int MarkerHandle = -2;
/// <summary>Handle role used for the label position handle.</summary>
public const int LabelHandle = -3;
/// <summary>Vertex index or one of the negative role constants.</summary>
public int Index;
/// <summary>Invoked during drag with the handle centre in global coordinates.</summary>
public System.Action<Vector2> DragUpdate;
/// <summary>Invoked when the handle is clicked (mouse down).</summary>
public System.Action Clicked;
/// <summary>Invoked when the drag ends (mouse up).</summary>
public System.Action DragEnd;
/// <summary>
/// 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.
/// </summary>
public System.Action RightClicked;
private bool _dragging;
private Vector2 _dragOffset;
/// <summary>Creates a handle with the given colour and role index.</summary>
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);
}
}
}
}
File diff suppressed because it is too large. Load diff
+21 -32
View File
@@ -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
{
/// <summary>State of one open scenario file tab: identity, saved text and dirty flag.</summary>
public sealed class TabData
{
public string Key;
public string ModId;
public string RelativePath;
public string FileName;
public string SavedText;
public bool IsDirty;
}
/// <summary>
/// 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
/// <see cref="CodeEdit"/> exists; switching tabs swaps its text with the
/// tab's <see cref="TabData.Buffer"/>, which therefore holds unsaved edits
/// of background tabs.
/// </summary>
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<TabData> _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<bool> DirtyChanged;
public event Action<string> GraphFileRequested;
public ScenarioEditTabs(ScenarioGraphView graphView, Font monospaceFont)
/// <summary>Raised on every user edit of the active text tab.</summary>
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)
+99 -130
View File
@@ -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
{
/// <summary>
/// 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.
/// <para/>
/// Shortcuts: Ctrl+S saves the active file, F5 play-tests it.
/// </summary>
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"));
}
/// <summary>Opens with the graph view so the editor starts on the cross-scene map.</summary>
@@ -44,10 +51,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_editTabs.SetViewToGraph();
}
protected override void Cleanup()
{
}
/// <summary>Returns from play-test mode as soon as the in-game test play ends.</summary>
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();
}
}
/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut() => OnSavePressed();
/// <summary>F5 play-tests the active scenario.</summary>
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<GodotSceneService>("/root/GodotSceneService");
sceneService?.SwitchToMenu();
SetStatus(ToolUi.Tr("tools/scenario_editor/play_stopped"));
}
/// <summary>Stop button shown over the running game while the scenario is play-tested.</summary>
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);
+100 -51
View File
@@ -6,82 +6,138 @@ using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Left-hand tree of every mod's scenario files, grouped by mod and subdirectory; raises <see cref="FileSelected"/> on picks.</summary>
/// <summary>
/// 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 <see cref="FileSelected"/> with the
/// scenario key on picks.
/// <para/>
/// Built from <c>scenario_file_tree_panel.tscn</c>: the tscn builder creates
/// the script node through its constructor before the child nodes exist,
/// so children are looked up lazily (<see cref="Bind"/>) — not in the
/// constructor, which is what used to crash the whole editor on open.
/// </summary>
public partial class ScenarioFileTreePanel : VBoxContainer
{
private Tree _fileTree;
private readonly Dictionary<string, string> _fileItemToKey = new();
private readonly Dictionary<string, TreeItem> _keyToTreeItem = new();
private LineEdit _search;
private List<ModScenarioTree> _mods = new();
private readonly Dictionary<TreeItem, string> _itemToKey = new();
private readonly Dictionary<string, TreeItem> _keyToItem = new();
private bool _isProgrammaticSelection;
private string _selectedKey;
public event Action<string> FileSelected;
public ScenarioFileTreePanel()
/// <summary>Binds child nodes and fills the tree once the panel enters the tree.</summary>
public override void _Ready()
{
Reload();
}
/// <summary>Re-scans the mods' scenario files and rebuilds the tree.</summary>
public void Reload()
{
Bind();
_mods = ScenarioFileService.ScanScenarioFiles();
Populate();
}
private void Bind()
{
if (_fileTree != null)
return;
_fileTree = GetNode<Tree>("FileTree");
_fileTree.CellSelected += OnCellSelected;
Populate();
_search = GetNode<LineEdit>("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();
}
/// <summary>Programmatically selects the tree row for a scenario key (no selection event).</summary>
/// <summary>Selects the tree row of a scenario key and raises <see cref="FileSelected"/>.</summary>
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);
}
}
}
+74
View File
@@ -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
{
/// <summary>
/// Layered left-to-right layout of the scene graph: a scene's column is
/// its distance (in scene switches) from the start scenes — the
/// <c>start_scene</c> 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.
/// </summary>
public static class ScenarioGraphLayout
{
private const float ColumnWidth = 300f;
private const float RowHeight = 130f;
private const float Margin = 40f;
/// <summary>Canvas position per lower-cased scene name.</summary>
public static Dictionary<string, Vector2> Compute(SceneGraphData data)
{
var names = data.Nodes.Select(n => n.SceneName.ToLowerInvariant()).Distinct().ToList();
var outgoing = names.ToDictionary(n => n, _ => new List<string>());
var incoming = new HashSet<string>();
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<string, int>();
var queue = new Queue<string>();
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<string, Vector2>();
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;
}
}
}
+23 -29
View File
@@ -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<string, GraphNode>();
_lookup = new Dictionary<string, SceneGraphNode>();
int x = 40, y = 40;
foreach (var pos in savedPositions.Values)
{
int bottom = (int)pos.Y + 160 + 20;
if (bottom > y)
y = bottom;
}
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<string, Vector2> 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());
+49 -13
View File
@@ -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()
/// <summary>Binds the rows once the panel enters the tree (see <see cref="Bind"/>).</summary>
public override void _Ready()
{
GetNode<Label>("Scroll/VBox/HintScene").Text = TranslationServer.Translate("tools/scenario_editor/info_scene") + ":";
GetNode<Label>("Scroll/VBox/HintPriority").Text = TranslationServer.Translate("tools/scenario_editor/info_priority") + ":";
GetNode<Label>("Scroll/VBox/HintCondition").Text = TranslationServer.Translate("tools/scenario_editor/info_condition") + ":";
GetNode<Label>("Scroll/VBox/HintSwitchTargets").Text = TranslationServer.Translate("tools/scenario_editor/info_switch_targets") + ":";
GetNode<Label>("Scroll/VBox/HintLocaleKeys").Text = TranslationServer.Translate("tools/scenario_editor/info_locale_keys") + ":";
GetNode<Label>("Scroll/VBox/HintQuestRefs").Text = TranslationServer.Translate("tools/scenario_editor/info_quest_refs") + ":";
Bind();
}
_infoScene = GetNode<Label>("Scroll/VBox/InfoScene");
_infoPriority = GetNode<Label>("Scroll/VBox/InfoPriority");
_infoCondition = GetNode<Label>("Scroll/VBox/InfoCondition");
_infoSwitchTargets = GetNode<Label>("Scroll/VBox/InfoSwitchTargets");
_infoLocaleKeys = GetNode<Label>("Scroll/VBox/InfoLocaleKeys");
_infoQuestRefs = GetNode<Label>("Scroll/VBox/InfoQuestRefs");
/// <summary>
/// Looks the rows up lazily: the tscn builder runs the constructor before
/// the child nodes exist, so lookups there would fail.
/// </summary>
private void Bind()
{
if (_infoScene != null)
return;
SetHint("HintScene", "tools/scenario_editor/info_scene");
SetHint("HintPriority", "tools/scenario_editor/info_priority");
SetHint("HintCondition", "tools/scenario_editor/info_condition");
SetHint("HintSwitchTargets", "tools/scenario_editor/info_switch_targets");
SetHint("HintLocaleKeys", "tools/scenario_editor/info_locale_keys");
SetHint("HintQuestRefs", "tools/scenario_editor/info_quest_refs");
_infoScene = Value("InfoScene");
_infoPriority = Value("InfoPriority");
_infoCondition = Value("InfoCondition");
_infoSwitchTargets = Value("InfoSwitchTargets");
_infoLocaleKeys = Value("InfoLocaleKeys");
_infoQuestRefs = Value("InfoQuestRefs");
}
private void SetHint(string node, string key)
{
var label = GetNode<Label>("Scroll/VBox/" + node);
label.Text = TranslationServer.Translate(key).ToString().ToUpperInvariant();
label.AddThemeColorOverride("font_color", UiTokens.Muted);
label.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
}
private Label Value(string node)
{
var label = GetNode<Label>("Scroll/VBox/" + node);
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
label.SizeFlagsHorizontal = SizeFlags.ExpandFill;
label.CustomMinimumSize = new Vector2(120, 0);
label.AddThemeFontOverride("font", ToolWindow.GetMonospaceFont());
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
return label;
}
/// <summary>Parses the given scenario text and refreshes every metadata row.</summary>
public void Update(string text)
{
Bind();
if (string.IsNullOrEmpty(text))
{
Clear();
@@ -54,6 +89,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
/// <summary>Resets every metadata row to the "-" placeholder.</summary>
public void Clear()
{
Bind();
_infoScene.Text = "-";
_infoPriority.Text = "-";
_infoCondition.Text = "-";
+10 -9
View File
@@ -9,10 +9,11 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
private HBoxContainer _thumbContainer;
public ScenarioThumbnailPanel()
{
_thumbContainer = GetNode<HBoxContainer>("ThumbContainer");
}
/// <summary>
/// Container lookup is lazy: the tscn builder runs the constructor
/// before the child nodes exist.
/// </summary>
private HBoxContainer Thumbs => _thumbContainer ??= GetNode<HBoxContainer>("ThumbContainer");
/// <summary>Loads the background thumbnails referenced by the given scenario text (missing ones render as placeholders).</summary>
public void Load(string text)
@@ -26,7 +27,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
if (refs.BackgroundRefs.Count == 0)
{
_thumbContainer.AddChild(new Label
Thumbs.AddChild(new Label
{
Text = TranslationServer.Translate("tools/scenario_editor/no_backgrounds")
});
@@ -39,7 +40,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
if (tex != null)
{
_thumbContainer.AddChild(new TextureRect
Thumbs.AddChild(new TextureRect
{
Texture = tex,
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
@@ -50,10 +51,10 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
else
{
_thumbContainer.AddChild(new Label
Thumbs.AddChild(new Label
{
Text = $"[? {id}]",
TooltipText = $"Not found: {id}"
TooltipText = string.Format(TranslationServer.Translate("tools/scenario_editor/bg_missing"), id)
});
}
}
@@ -62,7 +63,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
/// <summary>Removes every thumbnail from the row.</summary>
public new void Clear()
{
foreach (var c in _thumbContainer.GetChildren())
foreach (var c in Thumbs.GetChildren())
c.QueueFree();
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// State of one open scenario file tab: identity, the current text buffer
/// (including unsaved edits while the tab is in the background) and the
/// dirty flag.
/// </summary>
public sealed class TabData
{
public string Key;
public string ModId;
public string RelativePath;
public string FileName;
public string Buffer;
public bool IsDirty;
}
}
+22 -8
View File
@@ -5,6 +5,7 @@ using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
@@ -50,24 +51,34 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
protected override void Construct()
{
// ModWidget is a plain Control: it neither sizes its child nor
// reports the child's height. Anchor the column to full width and
// mirror its height into the minimum size so the hosting
// ScrollContainer can scroll (and wrapped hints get real width).
var root = new VBoxContainer();
root.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
root.AddThemeConstantOverride("separation", UiTokens.Space2);
AddChild(root);
root.SetAnchorsAndOffsetsPreset(LayoutPreset.TopWide);
root.MinimumSizeChanged += () => CustomMinimumSize = new Vector2(0, root.GetCombinedMinimumSize().Y);
_hint = new Label();
_hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
_hint.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_hint.AddThemeColorOverride("font_color", UiTokens.Faint);
_hint.Visible = false;
root.AddChild(_hint);
_header = new Label();
_header.AddThemeColorOverride("font_color", UiTokens.InkBright);
_header.AddThemeFontSizeOverride("font_size", UiTokens.FontBodyLarge);
_header.Visible = false;
root.AddChild(_header);
_banner = new Label();
_banner.AutowrapMode = TextServer.AutowrapMode.WordSmart;
_banner.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_banner.Modulate = UiTokens.Warning;
_banner.AddThemeColorOverride("font_color", UiTokens.Warning);
_banner.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
_banner.Visible = false;
root.AddChild(_banner);
@@ -80,7 +91,9 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
Text = TranslationServer.Translate("tools/scene_builder/inspector_preset"),
TooltipText = "anchors_preset"
};
presetLabel.CustomMinimumSize = new Vector2(110f, 0f);
presetLabel.CustomMinimumSize = new Vector2(120f, 0f);
presetLabel.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
presetLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
_presetRow.AddChild(presetLabel);
_presetDropdown = new OptionButton();
@@ -113,7 +126,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
_hint.Visible = false;
_header.Visible = true;
_header.Text = node.Name + " (" + node.GetClass() + ")";
_header.Text = node.Name + " · " + node.GetClass();
var insideContainer = node is Control && node.GetParent() is Container;
_banner.Visible = insideContainer;
@@ -126,8 +139,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
foreach (var entry in InspectorPropertyCatalog.Build(node, insideContainer))
{
var categoryLabel = new Label { Text = TranslationServer.Translate(entry.CategoryKey) };
categoryLabel.Modulate = UiTokens.AccentBright;
var categoryLabel = UiKit.SectionTitle(TranslationServer.Translate(entry.CategoryKey), UiTokens.FontSmall);
_rows.AddChild(categoryLabel);
foreach (var property in entry.Properties)
@@ -169,8 +181,10 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
var labelText = Humanize(property.Key);
if (storedInFile)
labelText += " *";
var label = new Label { Text = labelText, TooltipText = BuildTooltip(property.Key, storedInFile) };
label.CustomMinimumSize = new Vector2(110f, 0f);
var label = new Label { Text = labelText, TooltipText = BuildTooltip(property.Key, storedInFile), MouseFilter = MouseFilterEnum.Pass, ClipText = true };
label.CustomMinimumSize = new Vector2(120f, 0f);
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
label.AddThemeColorOverride("font_color", storedInFile ? UiTokens.Ink : UiTokens.Muted);
label.SizeFlagsVertical = Control.SizeFlags.ShrinkCenter;
row.AddChild(label);
+11 -6
View File
@@ -251,8 +251,9 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
/// <summary>
/// Recursively walks the loaded scene tree and populates the given
/// <see cref="Tree"/> control with nodes labelled <c>Name (Type)</c>.
/// Skips the internal highlight rect.
/// <see cref="Tree"/> control with nodes labelled <c>Name · Class</c>:
/// the mod scene root in gold, editable mod nodes in ink, read-only
/// base-scene nodes faint. Skips the internal highlight rect.
/// </summary>
public void BuildHierarchy(Tree tree)
{
@@ -271,9 +272,12 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private void AddNodeToTree(Tree tree, TreeItem parent, Node node)
{
var item = tree.CreateItem(parent);
var typeName = node.GetType().Name;
item.SetText(0, $"{node.Name} ({typeName})");
item.SetText(0, $"{node.Name} · {node.GetClass()}");
item.SetSelectable(0, true);
// Mod-scene nodes are the editable ones; base-scene nodes stay faint.
item.SetCustomColor(0, IsEditableModNode(node) ? UiTokens.Ink : UiTokens.Faint);
if (node == _modRoot)
item.SetCustomColor(0, UiTokens.AccentBright);
_nodeToItem[node] = item;
_itemToNode[item] = node;
@@ -425,8 +429,9 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
/// <summary>Hides the node highlight overlay and resets its colour.</summary>
public void HideHighlight()
{
if (_highlight != null)
_highlight.Visible = false;
if (_highlight == null)
return;
_highlight.Visible = false;
_highlight.Color = UiTokens.Alpha(UiTokens.Accent, 0.35f);
}
}
+224 -166
View File
@@ -5,7 +5,9 @@ using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
@@ -33,14 +35,15 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private Timer _autosaveTimer;
private Node _selectedNode;
private TscnFileProps _fileProps = TscnFileProps.Build(null);
private TextEdit _tscnEditor;
private Button _reloadBtn;
private Button _saveBtn;
private Button _refreshBtn;
private CodeEdit _tscnEditor;
private OptionButton _newNodeTypeDropdown;
private Button _addNodeBtn;
private Button _deleteNodeBtn;
private RichTextLabel _outputLabel;
private int _shownBase;
private int _shownMod;
private int _shownScene;
private bool _loadingText;
/// <summary>Logical game resolution (project.godot) the preview canvas is laid out at.</summary>
private static readonly Vector2I GameResolution = new(1920, 1024);
/// <summary>Curated palette for node creation — common Control classes the runtime builder instantiates via ClassDB.</summary>
private static readonly string[] CreatableTypes =
@@ -64,6 +67,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
PopulateBaseList();
PopulateModList();
RebuildPreview();
SetHint(ToolUi.Tr("tools/scene_builder/hint"));
}
private void BuildUI()
@@ -74,84 +78,121 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
var toolbar = CreateToolbar();
outerVBox.AddChild(toolbar);
toolbar.AddChild(new Label { Text = TranslationServer.Translate("tools/scene_builder/base_scene") });
_baseDropdown = Dropdown(toolbar, "tools/scene_builder/base_scene", OnBaseSelected);
_modDropdown = Dropdown(toolbar, "tools/scene_builder/mod", OnModSelected);
_sceneDropdown = Dropdown(toolbar, "tools/scene_builder/scene", OnSceneSelected);
toolbar.AddChild(ToolUi.Divider());
toolbar.AddChild(ToolUi.Button("tools/common/save", OnSavePressed, ButtonKind.Primary, "tools/scene_builder/save_tip"));
toolbar.AddChild(ToolUi.Button("tools/scene_builder/reload", OnReloadPressed, ButtonKind.Secondary, "tools/scene_builder/reload_tip"));
_baseDropdown = new OptionButton();
_baseDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_baseDropdown.ItemSelected += OnBaseSelected;
toolbar.AddChild(_baseDropdown);
var mainSplit = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outerVBox.AddChild(mainSplit);
toolbar.AddChild(new Label { Text = TranslationServer.Translate("tools/scene_builder/mod") });
mainSplit.AddChild(BuildHierarchyPanel());
_modDropdown = new OptionButton();
_modDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_modDropdown.ItemSelected += OnModSelected;
toolbar.AddChild(_modDropdown);
var centerSplit = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
mainSplit.AddChild(centerSplit);
centerSplit.AddChild(BuildPreview());
centerSplit.AddChild(BuildSidePanel());
toolbar.AddChild(new Label { Text = TranslationServer.Translate("tools/scene_builder/scene") });
_autosaveTimer = new Timer { OneShot = true, WaitTime = 0.4 };
_autosaveTimer.Timeout += OnAutosaveTimeout;
AddChild(_autosaveTimer);
}
_sceneDropdown = new OptionButton();
_sceneDropdown.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_sceneDropdown.ItemSelected += OnSceneSelected;
toolbar.AddChild(_sceneDropdown);
private static OptionButton Dropdown(HBoxContainer toolbar, string tooltipKey, Action<long> selected)
{
var dropdown = new OptionButton
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
TooltipText = ToolUi.Tr(tooltipKey),
FitToLongestItem = false
};
dropdown.ItemSelected += index => selected(index);
toolbar.AddChild(dropdown);
return dropdown;
}
_reloadBtn = new Button { Text = TranslationServer.Translate("tools/scene_builder/reload") };
_reloadBtn.Pressed += OnReloadPressed;
toolbar.AddChild(_reloadBtn);
private Control BuildHierarchyPanel()
{
var column = new VBoxContainer();
column.AddThemeConstantOverride("separation", UiTokens.Space2);
var panel = new PanelContainer { CustomMinimumSize = new Vector2(280, 0) };
panel.AddThemeStyleboxOverride("panel", SidebarBox());
panel.AddChild(column);
var mainHBox = new HBoxContainer();
mainHBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
mainHBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
outerVBox.AddChild(mainHBox);
column.AddChild(ToolUi.Section("tools/scene_builder/hierarchy"));
var leftVBox = new VBoxContainer();
leftVBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
leftVBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
var leftPanel = CreateSidebar(leftVBox);
mainHBox.AddChild(leftPanel);
var actions = new HBoxContainer();
actions.AddThemeConstantOverride("separation", UiTokens.Space1);
column.AddChild(actions);
var hierarchyHeader = new HBoxContainer();
var hierarchyLabel = new Label { Text = TranslationServer.Translate("tools/scene_builder/hierarchy") };
hierarchyLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
hierarchyHeader.AddChild(hierarchyLabel);
_newNodeTypeDropdown = new OptionButton();
_newNodeTypeDropdown.TooltipText = TranslationServer.Translate("tools/scene_builder/add_node_tip");
_newNodeTypeDropdown = new OptionButton
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
TooltipText = ToolUi.Tr("tools/scene_builder/node_type_tip")
};
foreach (var t in CreatableTypes)
_newNodeTypeDropdown.AddItem(t);
_newNodeTypeDropdown.Selected = 0;
hierarchyHeader.AddChild(_newNodeTypeDropdown);
actions.AddChild(_newNodeTypeDropdown);
_addNodeBtn = new Button { Text = "+" };
_addNodeBtn.TooltipText = TranslationServer.Translate("tools/scene_builder/add_node_tip");
_addNodeBtn.Pressed += OnAddNodePressed;
hierarchyHeader.AddChild(_addNodeBtn);
var addBtn = UiKit.IconButton("+", ToolUi.Tr("tools/scene_builder/add_node_tip"));
addBtn.Pressed += OnAddNodePressed;
actions.AddChild(addBtn);
_deleteNodeBtn = new Button { Text = "-" };
_deleteNodeBtn.TooltipText = TranslationServer.Translate("tools/scene_builder/delete_node_tip");
_deleteNodeBtn.Pressed += OnDeleteNodePressed;
hierarchyHeader.AddChild(_deleteNodeBtn);
var deleteBtn = UiKit.IconButton("\u2212", ToolUi.Tr("tools/scene_builder/delete_node_tip"));
deleteBtn.Pressed += OnDeleteNodePressed;
actions.AddChild(deleteBtn);
_refreshBtn = new Button();
_refreshBtn.Text = TranslationServer.Translate("tools/scene_builder/refresh");
_refreshBtn.Pressed += OnRefreshPressed;
hierarchyHeader.AddChild(_refreshBtn);
var refreshBtn = UiKit.IconButton("\u27F3", ToolUi.Tr("tools/scene_builder/refresh_tip"));
refreshBtn.Pressed += OnRefreshPressed;
actions.AddChild(refreshBtn);
leftVBox.AddChild(hierarchyHeader);
_hierarchyTree = new Tree();
_hierarchyTree.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
_hierarchyTree.HideRoot = true;
_hierarchyTree = new Tree
{
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
HideRoot = true
};
_hierarchyTree.CellSelected += OnTreeCellSelected;
leftVBox.AddChild(_hierarchyTree);
column.AddChild(_hierarchyTree);
column.AddChild(ToolUi.Hint(ToolUi.Tr("tools/scene_builder/hierarchy_hint")));
return panel;
}
var previewContainer = new PanelContainer();
previewContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
previewContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
mainHBox.AddChild(previewContainer);
/// <summary>
/// Preview canvas. The sub-viewport lays the scene out at the game
/// resolution (2D size override) and is scaled to fit inside an
/// aspect-locked box, so anchors and sizes look exactly as in the game
/// whatever the window size. The selection overlay shares the box, so
/// its coordinate mapping is a plain scale.
/// </summary>
private Control BuildPreview()
{
var previewContainer = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
previewContainer.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line));
_viewport = new SubViewport();
_viewport.Size = new Vector2I(1920, 1080);
var aspect = new AspectRatioContainer
{
Ratio = (float)GameResolution.X / GameResolution.Y,
StretchMode = AspectRatioContainer.StretchModeEnum.Fit
};
previewContainer.AddChild(aspect);
_viewport = new SubViewport
{
Size = GameResolution,
Size2DOverride = GameResolution,
Size2DOverrideStretch = true
};
var clickCatcher = new Control();
clickCatcher.MouseFilter = Control.MouseFilterEnum.Stop;
@@ -161,97 +202,64 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
clickCatcher.GuiInput += OnViewportClick;
_viewport.AddChild(clickCatcher);
var viewportContainer = new SubViewportContainer();
viewportContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
viewportContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
viewportContainer.Stretch = true;
viewportContainer.StretchShrink = 1;
var viewportContainer = new SubViewportContainer { Stretch = true };
viewportContainer.AddChild(_viewport);
previewContainer.AddChild(viewportContainer);
aspect.AddChild(viewportContainer);
_selectionOverlay = new SelectionOverlay();
previewContainer.AddChild(_selectionOverlay);
_selectionOverlay.Setup(_viewport, viewportContainer);
aspect.AddChild(_selectionOverlay);
_selectionOverlay.Setup(_viewport, viewportContainer, GameResolution);
_selectionOverlay.PickRequested += OnOverlayPick;
_selectionOverlay.BatchCommitted += OnOverlayBatchCommitted;
_selectionOverlay.EnsureConstructed();
_autosaveTimer = new Timer { OneShot = true, WaitTime = 0.4 };
_autosaveTimer.Timeout += OnAutosaveTimeout;
AddChild(_autosaveTimer);
return previewContainer;
}
// Compact tabbed sidebar: inspector and tscn source share one column,
// the output strip stays below the tabs so errors are always visible.
var rightVBox = new VBoxContainer();
rightVBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
rightVBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
var rightPanel = CreateSidebar(rightVBox, 360);
mainHBox.AddChild(rightPanel);
/// <summary>Inspector and tscn source share one tabbed column.</summary>
private Control BuildSidePanel()
{
var panel = new PanelContainer { CustomMinimumSize = new Vector2(380, 0) };
panel.AddThemeStyleboxOverride("panel", SidebarBox());
_sideTabs = new TabContainer();
_sideTabs.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_sideTabs.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
rightVBox.AddChild(_sideTabs);
var inspectorTab = new VBoxContainer
_sideTabs = new TabContainer
{
Name = TranslationServer.Translate("tools/scene_builder/tab_inspector"),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
_sideTabs.AddChild(inspectorTab);
panel.AddChild(_sideTabs);
_inspector = new InspectorPanel();
inspectorTab.AddChild(_inspector);
var inspectorScroll = new ScrollContainer { HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled };
_sideTabs.AddChild(inspectorScroll);
_sideTabs.SetTabTitle(0, ToolUi.Tr("tools/scene_builder/tab_inspector"));
_inspector = new InspectorPanel { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
inspectorScroll.AddChild(_inspector);
_inspector.PropertyEdited += OnInspectorPropertyEdited;
_inspector.EnsureConstructed();
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_select"));
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));
// Titles are set explicitly: a node name cannot hold "." (".tscn").
var tscnTab = new VBoxContainer
{
Name = TranslationServer.Translate("tools/scene_builder/tab_tscn"),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
_sideTabs.AddChild(tscnTab);
_sideTabs.SetTabTitle(1, ToolUi.Tr("tools/scene_builder/tab_tscn"));
_tscnEditor = new TextEdit();
_tscnEditor.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_tscnEditor.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
_tscnEditor.WrapMode = TextEdit.LineWrappingMode.None;
_tscnEditor.AddThemeFontOverride("font", GetMonospaceFont());
_tscnEditor.TextChanged += () => SetDirty();
_tscnEditor = ToolUi.Code();
_tscnEditor.TextChanged += OnTscnTextChanged;
tscnTab.AddChild(_tscnEditor);
tscnTab.AddChild(ToolUi.Hint(ToolUi.Tr("tools/scene_builder/tscn_hint")));
_saveBtn = new Button();
_saveBtn.Text = TranslationServer.Translate("tools/scene_builder/save");
_saveBtn.Pressed += OnSavePressed;
tscnTab.AddChild(_saveBtn);
var outputHeader = new HBoxContainer();
var outputLabel = new Label { Text = TranslationServer.Translate("tools/scene_builder/output") };
outputLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
outputHeader.AddChild(outputLabel);
var clearOutputBtn = new Button { Text = TranslationServer.Translate("tools/scene_builder/clear") };
clearOutputBtn.Pressed += () => _outputLabel.Text = "";
outputHeader.AddChild(clearOutputBtn);
rightVBox.AddChild(outputHeader);
_outputLabel = new RichTextLabel();
_outputLabel.BbcodeEnabled = true;
_outputLabel.FitContent = false;
_outputLabel.ScrollActive = true;
_outputLabel.ScrollFollowing = true;
_outputLabel.CustomMinimumSize = new Vector2(0, 70);
rightVBox.AddChild(_outputLabel);
return panel;
}
private void PopulateBaseList()
{
_baseDropdown.Clear();
_baseDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/empty_scene"));
_baseDropdown.AddItem(ToolUi.Tr("tools/scene_builder/empty_scene"));
_baseScenes = _controller.GetBaseSceneList();
foreach (var s in _baseScenes)
_baseDropdown.AddItem(s.Display);
@@ -261,7 +269,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private void PopulateModList()
{
_modDropdown.Clear();
_modDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/select_mod"));
_modDropdown.AddItem(ToolUi.Tr("tools/scene_builder/select_mod"));
_modScenes = _controller.GetModSceneIndex();
_mods = _modScenes.Select(s => s.ModId).Distinct().ToList();
foreach (var modId in _mods)
@@ -269,7 +277,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
_modDropdown.Selected = 0;
_sceneDropdown.Clear();
_sceneDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/select_scene"));
_sceneDropdown.AddItem(ToolUi.Tr("tools/scene_builder/select_scene"));
_sceneDropdown.Selected = 0;
}
@@ -284,7 +292,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
}
_sceneDropdown.Clear();
_sceneDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/select_scene"));
_sceneDropdown.AddItem(ToolUi.Tr("tools/scene_builder/select_scene"));
foreach (var s in _currentModScenes)
_sceneDropdown.AddItem(s.Name);
_sceneDropdown.Selected = 0;
@@ -303,6 +311,10 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private void RebuildPreview()
{
_shownBase = _baseDropdown.Selected;
_shownMod = _modDropdown.Selected;
_shownScene = _sceneDropdown.Selected;
var baseIdx = (int)_baseDropdown.Selected;
if (baseIdx <= 0 || baseIdx - 1 >= _baseScenes.Count)
_controller.LoadEmptyScene(_viewport);
@@ -317,7 +329,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
RefreshFileSnapshot();
_selectedNode = null;
_selectionOverlay.Track(null);
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_select"));
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));
}
/// <summary>Re-parses the current editor text into the file-truth property snapshot used by the inspector.</summary>
@@ -340,43 +352,93 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private void ShowTscnText()
{
_loadingText = true;
_tscnEditor.Text = "";
if (TryGetSelectedModScene(out var scene))
{
var text = _controller.ReadSceneText(scene.ModId, scene.RelativePath);
_tscnEditor.Text = text ?? "";
}
_tscnEditor.ClearUndoHistory();
_loadingText = false;
MarkClean();
}
/// <summary>Hand edits of the source mark the window dirty; they reach the preview on save.</summary>
private void OnTscnTextChanged()
{
if (!_loadingText)
SetDirty();
}
private void OnBaseSelected(long index)
{
RebuildPreview();
RunDiscardingChanges(RebuildPreview, RestoreDropdowns);
}
private void OnModSelected(long index)
{
var modId = index == 0 ? null : _mods[(int)index - 1];
PopulateSceneListForMod(modId);
RebuildPreview();
RunDiscardingChanges(() =>
{
var modId = index == 0 ? null : _mods[(int)index - 1];
PopulateSceneListForMod(modId);
RebuildPreview();
}, RestoreDropdowns);
}
private void OnSceneSelected(long index)
{
RebuildPreview();
RunDiscardingChanges(RebuildPreview, RestoreDropdowns);
}
/// <summary>Rolls the selectors back to the scene still shown after a refused switch.</summary>
private void RestoreDropdowns()
{
_baseDropdown.Select(_shownBase);
_modDropdown.Select(_shownMod);
PopulateSceneListForMod(_shownMod <= 0 ? null : _mods[_shownMod - 1]);
_sceneDropdown.Select(_shownScene);
}
private void OnReloadPressed()
{
if (TryGetSelectedModScene(out var scene))
_controller.InvalidateScene(scene.RelativePath);
RunDiscardingChanges(() =>
{
if (TryGetSelectedModScene(out var scene))
_controller.InvalidateScene(scene.RelativePath);
RebuildPreview();
ShowOutput(TranslationServer.Translate("tools/scene_builder/reloaded"), false);
RebuildPreview();
ShowOutput(ToolUi.Tr("tools/scene_builder/reloaded"), false);
});
}
/// <summary>Ctrl+S: save the source and rebuild the preview from it.</summary>
protected override void OnSaveShortcut() => OnSavePressed();
/// <summary>Delete removes the selected mod node after a confirmation.</summary>
protected override bool HandleKey(InputEventKey key)
{
if (key.Keycode != Key.Delete || _selectedNode == null || !_controller.IsEditableModNode(_selectedNode))
return false;
ShowConfirm("tools/scene_builder/delete_node_title", "tools/scene_builder/delete_node_confirm", OnDeleteNodePressed);
return true;
}
/// <summary>
/// Saves the source and, when it was edited by hand, rebuilds the preview
/// from the saved file — hand edits do not reach the live preview
/// otherwise (inspector and overlay edits are applied live already).
/// </summary>
private void OnSavePressed()
{
SaveCurrentText(false);
var handEdited = _dirty;
if (!SaveCurrentText(false) || !handEdited || !TryGetSelectedModScene(out var scene))
return;
_controller.InvalidateScene(scene.RelativePath);
RebuildPreview();
ShowOutput(ToolUi.Tr("tools/scene_builder/saved_rebuilt"), false);
}
/// <summary>
@@ -389,20 +451,20 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
if (!TryGetSelectedModScene(out var scene))
{
if (!auto)
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"), true);
return false;
}
if (_controller.SaveSceneText(scene.ModId, scene.RelativePath, _tscnEditor.Text))
{
MarkClean();
ShowOutput(TranslationServer.Translate(auto
ShowOutput(ToolUi.Tr(auto
? "tools/scene_builder/autosaved"
: "tools/scene_builder/saved_ok"), false);
return true;
}
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_save"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/error_save"), true);
GameLogger.Log("SCENE_BUILDER",
$"save failed: mod='{scene.ModId}' path='{scene.RelativePath}' (zip mods are read-only)",
LogLevel.Error);
@@ -466,7 +528,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
}
else
{
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_readonly"));
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_readonly"));
_selectionOverlay.Track(null);
}
}
@@ -501,7 +563,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
{
if (!TryGetSelectedModScene(out var scene))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"), true);
return;
}
@@ -509,14 +571,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
var parent = ResolveAddParent();
if (parent == null)
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"), true);
return;
}
var created = _controller.CreateUniqueChild(parent, typeName);
if (created == null)
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_add_failed"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/err_add_failed"), true);
GameLogger.Log("SCENE_BUILDER", $"ClassDB.Instantiate failed for '{typeName}'", LogLevel.Error);
return;
}
@@ -527,7 +589,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
{
created.GetParent().RemoveChild(created);
created.QueueFree();
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_add_parent_missing"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/err_add_parent_missing"), true);
GameLogger.Log("SCENE_BUILDER",
$"node insert rejected: parent section '{parentPath}' absent from file", LogLevel.Warning);
return;
@@ -578,19 +640,19 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
var node = _selectedNode;
if (node == null || !_controller.IsEditableModNode(node))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_del_none"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/err_del_none"), true);
return;
}
if (node == _controller.GetModRoot())
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_del_root"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/err_del_root"), true);
return;
}
var path = _controller.GetModNodePath(node);
if (!_controller.DeleteModNode(node))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/err_del_none"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/err_del_none"), true);
return;
}
@@ -602,14 +664,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
}
else
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_missing_section"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_missing_section"), true);
GameLogger.Log("SCENE_BUILDER",
$"subtree remove failed: section '{path}' absent from file", LogLevel.Warning);
}
_selectedNode = null;
_selectionOverlay.Track(null);
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_select"));
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));
_controller.BuildHierarchy(_hierarchyTree);
}
@@ -622,14 +684,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
{
if (!TryGetSelectedModScene(out var scene))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"), true);
return;
}
var nodePath = _controller.GetModNodePath(node);
if (nodePath == null)
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_readonly"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_readonly"), true);
GameLogger.Log("SCENE_BUILDER", $"batch rejected: '{node.Name}' is not an editable mod node", LogLevel.Warning);
return;
}
@@ -640,14 +702,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
var serialized = TscnValueFormatter.Format(pair.Value);
if (serialized == null)
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_bad_value"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_bad_value"), true);
GameLogger.Log("SCENE_BUILDER", $"unserializable value for '{pair.Key}'", LogLevel.Warning);
continue;
}
if (!TscnTextSurgery.TryUpsertProperty(text, nodePath, pair.Key, serialized, out text))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_missing_section"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_missing_section"), true);
GameLogger.Log("SCENE_BUILDER",
$"section not found: path='{nodePath}' key='{pair.Key}'", LogLevel.Warning);
}
@@ -670,14 +732,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
{
if (!TryGetSelectedModScene(out var scene))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/error_no_scene"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"), true);
return;
}
var serialized = TscnValueFormatter.Format(value);
if (serialized == null)
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_bad_value"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_bad_value"), true);
GameLogger.Log("SCENE_BUILDER", $"unserializable value for '{key}'", LogLevel.Warning);
return;
}
@@ -685,14 +747,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
var nodePath = _controller.GetModNodePath(node);
if (nodePath == null)
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_readonly"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_readonly"), true);
GameLogger.Log("SCENE_BUILDER", $"edit rejected: '{node.Name}' is not an editable mod node", LogLevel.Warning);
return;
}
if (!TscnTextSurgery.TryUpsertProperty(_tscnEditor.Text, nodePath, key, serialized, out var updated))
{
ShowOutput(TranslationServer.Translate("tools/scene_builder/inspector_missing_section"), true);
ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_missing_section"), true);
GameLogger.Log("SCENE_BUILDER",
$"section not found: path='{nodePath}' key='{key}'", LogLevel.Warning);
return;
@@ -716,11 +778,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private void ShowOutput(string text, bool isError)
{
if (_outputLabel == null)
return;
var color = isError ? "red" : "green";
_outputLabel.Text = $"[color={color}]{text}[/color]";
SetStatus(text, isError ? ToolStatusKind.Error : ToolStatusKind.Ok);
}
}
}
+53 -9
View File
@@ -16,11 +16,14 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
/// overlay itself hit-tests them in its <c>GuiInput</c> handler — because
/// per-marker Controls cannot capture motion outside their small rects and
/// virtual overrides (_Process/_GuiInput) do not dispatch in mod assemblies.
/// Pressing inside the selected rectangle and dragging moves the node (all
/// four offsets shift together); a press that does not move is a pick.
/// Live edits go straight onto the node during the drag; the accumulated
/// key/value batch is emitted once on mouse release via
/// <see cref="BatchCommitted"/> so text write-back happens in one pass.
/// Coordinates map between the fixed-size SubViewport canvas and this
/// overlay through the stretch scale of the hosting SubViewportContainer.
/// Coordinates map between the game-resolution canvas of the SubViewport
/// and this overlay (which shares the viewport container's rectangle) by a
/// plain scale.
/// </summary>
public partial class SelectionOverlay : ModWidget
{
@@ -43,7 +46,9 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private const int ResizeMarkerCount = 8;
private const int AnchorDotCount = 4;
private enum DragKind { None, Resize, Anchor }
private const float MoveThreshold = 4f;
private enum DragKind { None, Resize, Anchor, PendingMove, Move }
private struct Marker
{
@@ -54,6 +59,9 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private SubViewport _viewport;
private Control _containerArea;
private Vector2 _canvasSize;
private Vector2 _pressPoint;
private readonly float[] _moveStartOffsets = new float[4];
private Control _selected;
private readonly List<Marker> _markers = new();
private readonly List<ColorRect> _borderRects = new();
@@ -66,10 +74,11 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private readonly Dictionary<string, double> _pendingValues = new();
/// <summary>Binds the overlay to the preview pipeline; call once before use.</summary>
public void Setup(SubViewport viewport, Control containerArea)
public void Setup(SubViewport viewport, Control containerArea, Vector2I canvasSize)
{
_viewport = viewport;
_containerArea = containerArea;
_canvasSize = canvasSize;
_containerArea.Resized += Refresh;
Refresh();
}
@@ -250,6 +259,11 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
{
if (mb.Pressed)
OnPress(mb.Position);
else if (_drag == DragKind.PendingMove)
{
_drag = DragKind.None;
PickRequested?.Invoke(LocalToCanvas(mb.Position));
}
else if (_drag != DragKind.None)
FinishDrag();
return;
@@ -257,7 +271,10 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
if (evt is InputEventMouseMotion motion)
{
if (_drag != DragKind.None)
if (_drag == DragKind.PendingMove && (motion.Position - _pressPoint).Length() >= MoveThreshold)
_drag = DragKind.Move;
if (_drag is DragKind.Resize or DragKind.Anchor or DragKind.Move)
{
ApplyDrag(motion.Position);
Refresh();
@@ -291,6 +308,19 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
return;
}
if (_markers.Count > 0 && _selected is Control control
&& CanvasRectToOverlay(control.GetGlobalRect()).HasPoint(localPoint))
{
BeginDrag(DragKind.PendingMove, 0);
_pressPoint = localPoint;
_moveStartOffsets[0] = control.OffsetLeft;
_moveStartOffsets[1] = control.OffsetTop;
_moveStartOffsets[2] = control.OffsetRight;
_moveStartOffsets[3] = control.OffsetBottom;
AcceptEvent();
return;
}
PickRequested?.Invoke(LocalToCanvas(localPoint));
}
@@ -315,6 +345,18 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
return;
}
if (_drag == DragKind.Move)
{
var delta = LocalToCanvas(localPoint) - LocalToCanvas(_pressPoint);
var dx = Mathf.Round(delta.X);
var dy = Mathf.Round(delta.Y);
StorePair(control, "offset_left", _moveStartOffsets[0] + dx);
StorePair(control, "offset_top", _moveStartOffsets[1] + dy);
StorePair(control, "offset_right", _moveStartOffsets[2] + dx);
StorePair(control, "offset_bottom", _moveStartOffsets[3] + dy);
return;
}
ApplyAnchorDrag(control, parentSize, mouseInParent, _dragIndex);
}
@@ -453,17 +495,19 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
}
}
MouseDefaultCursorShape = Control.CursorShape.Arrow;
MouseDefaultCursorShape = _markers.Count > 0 && _selected is Control control
&& CanvasRectToOverlay(control.GetGlobalRect()).HasPoint(localPoint)
? Control.CursorShape.Move
: Control.CursorShape.Arrow;
}
// ── coordinate mapping ──────────────────────────────────────────
private Vector2 ComputeStretchScale()
{
var viewSize = _viewport.Size;
return new Vector2(
viewSize.X > 0 ? _containerArea.Size.X / viewSize.X : 1f,
viewSize.Y > 0 ? _containerArea.Size.Y / viewSize.Y : 1f);
_canvasSize.X > 0 ? _containerArea.Size.X / _canvasSize.X : 1f,
_canvasSize.Y > 0 ? _containerArea.Size.Y / _canvasSize.Y : 1f);
}
private Vector2 CanvasToOverlay(Vector2 canvasPoint)
+5 -101
View File
@@ -1,102 +1,21 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Providers;
using Cthangover.Mods.Map;
using Cthangover.Core.Utils;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Static service for discovering, reading and writing world map zone files
/// (<c>mods/&lt;mod&gt;/map/*.json</c>) from the map editor. Follows
/// <see cref="ScenarioFileService"/>: reading goes through the mod file
/// provider abstraction so zip mods are visible, writing is deliberately
/// limited to folder-backed mods because zip archives are read-only.
/// <para/>
/// New files are created through the provider's <c>RootPath</c> rather than
/// <c>GetFileSystemPath</c>, which reports <c>null</c> for files that do not
/// exist yet — the editor must be able to author a brand-new map file, not
/// only overwrite an existing one.
/// (De)serialisation of world map zone files (<c>mods/&lt;mod&gt;/map/*.json</c>)
/// for the map editor. File access itself goes through
/// <see cref="ModFileService"/>.
/// </summary>
public static class MapFileService
{
private const string MapDir = "map";
private const string JsonExtension = ".json";
/// <summary>
/// IDs of every loaded mod that can be written to (folder-backed).
/// Zip mods are skipped silently: they appear in the game but the editor
/// cannot save into them.
/// </summary>
public static List<string> ListWritableMods()
{
var result = new List<string>();
var manager = ModManager.Instance;
if (manager?.Mods == null)
return result;
foreach (var pair in manager.Mods)
{
if (pair.Value?.FileProvider is FolderModFileProvider)
result.Add(pair.Key);
}
result.Sort(StringComparer.OrdinalIgnoreCase);
return result;
}
/// <summary>
/// Mod-relative paths of all <c>.json</c> files under the mod's
/// <c>map/</c> directory, including nested subdirectories. Returns an
/// empty list for unknown mods.
/// </summary>
public static List<string> ListMapFiles(string modId)
{
var result = new List<string>();
if (string.IsNullOrEmpty(modId))
return result;
CollectJsonFiles(modId, MapDir, result);
result.Sort(StringComparer.OrdinalIgnoreCase);
return result;
}
/// <summary>Reads a map file through the mod provider; <c>null</c> when missing.</summary>
public static string ReadFile(string modId, string relativePath)
{
return ModManager.Instance.ReadFileText(modId, relativePath);
}
/// <summary>
/// Writes a map file into a folder-backed mod, creating the
/// <c>map/</c> directory and any missing parents. Returns <c>false</c>
/// for zip mods, unknown mods and empty paths.
/// </summary>
public static bool WriteFile(string modId, string relativePath, string text)
{
if (string.IsNullOrEmpty(modId) || string.IsNullOrEmpty(relativePath))
return false;
var mod = ModManager.Instance.GetMod(modId);
if (mod?.FileProvider is not FolderModFileProvider provider)
return false;
var root = provider.RootPath;
if (string.IsNullOrEmpty(root))
return false;
var absolute = Path.GetFullPath(Path.Combine(root, relativePath.Replace('\\', '/')));
var directory = Path.GetDirectoryName(absolute);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(absolute, text);
return true;
}
/// <summary>Folder of map zone files inside a mod.</summary>
public const string MapDir = "map";
/// <summary>
/// Parses the standard <c>{"Items": [...]}</c> envelope into zones.
@@ -122,20 +41,5 @@ namespace Cthangover.Tools.Services
var data = new FileData<MapZone> { Items = zones?.ToList() ?? new List<MapZone>() };
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
}
private static void CollectJsonFiles(string modId, string directory, List<string> result)
{
foreach (var entry in ModManager.Instance.ListFiles(modId, directory))
{
if (entry.EndsWith("/"))
{
CollectJsonFiles(modId, entry.TrimEnd('/'), result);
continue;
}
if (entry.EndsWith(JsonExtension, StringComparison.OrdinalIgnoreCase))
result.Add(entry.Replace('\\', '/'));
}
}
}
}
+115
View File
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.IO;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Providers;
namespace Cthangover.Tools.Services
{
/// <summary>
/// File access shared by the editors: listing a mod's files by folder and
/// extension, reading them through the mod provider and writing them back.
/// Reading works for every mod (folder, zip, test-suite subdir), writing only
/// for folder-backed mods — the only ones with a real directory behind them.
/// New files are created under the provider's <c>RootPath</c> because
/// <c>GetFileSystemPath</c> reports <c>null</c> for files that do not exist
/// yet, and an editor must be able to author a brand-new file.
/// </summary>
public static class ModFileService
{
/// <summary>IDs of every loaded folder-backed (writable) mod, sorted.</summary>
public static List<string> ListWritableMods()
{
var result = new List<string>();
var manager = ModManager.Instance;
if (manager?.Mods == null)
return result;
foreach (var pair in manager.Mods)
{
if (pair.Value?.FileProvider is FolderModFileProvider)
result.Add(pair.Key);
}
result.Sort(StringComparer.OrdinalIgnoreCase);
return result;
}
/// <summary>True when the mod is folder-backed, i.e. the editors can save into it.</summary>
public static bool IsWritable(string modId)
{
return !string.IsNullOrEmpty(modId)
&& ModManager.Instance.GetMod(modId)?.FileProvider is FolderModFileProvider;
}
/// <summary>
/// Mod-relative paths of the files under <paramref name="directory"/>
/// (recursively) ending with <paramref name="extension"/>, sorted.
/// </summary>
public static List<string> ListFiles(string modId, string directory, string extension)
{
var result = new List<string>();
if (string.IsNullOrEmpty(modId))
return result;
Collect(modId, directory, extension, result);
result.Sort(StringComparer.OrdinalIgnoreCase);
return result;
}
/// <summary>Reads a file through the mod provider; <c>null</c> when missing.</summary>
public static string ReadText(string modId, string relativePath)
{
return ModManager.Instance.ReadFileText(modId, relativePath);
}
/// <summary>
/// Writes a file into a folder-backed mod, creating missing directories.
/// Returns <c>false</c> for zip/virtual mods, unknown mods and empty paths.
/// </summary>
public static bool WriteText(string modId, string relativePath, string text)
{
var absolute = GetWritablePath(modId, relativePath);
if (absolute == null)
return false;
var directory = Path.GetDirectoryName(absolute);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(absolute, text);
return true;
}
/// <summary>
/// Absolute path a file of a folder-backed mod has (or would have once
/// written); <c>null</c> for read-only mods and empty paths.
/// </summary>
public static string GetWritablePath(string modId, string relativePath)
{
if (string.IsNullOrEmpty(modId) || string.IsNullOrEmpty(relativePath))
return null;
if (ModManager.Instance.GetMod(modId)?.FileProvider is not FolderModFileProvider provider
|| string.IsNullOrEmpty(provider.RootPath))
return null;
return Path.GetFullPath(Path.Combine(provider.RootPath, relativePath.Replace('\\', '/')));
}
private static void Collect(string modId, string directory, string extension, List<string> result)
{
foreach (var entry in ModManager.Instance.ListFiles(modId, directory))
{
if (entry.EndsWith("/"))
{
Collect(modId, entry.TrimEnd('/'), extension, result);
continue;
}
if (entry.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
result.Add(entry.Replace('\\', '/'));
}
}
}
}
+28 -2
View File
@@ -20,7 +20,8 @@ namespace Cthangover.Tools.Services
{
/// <summary>
/// Returns all unique background IDs (filenames without extension) from
/// the <c>"backgrounds"</c> file collection across all installed mods.
/// the <c>"backgrounds"</c> file collection across all installed mods,
/// sorted, without the <c>_albedo</c>/<c>_depth</c> lighting masks.
/// </summary>
public static List<string> GetBackgroundIds()
{
@@ -35,11 +36,13 @@ namespace Cthangover.Tools.Services
if (!string.IsNullOrWhiteSpace(id) && exts.Contains(ext.ToLower()))
{
var cleanId = id.Substring(0, id.LastIndexOf('.'));
if (!cleanId.EndsWith("_albedo") && !cleanId.EndsWith("_depth"))
var bare = cleanId.TrimEnd('_');
if (!bare.EndsWith("_albedo") && !bare.EndsWith("_depth"))
ids.Add(cleanId);
}
}
ids.Sort(StringComparer.OrdinalIgnoreCase);
return ids;
}
@@ -76,6 +79,29 @@ namespace Cthangover.Tools.Services
return ids;
}
/// <summary>
/// Texture IDs that make sense as small icons (map markers): every
/// texture except backgrounds and their <c>_depth</c>/<c>_albedo</c>
/// masks. Textures are keyed by file name, so backgrounds are excluded
/// by the file-name part of their IDs.
/// </summary>
public static List<string> GetIconIds()
{
var backgrounds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var id in GetBackgroundIds())
backgrounds.Add(id.Substring(id.LastIndexOf('/') + 1));
var result = new List<string>();
foreach (var id in GetTextureIds())
{
if (backgrounds.Contains(id) || id.EndsWith("_depth", StringComparison.OrdinalIgnoreCase)
|| id.EndsWith("_albedo", StringComparison.OrdinalIgnoreCase))
continue;
result.Add(id);
}
return result;
}
/// <summary>
/// Loads a background texture by ID via <see cref="BackgroundFactory.Instance.Get"/>.
/// Returns <c>null</c> on any error (missing resource, load failure).
+80
View File
@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods.Resolvers;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Finds which background an editor should show for a piece of content,
/// so the author does not have to guess it from a list of hundreds:
/// <list type="bullet">
/// <item>a scene's background is the first <c>defaultBackground</c> of its
/// <c>scenes/*.json</c> definition;</item>
/// <item>an interactive belongs to the scene of the scenario that adds it
/// (<c>interactive_add &lt;id&gt;</c>).</item>
/// </list>
/// Both are best-effort hints: <c>null</c> means "no idea", and the author
/// can always pick another background by hand.
/// </summary>
public static class SceneBackgroundService
{
private const string InteractiveAddCommand = "interactive_add";
/// <summary>Default background ID of a logical scene, or <c>null</c>.</summary>
public static string ForScene(string sceneName)
{
if (string.IsNullOrEmpty(sceneName) || sceneName == "-")
return null;
var defs = SceneDefinitions.Collect();
if (defs == null)
return null;
foreach (var pair in defs)
{
if (!string.Equals(pair.Key, sceneName, StringComparison.OrdinalIgnoreCase) || pair.Value == null)
continue;
// Several mods may define the scene; the last one wins at runtime.
for (var i = pair.Value.Count - 1; i >= 0; i--)
{
if (pair.Value[i]?.DefaultBackground is { Count: > 0 } list)
return list[0];
}
}
return null;
}
/// <summary>
/// Scans every scenario once and maps interactive IDs to the scene of
/// the first scenario that adds them.
/// </summary>
public static Dictionary<string, string> MapInteractivesToScenes()
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var mod in ScenarioFileService.ScanScenarioFiles())
{
foreach (var group in mod.Groups)
{
foreach (var file in group.Files)
{
var text = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath);
if (string.IsNullOrEmpty(text) || text.IndexOf(InteractiveAddCommand, StringComparison.Ordinal) < 0)
continue;
var scene = ScenarioFileService.ParseMetadata(text).Scene;
foreach (var raw in text.Split('\n'))
{
var tokens = ScenarioSyntaxService.Tokenize(raw.Trim());
if (tokens.Count >= 2 && tokens[0] == InteractiveAddCommand && !result.ContainsKey(tokens[1]))
result[tokens[1]] = scene;
}
}
}
}
return result;
}
}
}