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:
1 parent
578ba7c5d5
commit
8742aa3da5
45 files changed
+4553
-3298
No files matched your search
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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 = Главное меню
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_urlEdit.Text))
|
||||
{
|
||||
SetStatus(ToolUi.Tr("tools/catalog_export/url_required"), ToolStatusKind.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
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);
|
||||
|
||||
QueueFree();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
/// 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>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>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>Builds an InteractiveDefinition from current state.</summary>
|
||||
public InteractiveDefinition ToDefinition()
|
||||
/// <summary>Replaces the items with the contents of a <c>{"Items": [...]}</c> file. Throws on malformed JSON.</summary>
|
||||
public void FromJson(string json)
|
||||
{
|
||||
var hitArea = new HitAreaDefinition { Type = HitType };
|
||||
|
||||
switch (HitType)
|
||||
{
|
||||
case "rect":
|
||||
hitArea.X = RectX;
|
||||
hitArea.Y = RectY;
|
||||
hitArea.Width = RectW;
|
||||
hitArea.Height = RectH;
|
||||
break;
|
||||
case "circle":
|
||||
hitArea.X = CircleX;
|
||||
hitArea.Y = CircleY;
|
||||
hitArea.Radius = CircleRadius;
|
||||
break;
|
||||
case "polygon":
|
||||
hitArea.Vertices = PolygonVertices.ToArray();
|
||||
break;
|
||||
var 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;
|
||||
}
|
||||
|
||||
var clickAction = new ClickAction();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(OnClickScenario))
|
||||
clickAction.Scenario = OnClickScenario.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(OnClickCommands))
|
||||
{
|
||||
var lines = OnClickCommands.Split('\n', System.StringSplitOptions.RemoveEmptyEntries);
|
||||
var cleanLines = new List<string>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (!string.IsNullOrEmpty(trimmed))
|
||||
cleanLines.Add(trimmed);
|
||||
}
|
||||
if (cleanLines.Count > 0)
|
||||
clickAction.Commands = cleanLines.ToArray();
|
||||
}
|
||||
|
||||
return new InteractiveDefinition
|
||||
{
|
||||
ID = Id,
|
||||
Texture = string.IsNullOrWhiteSpace(Texture) ? null : Texture.Trim(),
|
||||
Layer = Layer,
|
||||
ZIndex = ZIndex,
|
||||
Enabled = Enabled,
|
||||
Visible = Visible,
|
||||
Cursor = string.IsNullOrWhiteSpace(Cursor) ? null : Cursor.Trim(),
|
||||
HitArea = hitArea,
|
||||
Highlight = new HighlightDefinition
|
||||
{
|
||||
ColorHex = HighlightColorHex,
|
||||
Scale = HighlightScale
|
||||
},
|
||||
Actions = new InteractiveActionDefinition
|
||||
{
|
||||
OnClick = (clickAction.Scenario != null || clickAction.Commands != null) ? clickAction : null,
|
||||
OnHoverEnter = string.IsNullOrWhiteSpace(OnHoverEnter) ? null : OnHoverEnter.Trim(),
|
||||
OnHoverLeave = string.IsNullOrWhiteSpace(OnHoverLeave) ? null : OnHoverLeave.Trim()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Loads state from an InteractiveDefinition.</summary>
|
||||
public void FromDefinition(InteractiveDefinition def)
|
||||
{
|
||||
if (def == null) return;
|
||||
|
||||
Id = def.ID ?? "new_interactive";
|
||||
Texture = def.Texture ?? "";
|
||||
Layer = def.Layer ?? "foreground";
|
||||
ZIndex = def.ZIndex;
|
||||
Enabled = def.Enabled;
|
||||
Visible = def.Visible;
|
||||
Cursor = def.Cursor ?? "";
|
||||
|
||||
if (def.HitArea != null)
|
||||
{
|
||||
HitType = def.HitArea.Type ?? "rect";
|
||||
RectX = def.HitArea.X;
|
||||
RectY = def.HitArea.Y;
|
||||
RectW = def.HitArea.Width;
|
||||
RectH = def.HitArea.Height;
|
||||
CircleX = def.HitArea.X;
|
||||
CircleY = def.HitArea.Y;
|
||||
CircleRadius = def.HitArea.Radius;
|
||||
PolygonVertices = def.HitArea.Vertices != null
|
||||
? new List<Vector2>(def.HitArea.Vertices)
|
||||
: new List<Vector2>();
|
||||
}
|
||||
else
|
||||
{
|
||||
HitType = "rect";
|
||||
}
|
||||
|
||||
if (def.Highlight != null)
|
||||
{
|
||||
HighlightColorHex = def.Highlight.ColorHex ?? "#FFFF0033";
|
||||
HighlightScale = def.Highlight.Scale;
|
||||
}
|
||||
|
||||
OnClickScenario = def.Actions?.OnClick?.Scenario ?? "";
|
||||
OnClickCommands = def.Actions?.OnClick?.Commands != null
|
||||
? string.Join("\n", def.Actions.OnClick.Commands)
|
||||
: "";
|
||||
OnHoverEnter = def.Actions?.OnHoverEnter ?? "";
|
||||
OnHoverLeave = def.Actions?.OnHoverLeave ?? "";
|
||||
}
|
||||
|
||||
/// <summary>Serializes the definition to formatted JSON (FileData envelope).</summary>
|
||||
/// <summary>Serialises all items into the standard envelope (indented, nulls omitted).</summary>
|
||||
public string ToJson()
|
||||
{
|
||||
var def = ToDefinition();
|
||||
var wrapper = new
|
||||
var data = new FileData<InteractiveDefinition> { Items = Items };
|
||||
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
|
||||
}
|
||||
|
||||
/// <summary>Drops all items.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
Items = new[] { def }
|
||||
Items = new List<InteractiveDefinition>();
|
||||
SelectedIndex = -1;
|
||||
}
|
||||
|
||||
/// <summary>Selects by index; out of range clears the selection.</summary>
|
||||
public void Select(int index)
|
||||
{
|
||||
SelectedIndex = index >= 0 && index < Items.Count ? index : -1;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
var options = new JsonSerializerOptions
|
||||
/// <summary>Appends a deep copy of the selected definition with a fresh ID, slightly offset.</summary>
|
||||
public InteractiveDefinition DuplicateSelected()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
var source = Selected;
|
||||
if (source == null)
|
||||
return null;
|
||||
|
||||
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>Removes the selected definition and keeps a sensible selection.</summary>
|
||||
public void RemoveSelected()
|
||||
{
|
||||
if (Selected == null)
|
||||
return;
|
||||
|
||||
Items.RemoveAt(SelectedIndex);
|
||||
SelectedIndex = Items.Count == 0 ? -1 : Math.Min(SelectedIndex, Items.Count - 1);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
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)
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(wrapper, options);
|
||||
}
|
||||
|
||||
/// <summary>Parses JSON back into the controller. Returns false on failure.</summary>
|
||||
public bool FromJson(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip };
|
||||
var wrapper = JsonSerializer.Deserialize<FileDataWrapper>(json, options);
|
||||
if (wrapper?.Items != null && wrapper.Items.Count > 0)
|
||||
{
|
||||
FromDefinition(wrapper.Items[0]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
break;
|
||||
default:
|
||||
hit.X = bounds.Position.X;
|
||||
hit.Y = bounds.Position.Y;
|
||||
hit.Width = bounds.Size.X;
|
||||
hit.Height = bounds.Size.Y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private class FileDataWrapper
|
||||
/// <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)
|
||||
{
|
||||
public List<InteractiveDefinition> Items { get; set; }
|
||||
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>Updates a polygon vertex at the given index.</summary>
|
||||
public void SetPolygonVertex(int index, Vector2 value)
|
||||
/// <summary>Makes sure the optional parts the editor binds to exist.</summary>
|
||||
public static void Ensure(InteractiveDefinition def)
|
||||
{
|
||||
while (PolygonVertices.Count <= index)
|
||||
PolygonVertices.Add(Vector2.Zero);
|
||||
PolygonVertices[index] = value;
|
||||
def.HitArea ??= new HitAreaDefinition();
|
||||
def.HitArea.Type = HitTypes.Contains(def.HitArea.Type) ? def.HitArea.Type : "rect";
|
||||
def.Highlight ??= new HighlightDefinition();
|
||||
def.Actions ??= new InteractiveActionDefinition();
|
||||
}
|
||||
|
||||
/// <summary>Removes a polygon vertex at the given index.</summary>
|
||||
public void RemovePolygonVertex(int index)
|
||||
/// <summary>ID not used by another item: <c>base</c>, <c>base_2</c>, <c>base_3</c>…</summary>
|
||||
public string MakeUniqueId(string baseId)
|
||||
{
|
||||
if (index >= 0 && index < PolygonVertices.Count)
|
||||
PolygonVertices.RemoveAt(index);
|
||||
var used = new HashSet<string>(Items.Select(i => i.ID ?? string.Empty), StringComparer.OrdinalIgnoreCase);
|
||||
if (!used.Contains(baseId))
|
||||
return baseId;
|
||||
|
||||
var suffix = 2;
|
||||
while (used.Contains($"{baseId}_{suffix}"))
|
||||
suffix++;
|
||||
return $"{baseId}_{suffix}";
|
||||
}
|
||||
|
||||
/// <summary>Adds a new polygon vertex at the default position (center of viewport).</summary>
|
||||
public void AddPolygonVertex()
|
||||
private static void Offset(HitAreaDefinition hit, Vector2 delta)
|
||||
{
|
||||
var offset = PolygonVertices.Count * 0.04f;
|
||||
PolygonVertices.Add(new Vector2(0.5f + offset, 0.5f + offset));
|
||||
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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
/// <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()
|
||||
/// <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)
|
||||
{
|
||||
for (int i = 0; i < 11; i++)
|
||||
{
|
||||
previewPositions[i] = new Vector2(-1000f, -1000f);
|
||||
previewRadii[i] = 0f;
|
||||
previewInfluence[i] = 0f;
|
||||
previewColors[i] = UiTokens.White;
|
||||
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>
|
||||
/// 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 bool LoadFromScenario(string text)
|
||||
{
|
||||
Lights = new List<LightDef>();
|
||||
SelectedIndex = -1;
|
||||
|
||||
var index = FindLine(text, out var lines);
|
||||
if (index < 0)
|
||||
return false;
|
||||
|
||||
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>
|
||||
/// 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.
|
||||
/// 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 ShaderMaterial CreatePreviewMaterial()
|
||||
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());
|
||||
}
|
||||
|
||||
return string.Join(newline, lines);
|
||||
}
|
||||
|
||||
/// <summary>True when the scenario text contains a <c>light_set</c> line.</summary>
|
||||
public static bool HasLights(string text) => FindLine(text, out _) >= 0;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
// ── shader preview ─────────────────────────────────────────
|
||||
|
||||
/// <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;
|
||||
|
||||
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 material = new ShaderMaterial { Shader = shader };
|
||||
material.SetShaderParameter("time_weights", new Vector4(0, 0, 0, 1));
|
||||
material.SetShaderParameter("use_time", true);
|
||||
return material;
|
||||
}
|
||||
|
||||
/// <summary>Sets the background texture uniform on the preview shader material.</summary>
|
||||
public void SetBackgroundForPreview(Texture2D texture, ShaderMaterial 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)
|
||||
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)
|
||||
if (material == null)
|
||||
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;
|
||||
material.SetShaderParameter("depth_mask", depth);
|
||||
material.SetShaderParameter("albedo_map", albedo);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// 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, Vector2 previewGlobalPos, Vector2 previewSize)
|
||||
public void UpdatePreview(ShaderMaterial material, Func<Vector2, Vector2> toCanvas, float radiusScale)
|
||||
{
|
||||
if (material == null || !dirty)
|
||||
if (material == null)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < 11; i++)
|
||||
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++)
|
||||
{
|
||||
previewPositions[i] = new Vector2(-1000f, -1000f);
|
||||
previewRadii[i] = 0f;
|
||||
previewInfluence[i] = 0f;
|
||||
previewColors[i] = UiTokens.White;
|
||||
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);
|
||||
}
|
||||
|
||||
int count = Mathf.Min(Lights.Count, 10);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var pixelPos = Lights[i].ToPixelPos(previewSize);
|
||||
previewPositions[i] = new Vector2(
|
||||
pixelPos.X + previewGlobalPos.X,
|
||||
pixelPos.Y + previewGlobalPos.Y);
|
||||
previewRadii[i] = Lights[i].Radius;
|
||||
previewInfluence[i] = Lights[i].Influence;
|
||||
previewColors[i] = Lights[i].ToColor();
|
||||
}
|
||||
|
||||
var posArray = new Godot.Collections.Array<Vector2>();
|
||||
var colArray = new Godot.Collections.Array<Color>();
|
||||
var radArray = new Godot.Collections.Array<float>();
|
||||
var infArray = new Godot.Collections.Array<float>();
|
||||
for (int i = 0; i < 11; i++)
|
||||
{
|
||||
posArray.Add(previewPositions[i]);
|
||||
colArray.Add(previewColors[i]);
|
||||
radArray.Add(previewRadii[i]);
|
||||
infArray.Add(previewInfluence[i]);
|
||||
}
|
||||
|
||||
material.SetShaderParameter("light_count", count);
|
||||
material.SetShaderParameter("light_positions", posArray);
|
||||
material.SetShaderParameter("light_colors", colArray);
|
||||
material.SetShaderParameter("light_radii", radArray);
|
||||
material.SetShaderParameter("light_influence", infArray);
|
||||
material.SetShaderParameter("light_radius_scale", 1.0f);
|
||||
|
||||
dirty = false;
|
||||
}
|
||||
|
||||
/// <summary>Serialises the light list as a compact JSON string.</summary>
|
||||
public string ExportLightsJson()
|
||||
{
|
||||
return JsonSerializer.Serialize(Lights, Cthangover.Core.Utils.JsonOptionsProvider.WriteCompactOptions);
|
||||
}
|
||||
|
||||
/// <summary>Produces a <c>light_set</c> scenario DSL command with the lights JSON as its argument.</summary>
|
||||
public string ExportScenarioSnippet()
|
||||
{
|
||||
var json = ExportLightsJson();
|
||||
return $"light_set \"{json.Replace("\"", "\\\"")}\"";
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
LoadBackgrounds();
|
||||
LoadScenarioList();
|
||||
SetHint(ToolUi.Tr("tools/light_editor/hint"));
|
||||
}
|
||||
|
||||
protected override void Cleanup()
|
||||
{
|
||||
foreach (var h in handles)
|
||||
h.QueueFree();
|
||||
handles.Clear();
|
||||
}
|
||||
|
||||
private Vector2 PreviewSize()
|
||||
{
|
||||
var sz = preview.Size;
|
||||
if (sz.X <= 0 || sz.Y <= 0)
|
||||
sz = new Vector2(1920, 1080);
|
||||
return sz;
|
||||
}
|
||||
|
||||
private void RefreshAll()
|
||||
{
|
||||
RepositionHandles();
|
||||
controller.MarkDirty();
|
||||
controller.UpdatePreview(previewMaterial, preview.GlobalPosition, PreviewSize());
|
||||
UpdateLightList();
|
||||
UpdateJsonEdit();
|
||||
}
|
||||
// ── UI construction ────────────────────────────────────────
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var outerVBox = CreateFillContainer();
|
||||
AddChild(outerVBox);
|
||||
var outer = CreateFillContainer();
|
||||
AddChild(outer);
|
||||
|
||||
var mainHBox = new HBoxContainer();
|
||||
mainHBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
mainHBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||
outerVBox.AddChild(mainHBox);
|
||||
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);
|
||||
|
||||
var sidebarVBox = new VBoxContainer();
|
||||
var sidebar = CreateSidebar(sidebarVBox);
|
||||
mainHBox.AddChild(sidebar);
|
||||
|
||||
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/background") });
|
||||
|
||||
backgroundList = new ItemList();
|
||||
backgroundList.CustomMinimumSize = new Vector2(0, 140);
|
||||
backgroundList.ItemSelected += OnBackgroundSelected;
|
||||
sidebarVBox.AddChild(backgroundList);
|
||||
|
||||
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/static_lights") });
|
||||
|
||||
lightListPanel = new VBoxContainer();
|
||||
sidebarVBox.AddChild(lightListPanel);
|
||||
|
||||
addLightBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/add_light") };
|
||||
addLightBtn.Pressed += OnAddLight;
|
||||
sidebarVBox.AddChild(addLightBtn);
|
||||
|
||||
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/selected_props") });
|
||||
|
||||
var radiusHBox = new HBoxContainer();
|
||||
radiusHBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/radius") });
|
||||
radiusEdit = new LineEdit { Text = "300" };
|
||||
radiusEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
radiusEdit.TextChanged += OnRadiusChanged;
|
||||
radiusHBox.AddChild(radiusEdit);
|
||||
sidebarVBox.AddChild(radiusHBox);
|
||||
|
||||
var infHBox = new HBoxContainer();
|
||||
infHBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/influence") });
|
||||
influenceEdit = new LineEdit { Text = "1" };
|
||||
influenceEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
influenceEdit.TextChanged += OnInfluenceChanged;
|
||||
infHBox.AddChild(influenceEdit);
|
||||
sidebarVBox.AddChild(infHBox);
|
||||
|
||||
var colorHBox = new HBoxContainer();
|
||||
colorHBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/color") });
|
||||
colorPicker = new ColorPickerButton { Color = Colors.Yellow };
|
||||
colorPicker.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
colorPicker.ColorChanged += OnColorChanged;
|
||||
colorHBox.AddChild(colorPicker);
|
||||
sidebarVBox.AddChild(colorHBox);
|
||||
|
||||
var importBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/import_json") };
|
||||
importBtn.Pressed += OnImport;
|
||||
sidebarVBox.AddChild(importBtn);
|
||||
|
||||
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/norm_coords") });
|
||||
|
||||
var previewContainer = new PanelContainer();
|
||||
previewContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
previewContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||
mainHBox.AddChild(previewContainer);
|
||||
|
||||
preview = new TextureRect();
|
||||
preview.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
|
||||
preview.StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered;
|
||||
preview.MouseFilter = Control.MouseFilterEnum.Stop;
|
||||
preview.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
preview.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||
previewContainer.AddChild(preview);
|
||||
|
||||
previewMaterial = controller.CreatePreviewMaterial();
|
||||
preview.Material = previewMaterial;
|
||||
|
||||
jsonEdit = new TextEdit();
|
||||
jsonEdit.CustomMinimumSize = new Vector2(0, 70);
|
||||
jsonEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
jsonEdit.WrapMode = TextEdit.LineWrappingMode.Boundary;
|
||||
jsonEdit.TextChanged += OnJsonEditChanged;
|
||||
outerVBox.AddChild(jsonEdit);
|
||||
}
|
||||
|
||||
private void OnJsonEditChanged()
|
||||
var main = new HSplitContainer
|
||||
{
|
||||
}
|
||||
|
||||
private void UpdateJsonEdit()
|
||||
{
|
||||
var json = controller.ExportLightsJson();
|
||||
if (jsonEdit.Text != json)
|
||||
jsonEdit.Text = json;
|
||||
}
|
||||
|
||||
private void LoadBackgroundList()
|
||||
{
|
||||
bgIds.Clear();
|
||||
backgroundList.Clear();
|
||||
|
||||
var ids = ModResourceService.GetBackgroundIds();
|
||||
bgIds.AddRange(ids);
|
||||
foreach (var id in ids)
|
||||
backgroundList.AddItem(id);
|
||||
}
|
||||
|
||||
private void OnBackgroundSelected(long index)
|
||||
{
|
||||
var bgId = bgIds[(int)index];
|
||||
var tex = BackgroundFactory.Instance.Get(bgId);
|
||||
preview.Texture = tex;
|
||||
controller.SetBackgroundForPreview(tex, previewMaterial);
|
||||
|
||||
var depthTex = BackgroundFactory.Instance.Get(bgId + "_depth");
|
||||
if (depthTex != null)
|
||||
controller.SetDepthForPreview(depthTex, previewMaterial);
|
||||
|
||||
var albedoTex = BackgroundFactory.Instance.Get(bgId + "_albedo");
|
||||
if (albedoTex != null)
|
||||
controller.SetAlbedoForPreview(albedoTex, previewMaterial);
|
||||
}
|
||||
|
||||
private void OnAddLight()
|
||||
{
|
||||
if (handles.Count >= 10)
|
||||
return;
|
||||
|
||||
var light = new LightDef
|
||||
{
|
||||
X = 0.4f,
|
||||
Y = 0.3f,
|
||||
Radius = 300f,
|
||||
Influence = 1f,
|
||||
ColorHex = "#ffff00"
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill
|
||||
};
|
||||
outer.AddChild(main);
|
||||
|
||||
controller.AddLight(light);
|
||||
AddHandle(light, controller.Lights.Count - 1);
|
||||
RefreshAll();
|
||||
}
|
||||
var side = new VBoxContainer();
|
||||
main.AddChild(CreateSidebar(side, 300));
|
||||
BuildSidebar(side);
|
||||
|
||||
private void AddHandle(LightDef light, int index)
|
||||
var canvasColumn = new VSplitContainer
|
||||
{
|
||||
var handle = new LightEditorHandle(light, index);
|
||||
handle.DragUpdate += globalCenter =>
|
||||
{
|
||||
var localCenter = globalCenter - preview.GlobalPosition;
|
||||
var currentSize = PreviewSize();
|
||||
light.FromPixelPos(localCenter, currentSize);
|
||||
RefreshAll();
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill
|
||||
};
|
||||
handle.Clicked += () => SelectHandle(index);
|
||||
preview.AddChild(handle);
|
||||
handles.Add(handle);
|
||||
RepositionHandle(handle, light);
|
||||
}
|
||||
main.AddChild(canvasColumn);
|
||||
|
||||
private void RepositionHandle(LightEditorHandle handle, LightDef light)
|
||||
var canvasPanel = new PanelContainer
|
||||
{
|
||||
var size = PreviewSize();
|
||||
handle.GlobalPosition = preview.GlobalPosition
|
||||
+ new Vector2(light.X * size.X - 12, light.Y * size.Y - 12);
|
||||
}
|
||||
|
||||
private void RepositionHandles()
|
||||
{
|
||||
for (int i = 0; i < handles.Count && i < controller.Lights.Count; i++)
|
||||
RepositionHandle(handles[i], controller.Lights[i]);
|
||||
}
|
||||
|
||||
private void SelectHandle(int index)
|
||||
{
|
||||
selectedIndex = index;
|
||||
if (index < 0 || index >= controller.Lights.Count)
|
||||
{
|
||||
radiusEdit.Text = "300";
|
||||
influenceEdit.Text = "1";
|
||||
colorPicker.Color = Colors.Yellow;
|
||||
return;
|
||||
}
|
||||
|
||||
var light = controller.Lights[index];
|
||||
radiusEdit.Text = light.Radius.ToString("F0");
|
||||
influenceEdit.Text = light.Influence.ToString("F2");
|
||||
colorPicker.Color = light.ToColor();
|
||||
}
|
||||
|
||||
private void OnRadiusChanged(string text)
|
||||
{
|
||||
if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count)
|
||||
return;
|
||||
if (float.TryParse(text, out var val))
|
||||
{
|
||||
controller.Lights[selectedIndex].Radius = val;
|
||||
RefreshAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnInfluenceChanged(string text)
|
||||
{
|
||||
if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count)
|
||||
return;
|
||||
if (float.TryParse(text, out var val))
|
||||
{
|
||||
controller.Lights[selectedIndex].Influence = val;
|
||||
RefreshAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnColorChanged(Color color)
|
||||
{
|
||||
if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count)
|
||||
return;
|
||||
controller.Lights[selectedIndex].ColorHex = color.ToHtml();
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
private void UpdateLightList()
|
||||
{
|
||||
foreach (var child in lightListPanel.GetChildren())
|
||||
child.QueueFree();
|
||||
|
||||
for (int i = 0; i < controller.Lights.Count; i++)
|
||||
{
|
||||
var light = controller.Lights[i];
|
||||
var hBox = new HBoxContainer();
|
||||
var label = new Label
|
||||
{
|
||||
Text = string.Format(TranslationServer.Translate("tools/light_editor/light_n"), i + 1, light.X, light.Y),
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill
|
||||
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||
SizeFlagsVertical = Control.SizeFlags.ExpandFill
|
||||
};
|
||||
hBox.AddChild(label);
|
||||
canvasPanel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line));
|
||||
canvasColumn.AddChild(canvasPanel);
|
||||
|
||||
var selBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/btn_select") };
|
||||
int idx = i;
|
||||
selBtn.Pressed += () => SelectHandle(idx);
|
||||
hBox.AddChild(selBtn);
|
||||
|
||||
var delBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/btn_delete") };
|
||||
delBtn.Pressed += () => RemoveLight(idx);
|
||||
hBox.AddChild(delBtn);
|
||||
|
||||
lightListPanel.AddChild(hBox);
|
||||
}
|
||||
|
||||
addLightBtn.Disabled = controller.Lights.Count >= 10;
|
||||
}
|
||||
|
||||
private void RemoveLight(int index)
|
||||
_canvas = new LightEditorCanvas
|
||||
{
|
||||
if (index < 0 || index >= controller.Lights.Count)
|
||||
return;
|
||||
Controller = _controller,
|
||||
PreviewMaterial = LightEditorController.CreatePreviewMaterial()
|
||||
};
|
||||
_canvas.Changed += OnCanvasChanged;
|
||||
_canvas.SelectionChanged += RefreshAll;
|
||||
canvasPanel.AddChild(_canvas);
|
||||
|
||||
controller.RemoveLight(index);
|
||||
|
||||
if (index < handles.Count)
|
||||
{
|
||||
handles[index].QueueFree();
|
||||
handles.RemoveAt(index);
|
||||
_lineEdit = ToolUi.Code(editable: false);
|
||||
_lineEdit.CustomMinimumSize = new Vector2(0, 70);
|
||||
_lineEdit.WrapMode = TextEdit.LineWrappingMode.Boundary;
|
||||
_lineEdit.Visible = false;
|
||||
canvasColumn.AddChild(_lineEdit);
|
||||
}
|
||||
|
||||
for (int i = 0; i < handles.Count; i++)
|
||||
handles[i].LightIndex = i;
|
||||
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);
|
||||
|
||||
if (selectedIndex == index)
|
||||
selectedIndex = -1;
|
||||
else if (selectedIndex > index)
|
||||
selectedIndex--;
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
_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();
|
||||
|
||||
column.AddChild(ToolUi.Section("tools/light_editor/background"));
|
||||
_bgList = new FilteredList(180);
|
||||
_bgList.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||
_bgList.Chosen += ShowBackground;
|
||||
column.AddChild(_bgList);
|
||||
}
|
||||
|
||||
// ── loading ────────────────────────────────────────────────
|
||||
|
||||
private void LoadBackgrounds()
|
||||
{
|
||||
_backgroundIds.AddRange(ModResourceService.GetBackgroundIds());
|
||||
_bgList.SetItems(_backgroundIds);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
_scenarios.Clear();
|
||||
_scenarios.AddRange(withLights);
|
||||
_scenarios.AddRange(others);
|
||||
|
||||
_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);
|
||||
|
||||
if (withLights.Count > 0)
|
||||
{
|
||||
_scenarioSelect.Select(1);
|
||||
OpenScenario(withLights[0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
RefreshAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnImport()
|
||||
private void OnScenarioPicked(long index)
|
||||
{
|
||||
var json = jsonEdit.Text;
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return;
|
||||
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 OpenScenario(ScenarioFileInfo file)
|
||||
{
|
||||
_scenario = file;
|
||||
_scenarioText = null;
|
||||
MarkClean();
|
||||
|
||||
if (file != null)
|
||||
{
|
||||
_scenarioText = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath) ?? string.Empty;
|
||||
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();
|
||||
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)
|
||||
{
|
||||
GameLogger.Log("LIGHT_EDITOR", $"Import JSON failed: {ex.Message}", LogLevel.Error);
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
_bgList.Select(id);
|
||||
_canvas.SetBackground(ModResourceService.LoadBackgroundTexture(id));
|
||||
LightEditorController.SetPreviewTextures(_canvas.PreviewMaterial,
|
||||
ModResourceService.LoadBackgroundTexture(id + "_depth"),
|
||||
ModResourceService.LoadBackgroundTexture(id + "_albedo"));
|
||||
_canvas.Refresh();
|
||||
}
|
||||
|
||||
// ── saving ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Ctrl+S.</summary>
|
||||
protected override void OnSaveShortcut() => Save();
|
||||
|
||||
private void Save()
|
||||
{
|
||||
if (_scenario == null)
|
||||
{
|
||||
SetStatus(ToolUi.Tr("tools/light_editor/pick_scenario"), ToolStatusKind.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = _controller.WriteToScenario(_scenarioText);
|
||||
if (!ScenarioFileService.WriteAllText(_scenario.ModId, _scenario.RelativePath, updated))
|
||||
{
|
||||
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_scenarioText = updated;
|
||||
MarkClean();
|
||||
SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), _scenario.ModId + "/" + _scenario.RelativePath), ToolStatusKind.Ok);
|
||||
}
|
||||
|
||||
private void CopyLine()
|
||||
{
|
||||
DisplayServer.ClipboardSet(_controller.ToScenarioLine());
|
||||
SetStatus(ToolUi.Tr("tools/light_editor/copied"), ToolStatusKind.Ok);
|
||||
}
|
||||
|
||||
// ── editing ────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
SetDirty();
|
||||
RefreshAll();
|
||||
}
|
||||
|
||||
private void OnDelete()
|
||||
{
|
||||
if (_controller.Selected == null)
|
||||
return;
|
||||
|
||||
_controller.RemoveSelected();
|
||||
SetDirty();
|
||||
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 OnFieldsChanged()
|
||||
{
|
||||
var light = _controller.Selected;
|
||||
if (_suppress || light == null)
|
||||
return;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private void OnCanvasChanged()
|
||||
{
|
||||
SetDirty();
|
||||
_suppress = true;
|
||||
WriteProperties();
|
||||
_suppress = false;
|
||||
RefreshList();
|
||||
_lineEdit.Text = _controller.ToScenarioLine();
|
||||
}
|
||||
|
||||
// ── refresh ────────────────────────────────────────────────
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
if (_controller.SelectedIndex >= 0)
|
||||
_lightList.Select(_controller.SelectedIndex);
|
||||
}
|
||||
|
||||
private void WriteProperties()
|
||||
{
|
||||
var light = _controller.Selected;
|
||||
_propsEmpty.Visible = light == null;
|
||||
_propsBody.Visible = light != null;
|
||||
if (light == null)
|
||||
return;
|
||||
|
||||
_x.Value = light.X;
|
||||
_y.Value = light.Y;
|
||||
_radius.Value = light.Radius;
|
||||
_influence.Value = light.Influence;
|
||||
_color.Color = light.ToColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
-154
@@ -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,20 +139,40 @@ 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;
|
||||
{
|
||||
FinishDrawing();
|
||||
}
|
||||
else
|
||||
{
|
||||
Controller.AddVertex(Clamp01(LocalToNorm(mb.Position)));
|
||||
RebuildHandles();
|
||||
Changed?.Invoke();
|
||||
QueueRedraw();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var hit = HitTest(mb.Position);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+422
-438
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
TreeItem groupItem = null;
|
||||
foreach (var file in group.Files)
|
||||
{
|
||||
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))
|
||||
{
|
||||
parentItem = modItem;
|
||||
groupItem = modItem;
|
||||
}
|
||||
else
|
||||
{
|
||||
parentItem = _fileTree.CreateItem(modItem);
|
||||
parentItem.SetText(0, group.DirectoryPath + "/");
|
||||
parentItem.SetSelectable(0, false);
|
||||
groupItem = _fileTree.CreateItem(modItem);
|
||||
groupItem.SetText(0, group.DirectoryPath + "/");
|
||||
groupItem.SetSelectable(0, false);
|
||||
groupItem.SetCustomColor(0, UiTokens.Muted);
|
||||
groupItem.Collapsed = filter.Length == 0;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var file in group.Files)
|
||||
{
|
||||
var fileItem = _fileTree.CreateItem(parentItem);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetItemKey(TreeItem item)
|
||||
{
|
||||
return item.GetInstanceId().ToString();
|
||||
if (_selectedKey != null && _keyToItem.TryGetValue(_selectedKey, out var selected))
|
||||
SelectSilently(selected);
|
||||
}
|
||||
|
||||
/// <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))
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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 = "-";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,7 +429,8 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
|
||||
/// <summary>Hides the node highlight overlay and resets its colour.</summary>
|
||||
public void HideHighlight()
|
||||
{
|
||||
if (_highlight != null)
|
||||
if (_highlight == null)
|
||||
return;
|
||||
_highlight.Visible = false;
|
||||
_highlight.Color = UiTokens.Alpha(UiTokens.Accent, 0.35f);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
_sideTabs = new TabContainer();
|
||||
_sideTabs.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
_sideTabs.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||
rightVBox.AddChild(_sideTabs);
|
||||
|
||||
var inspectorTab = new VBoxContainer
|
||||
/// <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
|
||||
{
|
||||
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)
|
||||
{
|
||||
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()
|
||||
{
|
||||
RunDiscardingChanges(() =>
|
||||
{
|
||||
if (TryGetSelectedModScene(out var scene))
|
||||
_controller.InvalidateScene(scene.RelativePath);
|
||||
|
||||
RebuildPreview();
|
||||
ShowOutput(TranslationServer.Translate("tools/scene_builder/reloaded"), false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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/<mod>/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/<mod>/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('\\', '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('\\', '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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 <id></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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user