Compare commits

...
10 Commits
Author SHA1 Message Date
cthangover bd9147e1b6 Play-test from a running game returns into the game
Stop brings the player back to the scene they were in instead of the main
menu, and a scene switch inside the played scenario ends the play-test in
place. New GUI test suite ingame_play covers both.
2026-10-18 21:06:11 +02:00
cthangover bf4ac5e27a Rename cthangover_story to cthangover_game
The mod holding the main game's own scenes and story follows the <game>_game
naming rule; further parts stay <game>_<part>, reusable content is <theme>_pack.
2026-10-18 19:45:50 +02:00
cthangover 2bb0a07edb Collections are packages of their own
A collection is no longer a file inside a mod: it is a package (folder or zip)
named after its id with collection.json, cover and optional locale/, shipped in
collections/ next to the mods or kept by the player in user://collections/.
Covers and texts come from the package; mods do not ship collections.
2026-10-18 19:21:35 +02:00
cthangover 754b854817 Mod naming rule and engine UI in engine mods
Mods are renamed by role (shell2d_base, menu_base, ui_base, live2d_base,
editor_tools, cthangover_story, cthangover_murakami, cthangover_party) and
every reference follows. Engine UI prefabs (dialog, answers, message, game
menu, inventory, map, skills) and their panel entries moved out of the story
mod into the subsystem mods; the card_novel and vn_starter_novel collections
are removed.
2026-10-18 19:00:24 +02:00
cthangover 7b6ae80616 Scenario play-test returns to the screen the editor was opened from
The play-test loads the game shell, which frees the screen the editor was
opened from (the novel_editor hub). Both the scenario's end and Stop used to
switch to the engine's main menu; the editor now remembers the route it left
and switches back to it, falling back to the main menu only when it was not
opened from a route. The tools_hub run of the windows suite plays its own
scenario and checks both ways back.
2026-10-17 21:44:34 +02:00
cthangover 55ef9416ef Tool windows test suite; drop visual_novel_core dependency
- test_suites/windows (--itest=tools_windows [--shots=<dir>]) opens every
  tool window, fails on any engine error logged meanwhile, drives the
  map/interactive/light canvases and can save a screenshot per window.
- The tools need the engine mods only; visual_novel_core is no longer a
  dependency (the Novel Editor collection brings the content).
2026-10-17 14:29:15 +00:00
cthangover 8742aa3da5 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".
2026-10-17 14:29:14 +00:00
cthangover 578ba7c5d5 dev_tools collection: Russian texts, atlases in the validation block
- ru name/description for the collection and every block.
- The optional "Validation" block also installs resource_atlas and
  locale_atlas next to the validator.
2026-10-17 14:29:08 +00:00
iisaevandClaude Opus 5.5 e9b9205514 upd: sync local changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:18:22 +00:00
cthangover 03ee4dbc8f tscn patch system 2026-09-29 19:31:09 +03:00
72 changed files with 5568 additions and 3461 deletions

No files matched your search

-28
View File
@@ -1,28 +0,0 @@
{
"id": "dev_tools",
"name": "Developer Tools",
"author": "ct",
"version": "1.0.0",
"description": "Mod authoring toolset: the scenario graph editor, content validators and the mod manager.",
"mechanics": [
{
"id": "editor",
"name": "Scenario Editor",
"description": "Graph-based scenario and scene authoring tools.",
"mods": ["tools>=1"]
},
{
"id": "validation",
"name": "Validation",
"description": "Optional content validation suite.",
"mods": ["validator>=0.1"],
"optional": true
},
{
"id": "manager",
"name": "Mod Manager",
"description": "Catalog browser and installer.",
"mods": ["mod_manager>=1"]
}
]
}
+3
View File
@@ -1,2 +1,5 @@
# Catalog export tool — extra keys
mods/catalog_select_mod = Select a mod:
mods/catalog_add_locale = Add language
mods/catalog_locale_name = Name in this language
mods/catalog_locale_desc = Description in this language
+3
View File
@@ -1,2 +1,5 @@
# Catalog export tool — дополнительные ключи
mods/catalog_select_mod = Выберите мод:
mods/catalog_add_locale = Добавить язык
mods/catalog_locale_name = Название на этом языке
mods/catalog_locale_desc = Описание на этом языке
+226 -146
View File
@@ -1,39 +1,145 @@
mod_tools_name = Tools
mod_tools_desc = Development tools
mod_editor_tools_name = Editor Tools
mod_editor_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,129 +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/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
+226 -145
View File
@@ -1,40 +1,145 @@
# Русская локализация для инструментов разработки
mod_tools_name = Инструменты
mod_tools_desc = Инструменты разработчика
mod_editor_tools_name = Редакторы
mod_editor_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,127 +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/hub/title = Редактор новелл
tools/hub/hint = Выберите редактор — окна открываются поверх этого экрана. Сценарий можно проиграть прямо из редактора (F5).
tools/hub/editors = Редакторы
tools/hub/empty = Не установлено ни одного редактора.
tools/hub/main_menu = Главное меню
+8 -4
View File
@@ -1,12 +1,16 @@
{
"id": "tools",
"name": "Tools",
"id": "editor_tools",
"name": "Editor Tools",
"version": "1.0.0",
"author": "ct",
"description": "Development tools",
"description": "Developer toolbox of ToolBox windows: scenario graph editor, map editor with zones, scene builder with inspector, interactive and light editors, and the mod catalog export window.",
"category": "tools, editor",
"sources": [
"src/**/*.cs"
],
"depends": ["visual_novel_core>=1"]
"depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1"],
"test_suites": [
{"id": "windows", "activate": ["--itest=tools_windows"]},
{"id": "ingame_play", "activate": ["--itest=ingame_play"]}
]
}
+12
View File
@@ -0,0 +1,12 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/Hub/NovelEditorHub.cs" id="NovelEditorHubScript"]
[node name="NovelEditorHub" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("NovelEditorHubScript")
+4
View File
@@ -5,6 +5,10 @@
[node name="ScenarioFileTreePanel" type="VBoxContainer" script=ExtResource("1_script")]
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 4
[node name="Search" type="LineEdit" parent="."]
clear_button_enabled = true
[node name="FileTree" type="Tree" parent="."]
size_flags_horizontal = 3
+3 -2
View File
@@ -9,10 +9,11 @@ theme_override_constants/separation = 6
[node name="Scroll" type="ScrollContainer" parent="."]
size_flags_vertical = 3
horizontal_scroll_mode = 3
horizontal_scroll_mode = 0
[node name="VBox" type="VBoxContainer" parent="Scroll"]
theme_override_constants/separation = 8
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="HintScene" type="Label" parent="Scroll/VBox"]
@@ -1,6 +1,7 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.CatalogExport
{
/// <summary>
+137 -269
View File
@@ -4,8 +4,10 @@ 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;
namespace Cthangover.Core.UI.Tool.CatalogExport
@@ -16,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
{
@@ -29,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();
}
@@ -92,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);
}
@@ -112,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);
@@ -289,6 +219,7 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
descEdit.TextChanged += _ => UpdatePreview();
_localeContainer.AddChild(row);
UpdatePreview();
}
private void RemoveLocaleRow(HBoxContainer row)
@@ -300,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);
}
@@ -330,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;
}
@@ -370,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();
@@ -385,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()
@@ -393,15 +300,27 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
if (_modInfo == null)
return;
var entry = BuildExportEntry();
var modsRoot = ModRegistry.ModsPath;
var isZip = _modInfo.FileProvider.RootPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase);
if (isZip)
ModCatalogExporter.ExportFromZip(_modInfo, entry, modsRoot);
else
ModCatalogExporter.ExportFromFolder(_modInfo, entry, modsRoot);
if (string.IsNullOrWhiteSpace(_urlEdit.Text))
{
SetStatus(ToolUi.Tr("tools/catalog_export/url_required"), ToolStatusKind.Error);
return;
}
QueueFree();
try
{
var entry = BuildExportEntry();
var modsRoot = ModRegistry.ModsPath;
var isZip = _modInfo.FileProvider.RootPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase);
var path = isZip
? ModCatalogExporter.ExportFromZip(_modInfo, entry, modsRoot)
: ModCatalogExporter.ExportFromFolder(_modInfo, entry, modsRoot);
SetStatus(string.Format(ToolUi.Tr("tools/catalog_export/exported"), path), ToolStatusKind.Ok);
}
catch (Exception ex)
{
GameLogger.Log("CATALOG_EXPORT", $"export of '{_modInfo.Id}' failed: {ex.Message}", LogLevel.Error);
SetStatus($"{ToolUi.Tr("tools/catalog_export/export_error")}: {ex.Message}", ToolStatusKind.Error);
}
}
private static List<string> ParseTags(string text)
@@ -434,66 +353,15 @@ namespace Cthangover.Core.UI.Tool.CatalogExport
};
}
private static CategoryType CollectSelectedCategories(ItemList list, CategoryType[] categories)
private CategoryType CollectSelectedCategories()
{
var selected = (CategoryType)0;
for (var i = 0; i < categories.Length && i < list.ItemCount; i++)
for (var i = 0; i < Categories.Length && i < _categoryChecks.Count; i++)
{
if (list.IsSelected(i))
selected |= categories[i];
if (_categoryChecks[i].ButtonPressed)
selected |= Categories[i];
}
return selected != 0 ? selected : CategoryType.Other;
}
private static void AddLabeledControl(Container parent, string labelKey, Control control)
{
var box = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
var label = new Label
{
Text = TranslationServer.Translate(labelKey),
};
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
label.AddThemeColorOverride("font_color", UiTokens.Muted);
box.AddChild(label);
box.AddChild(control);
parent.AddChild(box);
}
private static void AddReadOnlyField(Container parent, string labelKey, string value)
{
var label = new Label
{
Text = $"{TranslationServer.Translate(labelKey)}: {value}",
};
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBody);
label.AddThemeColorOverride("font_color", UiTokens.Body);
parent.AddChild(label);
}
private static LineEdit AddLineEdit(Container parent, string labelKey)
{
var edit = new LineEdit { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
AddLabeledControl(parent, labelKey, edit);
return edit;
}
private static OptionButton AddOptionButton(Container parent, string labelKey, string[] items)
{
var opt = new OptionButton { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
foreach (var item in items)
opt.AddItem(item);
AddLabeledControl(parent, labelKey, opt);
return opt;
}
private static void AddSectionLabel(Container parent, string text)
{
var sep = new HSeparator();
parent.AddChild(sep);
var label = new Label { Text = text };
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodyLarge);
label.AddThemeColorOverride("font_color", UiTokens.Ink);
parent.AddChild(label);
}
}
}
+137
View File
@@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Base of the editors that place things over a scene background (map
/// zones, interactive colliders, lights). Draws the background with the
/// same aspect-preserving "fit" layout the game uses and converts between
/// canvas pixels and the normalised [0..1] coordinates stored in the data.
/// Getting this rectangle wrong is what made saved positions drift: the
/// data means "fraction of the background", not "fraction of the panel".
/// <para/>
/// Subclasses paint their overlay in <see cref="DrawContent"/> and rebuild
/// their <see cref="DragHandle"/>s in <see cref="RebuildHandles"/>, which is
/// also called whenever the canvas is resized or the background changes.
/// </summary>
public abstract partial class BackgroundCanvas : Control
{
private static readonly Vector2 FallbackSize = new(1920, 1024);
/// <summary>Live handles of the current selection.</summary>
protected readonly List<DragHandle> _handles = new();
/// <summary>Reference background; <c>null</c> draws a flat placeholder.</summary>
public Texture2D Background { get; private set; }
protected BackgroundCanvas()
{
MouseFilter = MouseFilterEnum.Stop;
ClipContents = true;
Resized += OnResized;
Connect("draw", Callable.From((Action)DrawAll));
}
/// <summary>Assigns the background texture and relayouts handles.</summary>
public void SetBackground(Texture2D texture)
{
Background = texture;
RebuildHandles();
QueueRedraw();
}
/// <summary>Recreates the handles from the current selection.</summary>
public abstract void RebuildHandles();
/// <summary>Paints the overlay above the background inside <paramref name="rect"/>.</summary>
protected abstract void DrawContent(Rect2 rect);
/// <summary>
/// Aspect-preserving rectangle the background occupies inside the
/// canvas — identical to the runtime "fit" layout.
/// </summary>
public Rect2 GetBackgroundRect()
{
var controlSize = Size;
if (controlSize.X <= 0f || controlSize.Y <= 0f)
return new Rect2();
var textureSize = Background?.GetSize() ?? FallbackSize;
if (textureSize.X <= 0f || textureSize.Y <= 0f)
textureSize = FallbackSize;
var scale = Mathf.Min(controlSize.X / textureSize.X, controlSize.Y / textureSize.Y);
var size = textureSize * scale;
return new Rect2((controlSize - size) / 2f, size);
}
/// <summary>Canvas-local pixel of a normalised point.</summary>
public Vector2 NormToLocal(Vector2 normalized)
{
var rect = GetBackgroundRect();
return rect.Position + normalized * rect.Size;
}
/// <summary>Normalised point of a canvas-local pixel (not clamped).</summary>
public Vector2 LocalToNorm(Vector2 local)
{
var rect = GetBackgroundRect();
if (rect.Size.X <= 0f || rect.Size.Y <= 0f)
return Vector2.Zero;
return (local - rect.Position) / rect.Size;
}
/// <summary>Clamps both axes into [0..1].</summary>
public static Vector2 Clamp01(Vector2 value)
{
return new Vector2(Mathf.Clamp(value.X, 0f, 1f), Mathf.Clamp(value.Y, 0f, 1f));
}
/// <summary>Frees every handle.</summary>
protected void ClearHandles()
{
foreach (var handle in _handles)
handle.QueueFree();
_handles.Clear();
}
/// <summary>
/// Adds a handle centred on a normalised point. The handle enters the
/// tree before it is positioned (setting a global position on a
/// detached Control lands it in the wrong place). <paramref name="drag"/>
/// receives the clamped normalised point while dragging.
/// </summary>
protected DragHandle AddHandle(Vector2 normalized, Color color, int index, Action<Vector2> drag, float diameter = 14f)
{
var handle = new DragHandle(color, index, diameter);
handle.DragUpdate = globalCenter => drag(Clamp01(LocalToNorm(globalCenter - GlobalPosition)));
AddChild(handle);
handle.CenterAt(GlobalPosition + NormToLocal(Clamp01(normalized)));
_handles.Add(handle);
return handle;
}
private void OnResized()
{
RebuildHandles();
QueueRedraw();
}
private void DrawAll()
{
var rect = GetBackgroundRect();
if (rect.Size.X <= 0f || rect.Size.Y <= 0f)
return;
if (Background != null)
DrawTextureRect(Background, rect, false);
else
DrawRect(rect, UiTokens.Surface2);
DrawContent(rect);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Sidebar section with a clickable uppercase caption (style guide
/// <c>.ct-section-title</c> plus a ▸/▾ marker) that shows or hides its
/// <see cref="Body"/>. Editors expand the sections relevant to the selected
/// object and collapse the rest, so a long property panel stays readable
/// without losing access to rarely used fields.
/// </summary>
public partial class CollapsibleSection : VBoxContainer
{
private readonly Button _header;
private readonly string _title;
/// <summary>Container for the section's controls.</summary>
public VBoxContainer Body { get; }
/// <summary>Whether the body is shown.</summary>
public bool Expanded
{
get => Body.Visible;
set
{
Body.Visible = value;
_header.Text = (value ? "▾ " : "▸ ") + _title;
}
}
/// <summary>Creates a section titled by <paramref name="titleKey"/>.</summary>
public CollapsibleSection(string titleKey, bool expanded = true)
{
_title = ToolUi.Tr(titleKey).ToUpperInvariant();
AddThemeConstantOverride("separation", UiTokens.Space2);
_header = new Button
{
Flat = true,
Alignment = HorizontalAlignment.Left,
FocusMode = FocusModeEnum.None,
MouseDefaultCursorShape = CursorShape.PointingHand
};
_header.AddThemeFontSizeOverride("font_size", UiTokens.FontSmall);
_header.AddThemeColorOverride("font_color", UiTokens.Muted);
_header.AddThemeColorOverride("font_hover_color", UiTokens.AccentBright);
_header.AddThemeColorOverride("font_pressed_color", UiTokens.Muted);
_header.AddThemeStyleboxOverride("normal", new StyleBoxEmpty());
_header.AddThemeStyleboxOverride("hover", new StyleBoxEmpty());
_header.AddThemeStyleboxOverride("pressed", new StyleBoxEmpty());
_header.Pressed += () => Expanded = !Expanded;
AddChild(_header);
Body = new VBoxContainer();
Body.AddThemeConstantOverride("separation", UiTokens.Space2);
AddChild(Body);
Expanded = expanded;
}
}
}
+102
View File
@@ -0,0 +1,102 @@
using System;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Round draggable point used by every canvas editor (polygon vertices,
/// rect corners, markers, lights). It is a small Control placed over its
/// canvas: the left button drags it and reports the new centre in global
/// coordinates, the right button reports a delete request. Input arrives
/// through the <c>GuiInput</c> event and drawing through the <c>draw</c>
/// signal — the forms that dispatch reliably for mod assemblies.
/// <para/>
/// <see cref="Index"/> is free for the owner: a vertex index or a negative
/// role constant (marker, label, rect corner...).
/// </summary>
public partial class DragHandle : Control
{
/// <summary>Owner-defined identity of the handle.</summary>
public int Index;
/// <summary>Called while dragging with the handle centre in global coordinates.</summary>
public Action<Vector2> DragUpdate;
/// <summary>Called on left press.</summary>
public Action Clicked;
/// <summary>Called on left release after a press.</summary>
public Action DragEnd;
/// <summary>Called on right press.</summary>
public Action RightClicked;
private readonly Color _fill;
private bool _dragging;
private bool _hovered;
private Vector2 _dragOffset;
/// <summary>Creates a handle of the given fill colour, role index and diameter.</summary>
public DragHandle(Color fill, int index, float diameter = 14f)
{
_fill = fill;
Index = index;
Size = new Vector2(diameter, diameter);
MouseFilter = MouseFilterEnum.Stop;
MouseDefaultCursorShape = CursorShape.Drag;
GuiInput += OnGuiInput;
MouseEntered += () => { _hovered = true; QueueRedraw(); };
MouseExited += () => { _hovered = false; QueueRedraw(); };
Connect("draw", Callable.From((Action)Paint));
}
/// <summary>Places the handle so that its centre sits at <paramref name="globalCenter"/>; call after it is in the tree.</summary>
public void CenterAt(Vector2 globalCenter)
{
GlobalPosition = globalCenter - Size / 2f;
}
private void Paint()
{
var center = Size / 2f;
var radius = Size.X / 2f - 1f;
DrawCircle(center, radius, UiTokens.Surface0);
DrawCircle(center, radius - 2f, _fill);
DrawArc(center, radius, 0f, Mathf.Tau, 24, _hovered || _dragging ? UiTokens.AccentHover : UiTokens.Ink, 1.5f, true);
}
private void OnGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb)
{
if (mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
{
_dragging = true;
_dragOffset = GetGlobalMousePosition() - GlobalPosition;
Clicked?.Invoke();
}
else if (_dragging)
{
_dragging = false;
DragEnd?.Invoke();
}
AcceptEvent();
}
else if (mb.ButtonIndex == MouseButton.Right && mb.Pressed)
{
RightClicked?.Invoke();
AcceptEvent();
}
}
else if (@event is InputEventMouseMotion && _dragging)
{
GlobalPosition = GetGlobalMousePosition() - _dragOffset;
DragUpdate?.Invoke(GlobalPosition + Size / 2f);
AcceptEvent();
}
}
}
}
+136
View File
@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Search field + list used wherever an editor picks from a long catalogue
/// (backgrounds, icons, files, definitions). Items are identified by a
/// stable id, not by row index: filtering reorders and hides rows, so the
/// list keeps the id↔row mapping itself and raises <see cref="Chosen"/> /
/// <see cref="Activated"/> with the id. <see cref="Select"/> is silent so
/// windows can mirror their model into the list without feedback loops.
/// </summary>
public partial class FilteredList : VBoxContainer
{
private readonly LineEdit _search;
private readonly ItemList _list;
private readonly List<(string Id, string Text, Texture2D Icon)> _items = new();
private readonly List<string> _visibleIds = new();
private string _selectedId;
private bool _silent;
/// <summary>Raised when the user selects a row (click or keyboard).</summary>
public event Action<string> Chosen;
/// <summary>Raised on double-click / Enter.</summary>
public event Action<string> Activated;
/// <summary>Currently selected id, or <c>null</c>.</summary>
public string SelectedId => _selectedId;
/// <summary>Creates the list; <paramref name="minHeight"/> is the list part's minimum height.</summary>
public FilteredList(float minHeight = 140f, string placeholderKey = "tools/common/search")
{
AddThemeConstantOverride("separation", UiTokens.Space1);
_search = new LineEdit
{
PlaceholderText = TranslationServer.Translate(placeholderKey),
ClearButtonEnabled = true
};
_search.TextChanged += _ => Rebuild();
AddChild(_search);
_list = new ItemList
{
CustomMinimumSize = new Vector2(0, minHeight),
SizeFlagsVertical = SizeFlags.ExpandFill,
AllowReselect = true
};
_list.ItemSelected += OnItemSelected;
_list.ItemActivated += index => Activated?.Invoke(IdAt((int)index));
AddChild(_list);
}
/// <summary>Turns rows into an icon grid of <paramref name="iconSize"/> px thumbnails.</summary>
public void UseIconGrid(int iconSize)
{
_list.FixedIconSize = new Vector2I(iconSize, iconSize);
_list.IconMode = ItemList.IconModeEnum.Top;
_list.MaxColumns = 0;
_list.SameColumnWidth = true;
_list.FixedColumnWidth = iconSize + 24;
}
/// <summary>Replaces all items; keeps the current selection when its id is still present.</summary>
public void SetItems(IEnumerable<(string Id, string Text, Texture2D Icon)> items)
{
_items.Clear();
_items.AddRange(items);
Rebuild();
}
/// <summary>Replaces all items with text-only rows where text equals id.</summary>
public void SetItems(IEnumerable<string> ids)
{
_items.Clear();
foreach (var id in ids)
_items.Add((id, id, null));
Rebuild();
}
/// <summary>Selects the row with <paramref name="id"/> (null clears) without raising events.</summary>
public void Select(string id)
{
_selectedId = id;
_silent = true;
_list.DeselectAll();
var index = _visibleIds.IndexOf(id);
if (index >= 0)
{
_list.Select(index);
_list.EnsureCurrentIsVisible();
}
_silent = false;
}
/// <summary>Number of items before filtering.</summary>
public int Count => _items.Count;
private void Rebuild()
{
var filter = _search.Text.Trim();
_silent = true;
_list.Clear();
_visibleIds.Clear();
foreach (var item in _items)
{
if (filter.Length > 0 && item.Text.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0
&& item.Id.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
continue;
var index = item.Icon != null ? _list.AddItem(item.Text, item.Icon) : _list.AddItem(item.Text);
_list.SetItemTooltip(index, item.Id);
_visibleIds.Add(item.Id);
}
_silent = false;
Select(_selectedId);
}
private void OnItemSelected(long index)
{
if (_silent)
return;
_selectedId = IdAt((int)index);
Chosen?.Invoke(_selectedId);
}
private string IdAt(int index) => index >= 0 && index < _visibleIds.Count ? _visibleIds[index] : null;
}
}
+180
View File
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Toolbar group "mod ▾ file ▾ +" for editors that own one kind of file
/// inside a mod folder (<c>map/*.json</c>, <c>interactives/*.json</c>).
/// Only writable (folder-backed) mods are offered, and mods that already
/// contain such files come first, so the author lands on real data instead
/// of an arbitrary engine mod with nothing to edit.
/// <para/>
/// The selector never loads anything itself: it raises
/// <see cref="FileRequested"/> and the window decides (usually after an
/// "unsaved changes" confirmation). When the window declines, it calls
/// <see cref="Show"/> with the file still open to roll the dropdowns back.
/// A <c>null</c> path means "this mod has no such file yet".
/// </summary>
public partial class ModFileSelector : HBoxContainer
{
private readonly string _directory;
private readonly string _extension;
private readonly OptionButton _modSelect;
private readonly OptionButton _fileSelect;
private readonly List<string> _mods = new();
private readonly List<string> _files = new();
private bool _silent;
/// <summary>Raised with (modId, mod-relative path or null) when the author picks or creates a file.</summary>
public event Action<string, string> FileRequested;
/// <summary>Mod shown in the selector.</summary>
public string CurrentMod => _modSelect.Selected >= 0 && _modSelect.Selected < _mods.Count ? _mods[_modSelect.Selected] : null;
/// <summary>File shown in the selector, or <c>null</c>.</summary>
public string CurrentPath => _fileSelect.Selected >= 0 && _fileSelect.Selected < _files.Count ? _files[_fileSelect.Selected] : null;
/// <summary>Creates the selector for files in <paramref name="directory"/> with <paramref name="extension"/>.</summary>
public ModFileSelector(string directory, string extension)
{
_directory = directory;
_extension = extension;
AddThemeConstantOverride("separation", UiTokens.Space2);
_modSelect = new OptionButton { CustomMinimumSize = new Vector2(170, 0), TooltipText = ToolUi.Tr("tools/common/mod_tip") };
_modSelect.ItemSelected += _ => OnModPicked();
AddChild(_modSelect);
_fileSelect = new OptionButton { CustomMinimumSize = new Vector2(220, 0), TooltipText = ToolUi.Tr("tools/common/file_tip") };
_fileSelect.ItemSelected += _ => OnFilePicked();
AddChild(_fileSelect);
var newBtn = UiKit.IconButton("+", ToolUi.Tr("tools/common/new_file"));
newBtn.Pressed += OnNewPressed;
AddChild(newBtn);
}
/// <summary>Re-reads mods and files and requests the first file of the first mod that has any.</summary>
public void Refresh()
{
var writable = ModFileService.ListWritableMods();
_mods.Clear();
_mods.AddRange(writable.Where(HasFiles));
_mods.AddRange(writable.Where(m => !HasFiles(m)));
_silent = true;
_modSelect.Clear();
foreach (var mod in _mods)
_modSelect.AddItem(HasFiles(mod) ? mod : mod + " ·");
_silent = false;
if (_mods.Count == 0)
{
FileRequested?.Invoke(null, null);
return;
}
_modSelect.Select(0);
FillFiles(_mods[0]);
FileRequested?.Invoke(_mods[0], CurrentPath);
}
/// <summary>Silently shows <paramref name="modId"/>/<paramref name="path"/> (a not-yet-saved path is added).</summary>
public void Show(string modId, string path)
{
_silent = true;
var modIndex = _mods.IndexOf(modId);
if (modIndex >= 0)
{
_modSelect.Select(modIndex);
FillFiles(modId);
if (path != null && !_files.Contains(path))
{
_files.Add(path);
_fileSelect.AddItem(ShortName(path));
}
_fileSelect.Select(path != null ? _files.IndexOf(path) : -1);
}
_silent = false;
}
private bool HasFiles(string modId) => ModFileService.ListFiles(modId, _directory, _extension).Count > 0;
private void FillFiles(string modId)
{
_files.Clear();
_files.AddRange(ModFileService.ListFiles(modId, _directory, _extension));
var silent = _silent;
_silent = true;
_fileSelect.Clear();
foreach (var file in _files)
_fileSelect.AddItem(ShortName(file));
_fileSelect.Disabled = _files.Count == 0;
if (_files.Count > 0)
_fileSelect.Select(0);
_silent = silent;
}
private string ShortName(string path)
{
var prefix = _directory + "/";
return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? path.Substring(prefix.Length) : path;
}
private void OnModPicked()
{
if (_silent)
return;
var mod = CurrentMod;
FillFiles(mod);
FileRequested?.Invoke(mod, CurrentPath);
}
private void OnFilePicked()
{
if (!_silent)
FileRequested?.Invoke(CurrentMod, CurrentPath);
}
private void OnNewPressed()
{
if (CurrentMod == null)
return;
var dialog = new ConfirmationDialog { Title = ToolUi.Tr("tools/common/new_file") };
var column = new VBoxContainer();
column.AddChild(UiKit.Hint(string.Format(ToolUi.Tr("tools/common/new_file_hint"), CurrentMod, _directory, _extension)));
var nameEdit = new LineEdit { PlaceholderText = "new_file", CustomMinimumSize = new Vector2(320, 0) };
column.AddChild(nameEdit);
dialog.AddChild(column);
dialog.RegisterTextEnter(nameEdit);
dialog.Confirmed += () =>
{
var name = SanitizeName(nameEdit.Text);
dialog.QueueFree();
if (name.Length == 0)
return;
FileRequested?.Invoke(CurrentMod, _directory + "/" + name + _extension);
};
dialog.Canceled += () => dialog.QueueFree();
AddChild(dialog);
dialog.PopupCentered();
nameEdit.GrabFocus();
}
private string SanitizeName(string raw)
{
var name = (raw ?? string.Empty).Trim().Replace('\\', '/').Trim('/');
if (name.EndsWith(_extension, StringComparison.OrdinalIgnoreCase))
name = name.Substring(0, name.Length - _extension.Length);
var chars = name.Where(c => char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '/').ToArray();
return new string(chars);
}
}
}
+145
View File
@@ -0,0 +1,145 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Godot;
namespace Cthangover.Tools.Common
{
/// <summary>
/// Building blocks shared by every editor window of the tools mod, so all
/// of them read the same way: uppercase muted section captions, "label +
/// control" rows with a fixed label column, numeric spin boxes and token
/// buttons from <see cref="UiKit"/>. Editors are built in code (the
/// skill-code exception for utility tools), so this is the place where
/// their look is kept consistent with the style guide instead of each
/// window inventing its own spacing and label styles.
/// </summary>
public static class ToolUi
{
/// <summary>Width of the label column in <see cref="Field"/> rows.</summary>
public const int LabelWidth = 110;
/// <summary>Translates a locale key.</summary>
public static string Tr(string key) => TranslationServer.Translate(key);
/// <summary>Uppercase muted section caption (style guide <c>.ct-section-title</c>) with a little air above.</summary>
public static Control Section(string titleKey)
{
var box = new MarginContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
box.AddThemeConstantOverride("margin_top", UiTokens.Space2);
box.AddChild(UiKit.SectionTitle(Tr(titleKey), UiTokens.FontSmall));
return box;
}
/// <summary>
/// Adds a "label | control" row to <paramref name="parent"/>: the label is
/// muted, fixed width and carries the optional tooltip; the control fills
/// the rest. Returns the control for chaining.
/// </summary>
public static T Field<T>(Container parent, string labelKey, T control, string tooltipKey = null) where T : Control
{
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space3);
var label = new Label
{
Text = Tr(labelKey),
CustomMinimumSize = new Vector2(LabelWidth, 0),
VerticalAlignment = VerticalAlignment.Center,
ClipText = true,
MouseFilter = Control.MouseFilterEnum.Pass,
TooltipText = tooltipKey != null ? Tr(tooltipKey) : string.Empty
};
label.AddThemeColorOverride("font_color", UiTokens.Muted);
label.AddThemeFontSizeOverride("font_size", UiTokens.FontBodySmall);
row.AddChild(label);
control.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(control);
parent.AddChild(row);
return control;
}
/// <summary>
/// Numeric field. Values are typed and shown culture-independently by
/// <see cref="SpinBox"/>, which also clamps and steps them, so the editors
/// no longer parse free text ("0,5" vs "0.5").
/// </summary>
public static SpinBox Number(double min, double max, double step, Action<double> changed)
{
var spin = new SpinBox
{
MinValue = min,
MaxValue = max,
Step = step,
SelectAllOnFocus = true,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
CustomArrowStep = step
};
spin.GetLineEdit().AddThemeFontOverride("font", Mono());
if (changed != null)
spin.ValueChanged += value => changed(value);
return spin;
}
/// <summary>Single-line text field with a placeholder and an optional change handler.</summary>
public static LineEdit Text(string placeholder = "", Action<string> changed = null)
{
var edit = new LineEdit { PlaceholderText = placeholder ?? string.Empty };
if (changed != null)
edit.TextChanged += text => changed(text);
return edit;
}
/// <summary>Token button with a localized caption, optional tooltip and kind.</summary>
public static Button Button(string textKey, Action pressed, ButtonKind kind = ButtonKind.Secondary, string tooltipKey = null)
{
var button = UiKit.Button(Tr(textKey), kind);
if (tooltipKey != null)
button.TooltipText = Tr(tooltipKey);
if (pressed != null)
button.Pressed += pressed;
return button;
}
/// <summary>Thin vertical divider between toolbar groups.</summary>
public static VSeparator Divider() => new() { CustomMinimumSize = new Vector2(UiTokens.Space3, 0) };
/// <summary>Faint helper text that wraps inside narrow sidebars.</summary>
public static Label Hint(string text)
{
var label = UiKit.Hint(text);
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
label.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
label.CustomMinimumSize = new Vector2(80, 0);
return label;
}
/// <summary>Monospace font for IDs, numbers and code (style guide <c>--ct-mono</c>).</summary>
public static Font Mono() => ToolWindow.GetMonospaceFont();
/// <summary>Code editor preset: monospace, line numbers, current-line highlight, no wrap.</summary>
public static CodeEdit Code(bool editable = true)
{
var edit = new CodeEdit
{
Editable = editable,
GuttersDrawLineNumbers = true,
HighlightCurrentLine = true,
WrapMode = TextEdit.LineWrappingMode.None,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
IndentUseSpaces = true,
IndentSize = 2,
AutoBraceCompletionEnabled = false
};
edit.AddThemeFontOverride("font", Mono());
edit.AddThemeFontSizeOverride("font_size", UiTokens.FontBody);
edit.AddThemeColorOverride("current_line_color", UiTokens.WhiteTrace);
edit.AddThemeColorOverride("line_number_color", UiTokens.Faint);
edit.AddThemeColorOverride("font_readonly_color", UiTokens.Body);
return edit;
}
}
}
+79
View File
@@ -0,0 +1,79 @@
namespace Cthangover.Tools.Hub
{
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.UI.Tool;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Godot;
/// <summary>
/// Launcher screen of the <c>novel_editor</c> collection, entered as its
/// logical entry scene. Lists every registered tool the same way the main
/// menu does — each <see cref="IToolProvider"/> appends its own button via
/// <see cref="IToolProvider.AddMenuButton"/> — so a new editor shows up here
/// without touching this screen. Tool windows open on the tree root and
/// survive scene switches (playing a scenario, returning to the menu).
/// </summary>
public partial class NovelEditorHub : Control
{
/// <summary>Logical scene name; the collection's <c>entry.scene</c>.</summary>
public const string SceneName = "novel_editor";
private const int PanelWidth = 420;
public override void _Ready()
{
CthangoverTheme.Apply(this);
AddChild(new ColorRect
{
Color = UiTokens.Page,
MouseFilter = MouseFilterEnum.Ignore,
AnchorRight = 1f,
AnchorBottom = 1f,
});
var center = new CenterContainer { AnchorRight = 1f, AnchorBottom = 1f };
AddChild(center);
var panel = new PanelContainer { CustomMinimumSize = new Vector2(PanelWidth, 0) };
var frame = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Surface1, UiTokens.Line);
frame.SetContentMarginAll(UiTokens.Space8);
panel.AddThemeStyleboxOverride("panel", frame);
center.AddChild(panel);
var column = UiKit.Column(UiTokens.Space5);
panel.AddChild(column);
column.AddChild(UiKit.Label(Tr("tools/hub/title").ToUpperInvariant(), UiTokens.FontTitle, UiTokens.InkBright));
var hint = UiKit.Hint(Tr("tools/hub/hint"));
hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
column.AddChild(hint);
column.AddChild(new HSeparator());
column.AddChild(UiKit.SectionTitle(Tr("tools/hub/editors"), UiTokens.FontSmall));
var tools = UiKit.Column(UiTokens.Space2);
tools.MouseFilter = MouseFilterEnum.Pass;
column.AddChild(tools);
foreach (var tool in ToolFactory.Instance.GetAll())
tool.AddMenuButton(tools);
if (tools.GetChildCount() == 0)
column.AddChild(UiKit.EmptyState(Tr("tools/hub/empty")));
column.AddChild(new HSeparator());
var menu = UiKit.Button(Tr("tools/hub/main_menu"));
menu.Pressed += OnMainMenuPressed;
column.AddChild(menu);
GameLogger.Log("TOOLS", $"Novel editor hub: {tools.GetChildCount()} tool button(s)");
}
private void OnMainMenuPressed()
{
GetNodeOrNull<GodotSceneService>("/root/GodotSceneService")?.SwitchToMenu();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace Cthangover.Tools.Hub
{
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes;
/// <summary>
/// Lifecycle entry point of the tools mod. Publishes the logical scene
/// <see cref="NovelEditorHub.SceneName"/>, the entry scene of the
/// <c>novel_editor</c> collection: launching that collection from the game
/// picker goes through the regular new-game flow and lands on the editor
/// launcher instead of a story scene.
/// </summary>
public class ToolsInitializer : IModInitializer
{
private const string HubScenePath = "scenes/novel_editor_hub.tscn";
public void OnModLoaded(string modId)
{
}
public void OnModResourcesReady()
{
SceneRouteRegistry.Register(NovelEditorHub.SceneName, HubScenePath, fade: false);
}
}
}
@@ -0,0 +1,260 @@
using System;
using System.Linq;
using Cthangover.Core.Interactive;
using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Tools.InteractiveEditor
{
/// <summary>
/// Preview of the interactives of one file over a scene background. Every
/// collider is drawn (others faint, the selected one filled), so the author
/// sees how objects of the same scene sit next to each other; clicking a
/// collider selects it. The selected collider gets handles:
/// <list type="bullet">
/// <item>rect — top-left and bottom-right corners plus a centre handle
/// that moves the whole rect;</item>
/// <item>circle — centre and a radius handle;</item>
/// <item>polygon — one handle per vertex (right-click deletes it); a click
/// on empty background appends a vertex.</item>
/// </list>
/// Geometry is in the same normalised background space as
/// <c>InteractiveObject</c>: x/y/width/height are fractions of the
/// background size, a circle radius is a fraction of its shorter side.
/// </summary>
public partial class InteractiveEditorCanvas : BackgroundCanvas
{
private const int TopLeft = 0;
private const int BottomRight = 1;
private const int Move = 2;
private const int RadiusHandle = 1;
private const float MinExtent = 0.005f;
/// <summary>Data being edited; set once by the window.</summary>
public InteractiveEditorController Controller;
/// <summary>Raised whenever geometry of the selected item changes.</summary>
public event Action Changed;
/// <summary>Raised when a click on the canvas selects another item (-1: none).</summary>
public event Action<int> ItemPicked;
public InteractiveEditorCanvas()
{
GuiInput += OnGuiInput;
}
/// <summary>Recreates the handles of the selected item's collider.</summary>
public override void RebuildHandles()
{
ClearHandles();
var hit = Controller?.Selected?.HitArea;
if (hit == null || GetBackgroundRect().Size.X <= 0f)
return;
switch (hit.Type)
{
case "circle":
AddHandle(new Vector2(hit.X, hit.Y), UiTokens.Info, Move, p => { hit.X = p.X; hit.Y = p.Y; Moved(); });
AddHandle(RadiusPoint(hit), UiTokens.Info, RadiusHandle, p =>
{
var shortSide = ShortSide();
var centre = NormToLocal(new Vector2(hit.X, hit.Y));
hit.Radius = Mathf.Max(MinExtent, (NormToLocal(p) - centre).Length() / shortSide);
Moved();
});
break;
case "polygon":
var vertices = hit.Vertices ?? Array.Empty<Vector2>();
for (var i = 0; i < vertices.Length; i++)
{
var index = i;
var handle = AddHandle(vertices[i], UiTokens.Accent, i, p => { hit.Vertices[index] = p; Moved(); });
handle.RightClicked = () => RemoveVertex(index);
}
break;
default:
AddHandle(new Vector2(hit.X, hit.Y), UiTokens.Success, TopLeft, p =>
{
var right = hit.X + hit.Width;
var bottom = hit.Y + hit.Height;
hit.X = Mathf.Min(p.X, right - MinExtent);
hit.Y = Mathf.Min(p.Y, bottom - MinExtent);
hit.Width = right - hit.X;
hit.Height = bottom - hit.Y;
Moved();
});
AddHandle(new Vector2(hit.X + hit.Width, hit.Y + hit.Height), UiTokens.Success, BottomRight, p =>
{
hit.Width = Mathf.Max(MinExtent, p.X - hit.X);
hit.Height = Mathf.Max(MinExtent, p.Y - hit.Y);
Moved();
});
AddHandle(new Vector2(hit.X + hit.Width / 2f, hit.Y + hit.Height / 2f), UiTokens.AccentBright, Move, p =>
{
hit.X = Mathf.Clamp(p.X - hit.Width / 2f, 0f, 1f - hit.Width);
hit.Y = Mathf.Clamp(p.Y - hit.Height / 2f, 0f, 1f - hit.Height);
Moved();
}, 12f);
break;
}
foreach (var handle in _handles)
handle.DragEnd = () => { RebuildHandles(); Changed?.Invoke(); };
}
/// <summary>Keeps the other handles glued to the shape while one is dragged.</summary>
private void Moved()
{
var hit = Controller.Selected.HitArea;
foreach (var handle in _handles)
{
Vector2? target = hit.Type switch
{
"circle" => handle.Index == Move ? new Vector2(hit.X, hit.Y) : RadiusPoint(hit),
"polygon" => null,
_ => handle.Index switch
{
TopLeft => new Vector2(hit.X, hit.Y),
BottomRight => new Vector2(hit.X + hit.Width, hit.Y + hit.Height),
_ => new Vector2(hit.X + hit.Width / 2f, hit.Y + hit.Height / 2f)
}
};
if (target.HasValue)
handle.CenterAt(GlobalPosition + NormToLocal(target.Value));
}
QueueRedraw();
Changed?.Invoke();
}
private Vector2 RadiusPoint(HitAreaDefinition hit)
{
var rect = GetBackgroundRect();
var offset = rect.Size.X > 0f ? hit.Radius * ShortSide() / rect.Size.X : 0f;
return new Vector2(hit.X + offset, hit.Y);
}
private float ShortSide()
{
var rect = GetBackgroundRect();
return Mathf.Max(1f, Mathf.Min(rect.Size.X, rect.Size.Y));
}
private void RemoveVertex(int index)
{
var hit = Controller.Selected?.HitArea;
if (hit?.Vertices == null || index >= hit.Vertices.Length)
return;
hit.Vertices = hit.Vertices.Where((_, i) => i != index).ToArray();
RebuildHandles();
QueueRedraw();
Changed?.Invoke();
}
private void OnGuiInput(InputEvent @event)
{
if (Controller == null || @event is not InputEventMouseButton mb || !mb.Pressed || mb.ButtonIndex != MouseButton.Left)
return;
var norm = LocalToNorm(mb.Position);
var hitIndex = HitTest(norm);
var selected = Controller.Selected;
if (hitIndex >= 0 && hitIndex != Controller.SelectedIndex)
{
Controller.Select(hitIndex);
RebuildHandles();
QueueRedraw();
ItemPicked?.Invoke(hitIndex);
}
else if (hitIndex < 0 && selected?.HitArea.Type == "polygon")
{
var vertices = selected.HitArea.Vertices ?? Array.Empty<Vector2>();
selected.HitArea.Vertices = vertices.Append(Clamp01(norm)).ToArray();
RebuildHandles();
QueueRedraw();
Changed?.Invoke();
}
AcceptEvent();
}
/// <summary>Topmost item whose collider contains the normalised point, or -1.</summary>
private int HitTest(Vector2 norm)
{
var rect = GetBackgroundRect();
var local = NormToLocal(norm);
for (var i = Controller.Items.Count - 1; i >= 0; i--)
{
var hit = Controller.Items[i].HitArea;
if (hit == null)
continue;
var inside = hit.Type switch
{
"circle" => (local - NormToLocal(new Vector2(hit.X, hit.Y))).Length() <= hit.Radius * Mathf.Min(rect.Size.X, rect.Size.Y),
"polygon" => hit.Vertices is { Length: >= 3 } && Geometry2D.IsPointInPolygon(norm, hit.Vertices),
_ => new Rect2(hit.X, hit.Y, hit.Width, hit.Height).HasPoint(norm)
};
if (inside)
return i;
}
return -1;
}
protected override void DrawContent(Rect2 rect)
{
if (Controller == null)
return;
for (var i = 0; i < Controller.Items.Count; i++)
DrawCollider(Controller.Items[i], rect, i == Controller.SelectedIndex);
}
private void DrawCollider(InteractiveDefinition def, Rect2 rect, bool selected)
{
var hit = def.HitArea;
if (hit == null)
return;
var line = selected ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.Ink, 0.55f);
var fill = selected ? UiTokens.Alpha(UiTokens.Accent, 0.22f) : UiTokens.Alpha(UiTokens.Ink, 0.06f);
Vector2 labelAt;
switch (hit.Type)
{
case "circle":
var centre = NormToLocal(new Vector2(hit.X, hit.Y));
var radius = hit.Radius * Mathf.Min(rect.Size.X, rect.Size.Y);
DrawCircle(centre, radius, fill);
DrawArc(centre, radius, 0f, Mathf.Tau, 48, line, selected ? 2f : 1f, true);
labelAt = centre + new Vector2(-radius, -radius);
break;
case "polygon":
var vertices = hit.Vertices ?? Array.Empty<Vector2>();
var points = vertices.Select(NormToLocal).ToArray();
if (points.Length >= 3 && Geometry2D.TriangulatePolygon(points).Length > 0)
DrawColoredPolygon(points, fill);
if (points.Length >= 2)
DrawPolyline(points.Append(points[0]).ToArray(), line, selected ? 2f : 1f, true);
labelAt = points.Length > 0 ? points[0] : rect.Position;
break;
default:
var box = new Rect2(NormToLocal(new Vector2(hit.X, hit.Y)), new Vector2(hit.Width, hit.Height) * rect.Size);
DrawRect(box, fill);
DrawRect(box, line, false, selected ? 2f : 1f);
labelAt = box.Position;
break;
}
var font = GetThemeDefaultFont();
var size = UiTokens.FontSmall;
DrawString(font, labelAt + new Vector2(4f, -4f), def.ID ?? "?", HorizontalAlignment.Left, -1, size,
selected ? UiTokens.AccentBright : UiTokens.Body);
}
}
}
@@ -1,244 +1,206 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Cthangover.Core.Interactive;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.InteractiveEditor
{
/// <summary>
/// Data model for the interactive editor. Holds interactive definition
/// fields, manages collider vertex list for polygon editing, and produces
/// the output JSON.
/// </summary>
public class InteractiveEditorController
{
/// <summary>Unique identifier for the interactive object resource.</summary>
public string Id { get; set; } = "new_interactive";
/// <summary>Texture key mapped to a sprite resource by the resource system.</summary>
public string Texture { get; set; } = "";
/// <summary>Render layer: <c>"foreground"</c>, <c>"background"</c>, or <c>"ui"</c>.</summary>
public string Layer { get; set; } = "foreground";
/// <summary>Z-index for draw order within the layer.</summary>
public int ZIndex { get; set; }
/// <summary>Whether the interactive is initially enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Whether the interactive is visible.</summary>
public bool Visible { get; set; } = true;
/// <summary>Mouse cursor override name, e.g. <c>"PointingHand"</c>. Empty for default.</summary>
public string Cursor { get; set; } = "";
/// <summary>Collider shape type: <c>"rect"</c>, <c>"circle"</c>, or <c>"polygon"</c>.</summary>
public string HitType { get; set; } = "rect";
/// <summary>
/// Data model of the interactive editor: every definition of one
/// <c>interactives/*.json</c> file plus the selection. Definitions are
/// edited in place (the same objects the game deserialises), so the file
/// round-trips without a parallel field-by-field copy that could forget a
/// property. <see cref="Ensure"/> fills the optional sub-objects the editor
/// needs (hit area, highlight, actions) before a definition is shown.
/// </summary>
public class InteractiveEditorController
{
/// <summary>Collider kinds understood by <c>InteractiveObject</c>.</summary>
public static readonly string[] HitTypes = { "rect", "circle", "polygon" };
/// <summary>Normalised X position of the rect collider (0–1).</summary>
public float RectX { get; set; }
/// <summary>Normalised Y position of the rect collider (0–1).</summary>
public float RectY { get; set; }
/// <summary>Normalised width of the rect collider (0–1).</summary>
public float RectW { get; set; } = 0.1f;
/// <summary>Normalised height of the rect collider (0–1).</summary>
public float RectH { get; set; } = 0.1f;
/// <summary>Render layers understood by the interactive layer host.</summary>
public static readonly string[] Layers = { "foreground", "background", "ui" };
/// <summary>Normalised X centre of the circle collider (0–1).</summary>
public float CircleX { get; set; } = 0.5f;
/// <summary>Normalised Y centre of the circle collider (0–1).</summary>
public float CircleY { get; set; } = 0.5f;
/// <summary>Normalised radius of the circle collider (0–1).</summary>
public float CircleRadius { get; set; } = 0.05f;
/// <summary>Definitions of the open file, in file order.</summary>
public List<InteractiveDefinition> Items { get; private set; } = new();
/// <summary>List of normalised (0–1) vertices defining the polygon collider.</summary>
public List<Vector2> PolygonVertices { get; set; } = new();
/// <summary>Index of the selected definition, or -1.</summary>
public int SelectedIndex { get; private set; } = -1;
/// <summary>Hex colour string (e.g. <c>"#FFFF0033"</c>) for the highlight overlay.</summary>
public string HighlightColorHex { get; set; } = "#FFFF0033";
/// <summary>Scale multiplier applied to the sprite when highlighted.</summary>
public float HighlightScale { get; set; } = 1.02f;
/// <summary>The selected definition, or <c>null</c>.</summary>
public InteractiveDefinition Selected => SelectedIndex >= 0 && SelectedIndex < Items.Count ? Items[SelectedIndex] : null;
/// <summary>Scenario file path executed on click.</summary>
public string OnClickScenario { get; set; } = "";
/// <summary>Inline DSL commands executed on click, one per line.</summary>
public string OnClickCommands { get; set; } = "";
/// <summary>DSL commands executed when the mouse enters the interactive area.</summary>
public string OnHoverEnter { get; set; } = "";
/// <summary>DSL commands executed when the mouse leaves the interactive area.</summary>
public string OnHoverLeave { get; set; } = "";
/// <summary>Replaces the items with the contents of a <c>{"Items": [...]}</c> file. Throws on malformed JSON.</summary>
public void FromJson(string json)
{
var data = string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<FileData<InteractiveDefinition>>(json, JsonOptionsProvider.ReadOptions);
Items = data?.Items ?? new List<InteractiveDefinition>();
foreach (var item in Items)
Ensure(item);
SelectedIndex = Items.Count > 0 ? 0 : -1;
}
/// <summary>Builds an InteractiveDefinition from current state.</summary>
public InteractiveDefinition ToDefinition()
{
var hitArea = new HitAreaDefinition { Type = HitType };
/// <summary>Serialises all items into the standard envelope (indented, nulls omitted).</summary>
public string ToJson()
{
var data = new FileData<InteractiveDefinition> { Items = Items };
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
}
switch (HitType)
{
case "rect":
hitArea.X = RectX;
hitArea.Y = RectY;
hitArea.Width = RectW;
hitArea.Height = RectH;
break;
case "circle":
hitArea.X = CircleX;
hitArea.Y = CircleY;
hitArea.Radius = CircleRadius;
break;
case "polygon":
hitArea.Vertices = PolygonVertices.ToArray();
break;
}
/// <summary>Drops all items.</summary>
public void Clear()
{
Items = new List<InteractiveDefinition>();
SelectedIndex = -1;
}
var clickAction = new ClickAction();
/// <summary>Selects by index; out of range clears the selection.</summary>
public void Select(int index)
{
SelectedIndex = index >= 0 && index < Items.Count ? index : -1;
}
if (!string.IsNullOrWhiteSpace(OnClickScenario))
clickAction.Scenario = OnClickScenario.Trim();
/// <summary>Appends a new clickable rect in the middle of the background and selects it.</summary>
public InteractiveDefinition Add()
{
var def = new InteractiveDefinition
{
ID = MakeUniqueId("interactive"),
Cursor = "PointingHand",
HitArea = new HitAreaDefinition { Type = "rect", X = 0.45f, Y = 0.45f, Width = 0.1f, Height = 0.1f }
};
Ensure(def);
Items.Add(def);
SelectedIndex = Items.Count - 1;
return def;
}
if (!string.IsNullOrWhiteSpace(OnClickCommands))
{
var lines = OnClickCommands.Split('\n', System.StringSplitOptions.RemoveEmptyEntries);
var cleanLines = new List<string>();
foreach (var line in lines)
{
var trimmed = line.Trim();
if (!string.IsNullOrEmpty(trimmed))
cleanLines.Add(trimmed);
}
if (cleanLines.Count > 0)
clickAction.Commands = cleanLines.ToArray();
}
/// <summary>Appends a deep copy of the selected definition with a fresh ID, slightly offset.</summary>
public InteractiveDefinition DuplicateSelected()
{
var source = Selected;
if (source == null)
return null;
return new InteractiveDefinition
{
ID = Id,
Texture = string.IsNullOrWhiteSpace(Texture) ? null : Texture.Trim(),
Layer = Layer,
ZIndex = ZIndex,
Enabled = Enabled,
Visible = Visible,
Cursor = string.IsNullOrWhiteSpace(Cursor) ? null : Cursor.Trim(),
HitArea = hitArea,
Highlight = new HighlightDefinition
{
ColorHex = HighlightColorHex,
Scale = HighlightScale
},
Actions = new InteractiveActionDefinition
{
OnClick = (clickAction.Scenario != null || clickAction.Commands != null) ? clickAction : null,
OnHoverEnter = string.IsNullOrWhiteSpace(OnHoverEnter) ? null : OnHoverEnter.Trim(),
OnHoverLeave = string.IsNullOrWhiteSpace(OnHoverLeave) ? null : OnHoverLeave.Trim()
}
};
}
var json = JsonSerializer.Serialize(source, JsonOptionsProvider.WriteCompactOptions);
var copy = JsonSerializer.Deserialize<InteractiveDefinition>(json, JsonOptionsProvider.ReadOptions);
copy.ID = MakeUniqueId(source.ID);
Ensure(copy);
Offset(copy.HitArea, new Vector2(0.02f, 0.02f));
Items.Add(copy);
SelectedIndex = Items.Count - 1;
return copy;
}
/// <summary>Loads state from an InteractiveDefinition.</summary>
public void FromDefinition(InteractiveDefinition def)
{
if (def == null) return;
/// <summary>Removes the selected definition and keeps a sensible selection.</summary>
public void RemoveSelected()
{
if (Selected == null)
return;
Id = def.ID ?? "new_interactive";
Texture = def.Texture ?? "";
Layer = def.Layer ?? "foreground";
ZIndex = def.ZIndex;
Enabled = def.Enabled;
Visible = def.Visible;
Cursor = def.Cursor ?? "";
Items.RemoveAt(SelectedIndex);
SelectedIndex = Items.Count == 0 ? -1 : Math.Min(SelectedIndex, Items.Count - 1);
}
if (def.HitArea != null)
{
HitType = def.HitArea.Type ?? "rect";
RectX = def.HitArea.X;
RectY = def.HitArea.Y;
RectW = def.HitArea.Width;
RectH = def.HitArea.Height;
CircleX = def.HitArea.X;
CircleY = def.HitArea.Y;
CircleRadius = def.HitArea.Radius;
PolygonVertices = def.HitArea.Vertices != null
? new List<Vector2>(def.HitArea.Vertices)
: new List<Vector2>();
}
else
{
HitType = "rect";
}
/// <summary>
/// Changes the collider kind of a definition, converting the current
/// shape so it stays roughly where it was (rect ↔ circle by bounds,
/// polygon from the bounds' corners).
/// </summary>
public static void SetHitType(InteractiveDefinition def, string type)
{
var hit = def.HitArea;
if (hit.Type == type)
return;
if (def.Highlight != null)
{
HighlightColorHex = def.Highlight.ColorHex ?? "#FFFF0033";
HighlightScale = def.Highlight.Scale;
}
var bounds = Bounds(hit);
hit.Type = type;
switch (type)
{
case "circle":
hit.X = bounds.GetCenter().X;
hit.Y = bounds.GetCenter().Y;
hit.Radius = Mathf.Max(0.01f, Mathf.Min(bounds.Size.X, bounds.Size.Y) / 2f);
break;
case "polygon":
hit.Vertices = new[]
{
bounds.Position,
new Vector2(bounds.End.X, bounds.Position.Y),
bounds.End,
new Vector2(bounds.Position.X, bounds.End.Y)
};
break;
default:
hit.X = bounds.Position.X;
hit.Y = bounds.Position.Y;
hit.Width = bounds.Size.X;
hit.Height = bounds.Size.Y;
break;
}
}
OnClickScenario = def.Actions?.OnClick?.Scenario ?? "";
OnClickCommands = def.Actions?.OnClick?.Commands != null
? string.Join("\n", def.Actions.OnClick.Commands)
: "";
OnHoverEnter = def.Actions?.OnHoverEnter ?? "";
OnHoverLeave = def.Actions?.OnHoverLeave ?? "";
}
/// <summary>
/// Normalised bounding box of a hit area (circle radius taken as a
/// fraction of both axes — an approximation good enough for
/// conversions and duplication offsets).
/// </summary>
public static Rect2 Bounds(HitAreaDefinition hit)
{
switch (hit?.Type)
{
case "circle":
return new Rect2(hit.X - hit.Radius, hit.Y - hit.Radius, hit.Radius * 2f, hit.Radius * 2f);
case "polygon" when hit.Vertices is { Length: > 0 }:
var min = hit.Vertices[0];
var max = hit.Vertices[0];
foreach (var v in hit.Vertices)
{
min = new Vector2(Mathf.Min(min.X, v.X), Mathf.Min(min.Y, v.Y));
max = new Vector2(Mathf.Max(max.X, v.X), Mathf.Max(max.Y, v.Y));
}
return new Rect2(min, max - min);
case null:
return new Rect2(0.45f, 0.45f, 0.1f, 0.1f);
default:
return new Rect2(hit.X, hit.Y, hit.Width, hit.Height);
}
}
/// <summary>Serializes the definition to formatted JSON (FileData envelope).</summary>
public string ToJson()
{
var def = ToDefinition();
var wrapper = new
{
Items = new[] { def }
};
/// <summary>Makes sure the optional parts the editor binds to exist.</summary>
public static void Ensure(InteractiveDefinition def)
{
def.HitArea ??= new HitAreaDefinition();
def.HitArea.Type = HitTypes.Contains(def.HitArea.Type) ? def.HitArea.Type : "rect";
def.Highlight ??= new HighlightDefinition();
def.Actions ??= new InteractiveActionDefinition();
}
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>ID not used by another item: <c>base</c>, <c>base_2</c>, <c>base_3</c>…</summary>
public string MakeUniqueId(string baseId)
{
var used = new HashSet<string>(Items.Select(i => i.ID ?? string.Empty), StringComparer.OrdinalIgnoreCase);
if (!used.Contains(baseId))
return baseId;
return JsonSerializer.Serialize(wrapper, options);
}
var suffix = 2;
while (used.Contains($"{baseId}_{suffix}"))
suffix++;
return $"{baseId}_{suffix}";
}
/// <summary>Parses JSON back into the controller. Returns false on failure.</summary>
public bool FromJson(string json)
{
try
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip };
var wrapper = JsonSerializer.Deserialize<FileDataWrapper>(json, options);
if (wrapper?.Items != null && wrapper.Items.Count > 0)
{
FromDefinition(wrapper.Items[0]);
return true;
}
return false;
}
catch
{
return false;
}
}
private class FileDataWrapper
{
public List<InteractiveDefinition> Items { get; set; }
}
/// <summary>Updates a polygon vertex at the given index.</summary>
public void SetPolygonVertex(int index, Vector2 value)
{
while (PolygonVertices.Count <= index)
PolygonVertices.Add(Vector2.Zero);
PolygonVertices[index] = value;
}
/// <summary>Removes a polygon vertex at the given index.</summary>
public void RemovePolygonVertex(int index)
{
if (index >= 0 && index < PolygonVertices.Count)
PolygonVertices.RemoveAt(index);
}
/// <summary>Adds a new polygon vertex at the default position (center of viewport).</summary>
public void AddPolygonVertex()
{
var offset = PolygonVertices.Count * 0.04f;
PolygonVertices.Add(new Vector2(0.5f + offset, 0.5f + offset));
}
}
private static void Offset(HitAreaDefinition hit, Vector2 delta)
{
hit.X += delta.X;
hit.Y += delta.Y;
if (hit.Vertices != null)
hit.Vertices = hit.Vertices.Select(v => v + delta).ToArray();
}
}
}
@@ -1,64 +0,0 @@
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.InteractiveEditor
{
/// <summary>
/// Draggable 16x16 handle representing a collider vertex on the preview area.
/// Used for rect corners, circle radius marker, and polygon vertices.
/// Subscribes to the GuiInput C# event rather than overriding _GuiInput
/// for reliable event delivery in mod assemblies.
/// </summary>
public partial class InteractiveEditorHandle : ColorRect
{
/// <summary>Index of the vertex this handle represents (0-based).</summary>
public int VertexIndex;
/// <summary>Invoked during drag with the centre position of the handle in global coordinates.</summary>
public System.Action<Vector2> DragUpdate;
/// <summary>Invoked when the handle is clicked (mouse down).</summary>
public System.Action Clicked;
/// <summary>Invoked when the drag ends (mouse up). Used to sync sidebar values.</summary>
public System.Action DragEnd;
private bool _dragging;
private Vector2 _dragOffset;
/// <summary>Creates a handle with the given colour, size 16×16, and index for vertex identification.</summary>
public InteractiveEditorHandle(Color color, int index)
{
VertexIndex = index;
Size = new Vector2(16, 16);
Color = color;
MouseFilter = Control.MouseFilterEnum.Stop;
MouseDefaultCursorShape = Control.CursorShape.PointingHand;
GuiInput += OnGuiInput;
}
private void OnGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
{
_dragging = true;
_dragOffset = GetGlobalMousePosition() - GlobalPosition;
GameLogger.Log("INTERACTIVE_HANDLE", $"drag start idx={VertexIndex} pos={GlobalPosition}");
Clicked?.Invoke();
}
else
{
_dragging = false;
GameLogger.Log("INTERACTIVE_HANDLE", $"drag end idx={VertexIndex}");
DragEnd?.Invoke();
}
AcceptEvent();
}
else if (@event is InputEventMouseMotion && _dragging)
{
var center = GlobalPosition + Size / 2;
GlobalPosition = GetGlobalMousePosition() - _dragOffset;
DragUpdate?.Invoke(center);
}
}
}
}
@@ -1,6 +1,7 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Tools.InteractiveEditor
{
/// <summary>
File diff suppressed because it is too large. Load diff
+129
View File
@@ -0,0 +1,129 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>
/// Night-time preview of a scene with its static lights. The background is
/// drawn through the game's <c>timed_sprite</c> shader (set as this
/// control's material), so the preview lights the picture exactly like the
/// game; the lights' positions are pushed to the shader in this canvas'
/// coordinates and their radii scaled from the 1920-px game width to the
/// drawn background width. Radius rings and handles live in a separate
/// child overlay — anything drawn by this control itself would be shaded.
/// <para/>
/// A click on a handle selects that light and drags it; a double-click on
/// empty background adds a light there.
/// </summary>
public partial class LightEditorCanvas : BackgroundCanvas
{
private readonly Control _overlay;
/// <summary>Data being edited; set once by the window.</summary>
public LightEditorController Controller;
/// <summary>Preview material (may be null when the shader is missing).</summary>
public ShaderMaterial PreviewMaterial
{
get => Material as ShaderMaterial;
set => Material = value;
}
/// <summary>Raised when a light moved or was added on the canvas.</summary>
public event Action Changed;
/// <summary>Raised when the selection changed on the canvas.</summary>
public event Action SelectionChanged;
public LightEditorCanvas()
{
_overlay = new Control { MouseFilter = MouseFilterEnum.Ignore };
AddChild(_overlay);
_overlay.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_overlay.Connect("draw", Callable.From((Action)DrawRings));
GuiInput += OnGuiInput;
}
/// <summary>One handle per light, coloured like the light.</summary>
public override void RebuildHandles()
{
ClearHandles();
if (Controller == null || GetBackgroundRect().Size.X <= 0f)
return;
for (var i = 0; i < Controller.Lights.Count; i++)
{
var index = i;
var light = Controller.Lights[i];
var handle = AddHandle(new Vector2(light.X, light.Y), light.ToColor(), i, p =>
{
light.X = p.X;
light.Y = p.Y;
Refresh();
Changed?.Invoke();
}, index == Controller.SelectedIndex ? 20f : 16f);
handle.Clicked = () => SelectLight(index);
}
Refresh();
}
/// <summary>Redraws the preview and re-uploads the shader uniforms.</summary>
public void Refresh()
{
QueueRedraw();
_overlay.QueueRedraw();
}
private void SelectLight(int index)
{
if (index == Controller.SelectedIndex)
return;
Controller.Select(index);
SelectionChanged?.Invoke();
CallDeferred(nameof(RebuildHandles));
}
private void OnGuiInput(InputEvent @event)
{
if (Controller == null || @event is not InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left } mb)
return;
if (mb.DoubleClick && Controller.Add(Clamp01(LocalToNorm(mb.Position))) != null)
{
RebuildHandles();
Changed?.Invoke();
SelectionChanged?.Invoke();
}
AcceptEvent();
}
protected override void DrawContent(Rect2 rect)
{
// The shader compares against world_pos (MODEL_MATRIX * VERTEX), i.e.
// canvas coordinates of the window, not this control's local ones.
Controller?.UpdatePreview(PreviewMaterial, n => GlobalPosition + NormToLocal(n), rect.Size.X / LightEditorController.GameWidth);
}
private void DrawRings()
{
var rect = GetBackgroundRect();
if (Controller == null || rect.Size.X <= 0f)
return;
var scale = rect.Size.X / LightEditorController.GameWidth;
for (var i = 0; i < Controller.Lights.Count; i++)
{
var light = Controller.Lights[i];
var selected = i == Controller.SelectedIndex;
var centre = NormToLocal(new Vector2(light.X, light.Y));
var color = selected ? UiTokens.AccentBright : UiTokens.Alpha(light.ToColor(), 0.45f);
_overlay.DrawArc(centre, light.Radius * scale, 0f, Mathf.Tau, 64, color, selected ? 1.5f : 1f, true);
}
}
}
}
+173 -147
View File
@@ -1,176 +1,202 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.UI.Lights;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.Mods.Time;
using Godot;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>
/// Data controller for the light editor tool. Maintains a list of
/// <see cref="LightDef"/> objects and feeds up to 10 lights plus one
/// sentinel entry into the <c>"timed_sprite"</c> shader material for
/// real-time preview. Exports lights as JSON (<see cref="ExportLightsJson"/>)
/// or as a scenario DSL snippet (<see cref="ExportScenarioSnippet"/>).
/// Uses a dirty-flag pattern so shader uniform updates only occur when
/// the light list changes.
/// Data of the light editor: the static lights of one scenario and their
/// place in the scenario text. Static lights live in a <c>light_set</c>
/// line (<c>light_set [{"x":…,"y":…,"radius":…,"influence":…,"color":…}]</c>)
/// that <c>UiLightController.SetStaticLights</c> reads at runtime: x/y are
/// fractions of the viewport, radius is in viewport pixels at the game
/// resolution. The controller parses that line, edits the list and writes
/// the line back (or inserts one right after the scenario header), and
/// feeds the <c>timed_sprite</c> shader of the preview the same way the
/// game does, so the preview shows the real night lighting.
/// </summary>
public class LightEditorController
{
/// <summary>The editable list of light definitions. Capped at 10 for shader preview.</summary>
public List<LightDef> Lights { get; } = new();
public class LightEditorController
{
/// <summary>Static light slots the shader has (slot 0 is the dynamic light).</summary>
public const int MaxLights = 10;
private readonly Vector2[] previewPositions = new Vector2[11];
private readonly Color[] previewColors = new Color[11];
private readonly float[] previewRadii = new float[11];
private readonly float[] previewInfluence = new float[11];
/// <summary>Viewport width the stored radii refer to.</summary>
public const float GameWidth = 1920f;
private bool dirty = true;
private const string Command = "light_set";
private const int ShaderSlots = 11;
private static readonly Vector2 Offscreen = new(-1000f, -1000f);
/// <summary>Initialises preview arrays with out-of-bounds sentinel values.</summary>
public LightEditorController()
{
for (int i = 0; i < 11; i++)
{
previewPositions[i] = new Vector2(-1000f, -1000f);
previewRadii[i] = 0f;
previewInfluence[i] = 0f;
previewColors[i] = UiTokens.White;
}
}
/// <summary>The edited lights, in file order.</summary>
public List<LightDef> Lights { get; private set; } = new();
/// <summary>Index of the selected light, or -1.</summary>
public int SelectedIndex { get; private set; } = -1;
/// <summary>The selected light, or <c>null</c>.</summary>
public LightDef Selected => SelectedIndex >= 0 && SelectedIndex < Lights.Count ? Lights[SelectedIndex] : null;
/// <summary>Selects by index; out of range clears the selection.</summary>
public void Select(int index) => SelectedIndex = index >= 0 && index < Lights.Count ? index : -1;
/// <summary>Adds a warm light at the given normalised point and selects it; null when all slots are used.</summary>
public LightDef Add(Vector2 position)
{
if (Lights.Count >= MaxLights)
return null;
var light = new LightDef { X = position.X, Y = position.Y, Radius = 400f, Influence = 1f, ColorHex = "#ffcc66" };
Lights.Add(light);
SelectedIndex = Lights.Count - 1;
return light;
}
/// <summary>Removes the selected light.</summary>
public void RemoveSelected()
{
if (Selected == null)
return;
Lights.RemoveAt(SelectedIndex);
SelectedIndex = Math.Min(SelectedIndex, Lights.Count - 1);
}
/// <summary>Compact JSON array exactly as written into the <c>light_set</c> line.</summary>
public string ToJson() => JsonSerializer.Serialize(Lights, JsonOptionsProvider.WriteCompactOptions);
/// <summary>The scenario line for the current lights.</summary>
public string ToScenarioLine() => Command + " " + ToJson();
/// <summary>
/// Creates a <see cref="ShaderMaterial"/> using the <c>"timed_sprite"</c> shader
/// with night-time weights applied. Returns <c>null</c> if the shader is not found.
/// Loads the lights of the first <c>light_set</c> line of a scenario.
/// Returns <c>false</c> when the scenario has none (the list is then
/// empty and saving inserts a new line). Throws on malformed JSON.
/// </summary>
public ShaderMaterial CreatePreviewMaterial()
{
var shader = Shaders.Resolve("timed_sprite");
if (shader == null)
return null;
public bool LoadFromScenario(string text)
{
Lights = new List<LightDef>();
SelectedIndex = -1;
var mat = new ShaderMaterial { Shader = shader };
var night = new Vector4(0, 0, 0, 1);
mat.SetShaderParameter("time_weights", night);
mat.SetShaderParameter("use_time", true);
return mat;
}
var index = FindLine(text, out var lines);
if (index < 0)
return false;
/// <summary>Sets the background texture uniform on the preview shader material.</summary>
public void SetBackgroundForPreview(Texture2D texture, ShaderMaterial material)
{
if (material != null)
material.SetShaderParameter("background_map", texture);
}
/// <summary>Sets the depth mask texture uniform on the preview shader material.</summary>
public void SetDepthForPreview(Texture2D texture, ShaderMaterial material)
{
if (material != null)
material.SetShaderParameter("depth_mask", texture);
}
/// <summary>Sets the albedo texture uniform on the preview shader material.</summary>
public void SetAlbedoForPreview(Texture2D texture, ShaderMaterial material)
{
if (material != null)
material.SetShaderParameter("albedo_map", texture);
}
/// <summary>Appends a <see cref="LightDef"/> to the list and marks the preview dirty.</summary>
public void AddLight(LightDef light)
{
Lights.Add(light);
dirty = true;
}
/// <summary>Removes the light at <paramref name="index"/>. No-op if out of range.</summary>
public void RemoveLight(int index)
{
if (index < 0 || index >= Lights.Count)
return;
Lights.RemoveAt(index);
dirty = true;
}
/// <summary>Removes all lights from the list and marks dirty.</summary>
public void Clear()
{
Lights.Clear();
dirty = true;
}
/// <summary>Forces the preview to be recalculated on the next <see cref="UpdatePreview"/> call.</summary>
public void MarkDirty()
{
dirty = true;
}
var json = ArgumentOf(lines[index]);
Lights = JsonSerializer.Deserialize<List<LightDef>>(json, JsonOptionsProvider.ReadOptions) ?? new List<LightDef>();
SelectedIndex = Lights.Count > 0 ? 0 : -1;
return true;
}
/// <summary>
/// Packs up to 10 lights plus a sentinel into shader uniform arrays.
/// Only executes if <c>dirty</c> is <c>true</c>. Converts each light's
/// normalised position to pixel coordinates relative to the preview area.
/// Returns the scenario text with its <c>light_set</c> line replaced by
/// the current lights, or — when it has none — with a new line inserted
/// right after the <c>---</c> header separator (at the top otherwise).
/// </summary>
public void UpdatePreview(ShaderMaterial material, Vector2 previewGlobalPos, Vector2 previewSize)
{
if (material == null || !dirty)
return;
public string WriteToScenario(string text)
{
var index = FindLine(text, out var lines);
var newline = text.Contains("\r\n") ? "\r\n" : "\n";
if (index >= 0)
{
var indent = lines[index].Substring(0, lines[index].Length - lines[index].TrimStart().Length);
lines[index] = indent + ToScenarioLine();
}
else
{
var separator = lines.FindIndex(l => l.Trim() == "---");
lines.Insert(separator + 1, ToScenarioLine());
}
for (int i = 0; i < 11; i++)
{
previewPositions[i] = new Vector2(-1000f, -1000f);
previewRadii[i] = 0f;
previewInfluence[i] = 0f;
previewColors[i] = UiTokens.White;
}
return string.Join(newline, lines);
}
int count = Mathf.Min(Lights.Count, 10);
for (int i = 0; i < count; i++)
{
var pixelPos = Lights[i].ToPixelPos(previewSize);
previewPositions[i] = new Vector2(
pixelPos.X + previewGlobalPos.X,
pixelPos.Y + previewGlobalPos.Y);
previewRadii[i] = Lights[i].Radius;
previewInfluence[i] = Lights[i].Influence;
previewColors[i] = Lights[i].ToColor();
}
/// <summary>True when the scenario text contains a <c>light_set</c> line.</summary>
public static bool HasLights(string text) => FindLine(text, out _) >= 0;
var posArray = new Godot.Collections.Array<Vector2>();
var colArray = new Godot.Collections.Array<Color>();
var radArray = new Godot.Collections.Array<float>();
var infArray = new Godot.Collections.Array<float>();
for (int i = 0; i < 11; i++)
{
posArray.Add(previewPositions[i]);
colArray.Add(previewColors[i]);
radArray.Add(previewRadii[i]);
infArray.Add(previewInfluence[i]);
}
private static int FindLine(string text, out List<string> lines)
{
lines = new List<string>((text ?? string.Empty).Replace("\r\n", "\n").Split('\n'));
return lines.FindIndex(l => l.TrimStart().StartsWith(Command + " ", StringComparison.Ordinal));
}
material.SetShaderParameter("light_count", count);
material.SetShaderParameter("light_positions", posArray);
material.SetShaderParameter("light_colors", colArray);
material.SetShaderParameter("light_radii", radArray);
material.SetShaderParameter("light_influence", infArray);
material.SetShaderParameter("light_radius_scale", 1.0f);
/// <summary>Argument of a <c>light_set</c> line; the old quoted form (<c>"[{\"x\":…}]"</c>) is unescaped.</summary>
private static string ArgumentOf(string line)
{
var arg = line.Trim().Substring(Command.Length).Trim();
if (arg.Length >= 2 && arg[0] == '"' && arg[arg.Length - 1] == '"')
arg = arg.Substring(1, arg.Length - 2).Replace("\\\"", "\"");
return arg;
}
dirty = false;
}
// ── shader preview ─────────────────────────────────────────
/// <summary>Serialises the light list as a compact JSON string.</summary>
public string ExportLightsJson()
{
return JsonSerializer.Serialize(Lights, Cthangover.Core.Utils.JsonOptionsProvider.WriteCompactOptions);
}
/// <summary>
/// Material of the preview: the game's <c>timed_sprite</c> shader at
/// night weights. <c>null</c> when the shader is not installed.
/// </summary>
public static ShaderMaterial CreatePreviewMaterial()
{
var shader = Shaders.Resolve("timed_sprite");
if (shader == null)
return null;
/// <summary>Produces a <c>light_set</c> scenario DSL command with the lights JSON as its argument.</summary>
public string ExportScenarioSnippet()
{
var json = ExportLightsJson();
return $"light_set \"{json.Replace("\"", "\\\"")}\"";
}
}
var material = new ShaderMaterial { Shader = shader };
material.SetShaderParameter("time_weights", new Vector4(0, 0, 0, 1));
material.SetShaderParameter("use_time", true);
return material;
}
/// <summary>Binds the background and its optional depth/albedo masks to the preview material.</summary>
public static void SetPreviewTextures(ShaderMaterial material, Texture2D depth, Texture2D albedo)
{
if (material == null)
return;
material.SetShaderParameter("depth_mask", depth);
material.SetShaderParameter("albedo_map", albedo);
}
/// <summary>
/// Uploads the lights into the shader uniforms. <paramref name="toCanvas"/>
/// maps a normalised position into the preview's canvas coordinates
/// (the space of the shader's <c>world_pos</c>); <paramref name="radiusScale"/>
/// shrinks game-resolution radii to the preview size.
/// </summary>
public void UpdatePreview(ShaderMaterial material, Func<Vector2, Vector2> toCanvas, float radiusScale)
{
if (material == null)
return;
var positions = new Godot.Collections.Array<Vector2>();
var colors = new Godot.Collections.Array<Color>();
var radii = new Godot.Collections.Array<float>();
var influence = new Godot.Collections.Array<float>();
// Slot 0 is the dynamic (cursor) light at runtime; keep it off.
positions.Add(Offscreen);
colors.Add(UiTokens.White);
radii.Add(0f);
influence.Add(0f);
var count = Math.Min(Lights.Count, MaxLights);
for (var i = 0; i < ShaderSlots - 1; i++)
{
var on = i < count;
positions.Add(on ? toCanvas(new Vector2(Lights[i].X, Lights[i].Y)) : Offscreen);
colors.Add(on ? Lights[i].ToColor() : UiTokens.White);
radii.Add(on ? Lights[i].Radius : 0f);
influence.Add(on ? Lights[i].Influence : 0f);
}
material.SetShaderParameter("light_count", count + 1);
material.SetShaderParameter("light_positions", positions);
material.SetShaderParameter("light_colors", colors);
material.SetShaderParameter("light_radii", radii);
material.SetShaderParameter("light_influence", influence);
material.SetShaderParameter("light_radius_scale", radiusScale);
}
}
}
-58
View File
@@ -1,58 +0,0 @@
using Cthangover.Core.UI.Lights;
using Godot;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>
/// Draggable 24×24 coloured handle representing a light source on the preview.
/// Overrides <c>_GuiInput</c> (Godot virtual) for mouse drag support; the colour
/// is taken from <see cref="LightDef.ToColor"/>. Raises <see cref="DragUpdate"/>
/// with the handle's centre position in global coordinates for the window to
/// convert back to normalised coordinates.
/// </summary>
public partial class LightEditorHandle : ColorRect
{
/// <summary>Index of the light in <see cref="LightEditorController.Lights"/>.</summary>
public int LightIndex;
/// <summary>Invoked during drag with the centre position in global coordinates.</summary>
public System.Action<Vector2> DragUpdate;
/// <summary>Invoked when the handle is clicked (mouse down).</summary>
public System.Action Clicked;
private bool dragging;
private Vector2 dragOffset;
/// <summary>Creates a 24×24 handle coloured to match the light's <see cref="LightDef.ToColor"/> value.</summary>
public LightEditorHandle(LightDef light, int index)
{
LightIndex = index;
Size = new Vector2(24, 24);
Color = light.ToColor();
MouseFilter = MouseFilterEnum.Stop;
}
/// <summary>Handles mouse drag and click via Godot's virtual input system (<c>_GuiInput</c>).</summary>
public override void _GuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
{
dragging = true;
dragOffset = GetGlobalMousePosition() - GlobalPosition;
Clicked?.Invoke();
}
else
{
dragging = false;
}
AcceptEvent();
}
else if (@event is InputEventMouseMotion && dragging)
{
GlobalPosition = GetGlobalMousePosition() - dragOffset;
DragUpdate?.Invoke(GlobalPosition + Size / 2);
}
}
}
}
@@ -1,6 +1,7 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>Registers the light editor as a tool window. Creates <see cref="LightEditorWindow"/> instances.</summary>
+320 -313
View File
@@ -1,379 +1,386 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Linq;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.UI.Lights;
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;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>
/// Editor window for placing and configuring static lights on a background.
/// Provides a sidebar with background selection, a light list with add/delete/select,
/// per-light property editors (radius, influence, colour), draggable handles on a
/// shader-powered preview, and JSON export. Uses <see cref="LightEditorController"/>
/// for data management and <see cref="ModResourceService"/> for background textures.
/// Editor of the static lights of a scene. The author picks a scenario —
/// the ones that already set lights come first — and edits its
/// <c>light_set</c> line visually: drag lights on a night-time preview of
/// the scene background, double-click to add one, tune radius, influence
/// and colour. Saving rewrites that line in the scenario (or inserts it
/// after the header). The background follows the scenario's scene.
/// </summary>
public partial class LightEditorWindow : ToolWindow
{
private LightEditorController controller;
{
private readonly LightEditorController _controller = new();
private readonly List<ScenarioFileInfo> _scenarios = new();
private readonly List<string> _backgroundIds = new();
private ItemList backgroundList;
private VBoxContainer lightListPanel;
private Button addLightBtn;
private TextureRect preview;
private ShaderMaterial previewMaterial;
private ScenarioFileInfo _scenario;
private string _scenarioText;
private bool _suppress;
private LineEdit radiusEdit;
private LineEdit influenceEdit;
private ColorPickerButton colorPicker;
private TextEdit jsonEdit;
private OptionButton _scenarioSelect;
private LightEditorCanvas _canvas;
private ItemList _lightList;
private Button _addBtn;
private Button _deleteBtn;
private VBoxContainer _propsBody;
private Label _propsEmpty;
private SpinBox _x;
private SpinBox _y;
private SpinBox _radius;
private SpinBox _influence;
private ColorPickerButton _color;
private FilteredList _bgList;
private CodeEdit _lineEdit;
private readonly List<LightEditorHandle> handles = new();
private int selectedIndex = -1;
private readonly List<string> bgIds = new();
/// <summary>Constructs the window, creates the controller, builds UI, and loads the background list.</summary>
public LightEditorWindow() : base("tools/light_editor/title")
{
controller = new LightEditorController();
CthangoverTheme.Apply(this);
BuildUI();
LoadBackgroundList();
}
{
CthangoverTheme.Apply(this);
BuildUI();
LoadBackgrounds();
LoadScenarioList();
SetHint(ToolUi.Tr("tools/light_editor/hint"));
}
protected override void Cleanup()
{
foreach (var h in handles)
h.QueueFree();
handles.Clear();
}
// ── UI construction ────────────────────────────────────────
private Vector2 PreviewSize()
{
var sz = preview.Size;
if (sz.X <= 0 || sz.Y <= 0)
sz = new Vector2(1920, 1080);
return sz;
}
private void BuildUI()
{
var outer = CreateFillContainer();
AddChild(outer);
private void RefreshAll()
{
RepositionHandles();
controller.MarkDirty();
controller.UpdatePreview(previewMaterial, preview.GlobalPosition, PreviewSize());
UpdateLightList();
UpdateJsonEdit();
}
var toolbar = CreateToolbar();
outer.AddChild(toolbar);
_scenarioSelect = new OptionButton { CustomMinimumSize = new Vector2(420, 0), TooltipText = ToolUi.Tr("tools/light_editor/scenario_tip") };
_scenarioSelect.ItemSelected += OnScenarioPicked;
toolbar.AddChild(_scenarioSelect);
toolbar.AddChild(ToolUi.Button("tools/common/save", Save, ButtonKind.Primary, "tools/light_editor/save_tip"));
toolbar.AddChild(ToolUi.Button("tools/light_editor/copy_line", CopyLine, ButtonKind.Secondary, "tools/light_editor/copy_line_tip"));
toolbar.AddChild(UiKit.Spacer());
var lineToggle = ToolUi.Button("tools/light_editor/show_line", null, ButtonKind.Tab);
lineToggle.ToggleMode = true;
lineToggle.Toggled += on => _lineEdit.Visible = on;
toolbar.AddChild(lineToggle);
private void BuildUI()
{
var outerVBox = CreateFillContainer();
AddChild(outerVBox);
var main = new HSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
outer.AddChild(main);
var mainHBox = new HBoxContainer();
mainHBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
mainHBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
outerVBox.AddChild(mainHBox);
var side = new VBoxContainer();
main.AddChild(CreateSidebar(side, 300));
BuildSidebar(side);
var sidebarVBox = new VBoxContainer();
var sidebar = CreateSidebar(sidebarVBox);
mainHBox.AddChild(sidebar);
var canvasColumn = new VSplitContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
main.AddChild(canvasColumn);
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/background") });
var canvasPanel = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
canvasPanel.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusMd, UiTokens.Well, UiTokens.Line));
canvasColumn.AddChild(canvasPanel);
backgroundList = new ItemList();
backgroundList.CustomMinimumSize = new Vector2(0, 140);
backgroundList.ItemSelected += OnBackgroundSelected;
sidebarVBox.AddChild(backgroundList);
_canvas = new LightEditorCanvas
{
Controller = _controller,
PreviewMaterial = LightEditorController.CreatePreviewMaterial()
};
_canvas.Changed += OnCanvasChanged;
_canvas.SelectionChanged += RefreshAll;
canvasPanel.AddChild(_canvas);
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/static_lights") });
_lineEdit = ToolUi.Code(editable: false);
_lineEdit.CustomMinimumSize = new Vector2(0, 70);
_lineEdit.WrapMode = TextEdit.LineWrappingMode.Boundary;
_lineEdit.Visible = false;
canvasColumn.AddChild(_lineEdit);
}
lightListPanel = new VBoxContainer();
sidebarVBox.AddChild(lightListPanel);
private void BuildSidebar(VBoxContainer column)
{
column.AddChild(ToolUi.Section("tools/light_editor/static_lights"));
_lightList = new ItemList { CustomMinimumSize = new Vector2(0, 150) };
_lightList.ItemSelected += index => { _controller.Select((int)index); RefreshAll(); };
column.AddChild(_lightList);
addLightBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/add_light") };
addLightBtn.Pressed += OnAddLight;
sidebarVBox.AddChild(addLightBtn);
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space1);
column.AddChild(row);
_addBtn = ToolUi.Button("tools/light_editor/add_light", OnAdd);
_addBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(_addBtn);
_deleteBtn = ToolUi.Button("tools/light_editor/delete", OnDelete, ButtonKind.Danger);
_deleteBtn.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(_deleteBtn);
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/selected_props") });
column.AddChild(ToolUi.Section("tools/light_editor/selected_props"));
_propsEmpty = ToolUi.Hint(ToolUi.Tr("tools/light_editor/none_selected"));
column.AddChild(_propsEmpty);
_propsBody = new VBoxContainer();
_propsBody.AddThemeConstantOverride("separation", UiTokens.Space2);
column.AddChild(_propsBody);
var radiusHBox = new HBoxContainer();
radiusHBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/radius") });
radiusEdit = new LineEdit { Text = "300" };
radiusEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
radiusEdit.TextChanged += OnRadiusChanged;
radiusHBox.AddChild(radiusEdit);
sidebarVBox.AddChild(radiusHBox);
_x = ToolUi.Field(_propsBody, "tools/light_editor/x", ToolUi.Number(0, 1, 0.001, _ => OnFieldsChanged()));
_y = ToolUi.Field(_propsBody, "tools/light_editor/y", ToolUi.Number(0, 1, 0.001, _ => OnFieldsChanged()));
_radius = ToolUi.Field(_propsBody, "tools/light_editor/radius", ToolUi.Number(10, 3000, 10, _ => OnFieldsChanged()), "tools/light_editor/radius_tip");
_influence = ToolUi.Field(_propsBody, "tools/light_editor/influence", ToolUi.Number(0, 1, 0.05, _ => OnFieldsChanged()), "tools/light_editor/influence_tip");
_color = ToolUi.Field(_propsBody, "tools/light_editor/color", new ColorPickerButton { CustomMinimumSize = new Vector2(0, 28), EditAlpha = false });
_color.ColorChanged += _ => OnFieldsChanged();
var infHBox = new HBoxContainer();
infHBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/influence") });
influenceEdit = new LineEdit { Text = "1" };
influenceEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
influenceEdit.TextChanged += OnInfluenceChanged;
infHBox.AddChild(influenceEdit);
sidebarVBox.AddChild(infHBox);
column.AddChild(ToolUi.Section("tools/light_editor/background"));
_bgList = new FilteredList(180);
_bgList.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
_bgList.Chosen += ShowBackground;
column.AddChild(_bgList);
}
var colorHBox = new HBoxContainer();
colorHBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/color") });
colorPicker = new ColorPickerButton { Color = Colors.Yellow };
colorPicker.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
colorPicker.ColorChanged += OnColorChanged;
colorHBox.AddChild(colorPicker);
sidebarVBox.AddChild(colorHBox);
// ── loading ────────────────────────────────────────────────
var importBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/import_json") };
importBtn.Pressed += OnImport;
sidebarVBox.AddChild(importBtn);
private void LoadBackgrounds()
{
_backgroundIds.AddRange(ModResourceService.GetBackgroundIds());
_bgList.SetItems(_backgroundIds);
}
sidebarVBox.AddChild(new Label { Text = TranslationServer.Translate("tools/light_editor/norm_coords") });
/// <summary>Scenarios with a <c>light_set</c> line first (marked ●), then all others.</summary>
private void LoadScenarioList()
{
var withLights = new List<ScenarioFileInfo>();
var others = new List<ScenarioFileInfo>();
foreach (var file in ScenarioFileService.ScanScenarioFiles().SelectMany(m => m.Groups).SelectMany(g => g.Files))
{
var text = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath);
(text != null && LightEditorController.HasLights(text) ? withLights : others).Add(file);
}
var previewContainer = new PanelContainer();
previewContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
previewContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
mainHBox.AddChild(previewContainer);
_scenarios.Clear();
_scenarios.AddRange(withLights);
_scenarios.AddRange(others);
preview = new TextureRect();
preview.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
preview.StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered;
preview.MouseFilter = Control.MouseFilterEnum.Stop;
preview.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
preview.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
previewContainer.AddChild(preview);
_scenarioSelect.Clear();
_scenarioSelect.AddItem(ToolUi.Tr("tools/light_editor/pick_scenario"));
foreach (var file in _scenarios)
_scenarioSelect.AddItem((withLights.Contains(file) ? "● " : " ") + file.ModId + ": " + file.RelativePath);
_scenarioSelect.Select(0);
previewMaterial = controller.CreatePreviewMaterial();
preview.Material = previewMaterial;
if (withLights.Count > 0)
{
_scenarioSelect.Select(1);
OpenScenario(withLights[0]);
}
else
{
RefreshAll();
}
}
jsonEdit = new TextEdit();
jsonEdit.CustomMinimumSize = new Vector2(0, 70);
jsonEdit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
jsonEdit.WrapMode = TextEdit.LineWrappingMode.Boundary;
jsonEdit.TextChanged += OnJsonEditChanged;
outerVBox.AddChild(jsonEdit);
}
private void OnScenarioPicked(long index)
{
var file = index > 0 && index - 1 < _scenarios.Count ? _scenarios[(int)index - 1] : null;
RunDiscardingChanges(() => OpenScenario(file), () => _scenarioSelect.Select(_scenario == null ? 0 : _scenarios.IndexOf(_scenario) + 1));
}
private void OnJsonEditChanged()
{
}
private void OpenScenario(ScenarioFileInfo file)
{
_scenario = file;
_scenarioText = null;
MarkClean();
private void UpdateJsonEdit()
{
var json = controller.ExportLightsJson();
if (jsonEdit.Text != json)
jsonEdit.Text = json;
}
if (file != null)
{
_scenarioText = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath) ?? string.Empty;
try
{
var found = _controller.LoadFromScenario(_scenarioText);
SetStatus(string.Format(ToolUi.Tr(found ? "tools/light_editor/loaded" : "tools/light_editor/no_lights_yet"), file.RelativePath));
}
catch (Exception ex)
{
SetStatus($"{ToolUi.Tr("tools/common/load_error")}: {ex.Message}", ToolStatusKind.Error);
GameLogger.Log("LIGHT_EDITOR", $"light_set parse failed in '{file.Key}': {ex.Message}", LogLevel.Error);
}
private void LoadBackgroundList()
{
bgIds.Clear();
backgroundList.Clear();
var scene = ScenarioFileService.ParseMetadata(_scenarioText).Scene;
var background = SceneBackgroundService.ForScene(scene);
if (background != null && ModResourceService.LoadBackgroundTexture(background) == null)
SetStatus(string.Format(ToolUi.Tr("tools/common/background_missing"), background, scene), ToolStatusKind.Error);
else if (background != null)
ShowBackground(background);
}
else
{
_controller.LoadFromScenario(string.Empty);
}
var ids = ModResourceService.GetBackgroundIds();
bgIds.AddRange(ids);
foreach (var id in ids)
backgroundList.AddItem(id);
}
RefreshAll();
}
private void OnBackgroundSelected(long index)
{
var bgId = bgIds[(int)index];
var tex = BackgroundFactory.Instance.Get(bgId);
preview.Texture = tex;
controller.SetBackgroundForPreview(tex, previewMaterial);
/// <summary>Shows a background with its optional <c>_depth</c>/<c>_albedo</c> masks in the shader.</summary>
private void ShowBackground(string id)
{
if (string.IsNullOrEmpty(id))
return;
var depthTex = BackgroundFactory.Instance.Get(bgId + "_depth");
if (depthTex != null)
controller.SetDepthForPreview(depthTex, previewMaterial);
_bgList.Select(id);
_canvas.SetBackground(ModResourceService.LoadBackgroundTexture(id));
LightEditorController.SetPreviewTextures(_canvas.PreviewMaterial,
ModResourceService.LoadBackgroundTexture(id + "_depth"),
ModResourceService.LoadBackgroundTexture(id + "_albedo"));
_canvas.Refresh();
}
var albedoTex = BackgroundFactory.Instance.Get(bgId + "_albedo");
if (albedoTex != null)
controller.SetAlbedoForPreview(albedoTex, previewMaterial);
}
// ── saving ─────────────────────────────────────────────────
private void OnAddLight()
{
if (handles.Count >= 10)
return;
/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut() => Save();
var light = new LightDef
{
X = 0.4f,
Y = 0.3f,
Radius = 300f,
Influence = 1f,
ColorHex = "#ffff00"
};
private void Save()
{
if (_scenario == null)
{
SetStatus(ToolUi.Tr("tools/light_editor/pick_scenario"), ToolStatusKind.Error);
return;
}
controller.AddLight(light);
AddHandle(light, controller.Lights.Count - 1);
RefreshAll();
}
var updated = _controller.WriteToScenario(_scenarioText);
if (!ScenarioFileService.WriteAllText(_scenario.ModId, _scenario.RelativePath, updated))
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return;
}
private void AddHandle(LightDef light, int index)
{
var handle = new LightEditorHandle(light, index);
handle.DragUpdate += globalCenter =>
{
var localCenter = globalCenter - preview.GlobalPosition;
var currentSize = PreviewSize();
light.FromPixelPos(localCenter, currentSize);
RefreshAll();
};
handle.Clicked += () => SelectHandle(index);
preview.AddChild(handle);
handles.Add(handle);
RepositionHandle(handle, light);
}
_scenarioText = updated;
MarkClean();
SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), _scenario.ModId + "/" + _scenario.RelativePath), ToolStatusKind.Ok);
}
private void RepositionHandle(LightEditorHandle handle, LightDef light)
{
var size = PreviewSize();
handle.GlobalPosition = preview.GlobalPosition
+ new Vector2(light.X * size.X - 12, light.Y * size.Y - 12);
}
private void CopyLine()
{
DisplayServer.ClipboardSet(_controller.ToScenarioLine());
SetStatus(ToolUi.Tr("tools/light_editor/copied"), ToolStatusKind.Ok);
}
private void RepositionHandles()
{
for (int i = 0; i < handles.Count && i < controller.Lights.Count; i++)
RepositionHandle(handles[i], controller.Lights[i]);
}
// ── editing ────────────────────────────────────────────────
private void SelectHandle(int index)
{
selectedIndex = index;
if (index < 0 || index >= controller.Lights.Count)
{
radiusEdit.Text = "300";
influenceEdit.Text = "1";
colorPicker.Color = Colors.Yellow;
return;
}
private void OnAdd()
{
if (_controller.Add(new Vector2(0.5f, 0.5f)) == null)
{
SetStatus(string.Format(ToolUi.Tr("tools/light_editor/limit"), LightEditorController.MaxLights), ToolStatusKind.Error);
return;
}
var light = controller.Lights[index];
radiusEdit.Text = light.Radius.ToString("F0");
influenceEdit.Text = light.Influence.ToString("F2");
colorPicker.Color = light.ToColor();
}
SetDirty();
RefreshAll();
}
private void OnRadiusChanged(string text)
{
if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count)
return;
if (float.TryParse(text, out var val))
{
controller.Lights[selectedIndex].Radius = val;
RefreshAll();
}
}
private void OnDelete()
{
if (_controller.Selected == null)
return;
private void OnInfluenceChanged(string text)
{
if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count)
return;
if (float.TryParse(text, out var val))
{
controller.Lights[selectedIndex].Influence = val;
RefreshAll();
}
}
_controller.RemoveSelected();
SetDirty();
RefreshAll();
}
private void OnColorChanged(Color color)
{
if (selectedIndex < 0 || selectedIndex >= controller.Lights.Count)
return;
controller.Lights[selectedIndex].ColorHex = color.ToHtml();
RefreshAll();
}
/// <summary>Delete removes the selected light.</summary>
protected override bool HandleKey(InputEventKey key)
{
if (key.Keycode != Key.Delete || _controller.Selected == null)
return false;
OnDelete();
return true;
}
private void UpdateLightList()
{
foreach (var child in lightListPanel.GetChildren())
child.QueueFree();
private void OnFieldsChanged()
{
var light = _controller.Selected;
if (_suppress || light == null)
return;
for (int i = 0; i < controller.Lights.Count; i++)
{
var light = controller.Lights[i];
var hBox = new HBoxContainer();
var label = new Label
{
Text = string.Format(TranslationServer.Translate("tools/light_editor/light_n"), i + 1, light.X, light.Y),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill
};
hBox.AddChild(label);
light.X = (float)_x.Value;
light.Y = (float)_y.Value;
light.Radius = (float)_radius.Value;
light.Influence = (float)_influence.Value;
light.ColorHex = "#" + _color.Color.ToHtml(false);
SetDirty();
RefreshList();
_lineEdit.Text = _controller.ToScenarioLine();
_canvas.RebuildHandles();
}
var selBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/btn_select") };
int idx = i;
selBtn.Pressed += () => SelectHandle(idx);
hBox.AddChild(selBtn);
private void OnCanvasChanged()
{
SetDirty();
_suppress = true;
WriteProperties();
_suppress = false;
RefreshList();
_lineEdit.Text = _controller.ToScenarioLine();
}
var delBtn = new Button { Text = TranslationServer.Translate("tools/light_editor/btn_delete") };
delBtn.Pressed += () => RemoveLight(idx);
hBox.AddChild(delBtn);
// ── refresh ────────────────────────────────────────────────
lightListPanel.AddChild(hBox);
}
private void RefreshAll()
{
RefreshList();
_suppress = true;
WriteProperties();
_suppress = false;
_lineEdit.Text = _controller.ToScenarioLine();
_addBtn.Disabled = _controller.Lights.Count >= LightEditorController.MaxLights;
_deleteBtn.Disabled = _controller.Selected == null;
_canvas.RebuildHandles();
}
addLightBtn.Disabled = controller.Lights.Count >= 10;
}
private void RefreshList()
{
_lightList.Clear();
for (var i = 0; i < _controller.Lights.Count; i++)
{
var light = _controller.Lights[i];
var index = _lightList.AddItem($"● {i + 1}. {light.X:0.000}, {light.Y:0.000} r {light.Radius:0}");
_lightList.SetItemCustomFgColor(index, light.ToColor());
}
private void RemoveLight(int index)
{
if (index < 0 || index >= controller.Lights.Count)
return;
if (_controller.SelectedIndex >= 0)
_lightList.Select(_controller.SelectedIndex);
}
controller.RemoveLight(index);
private void WriteProperties()
{
var light = _controller.Selected;
_propsEmpty.Visible = light == null;
_propsBody.Visible = light != null;
if (light == null)
return;
if (index < handles.Count)
{
handles[index].QueueFree();
handles.RemoveAt(index);
}
for (int i = 0; i < handles.Count; i++)
handles[i].LightIndex = i;
if (selectedIndex == index)
selectedIndex = -1;
else if (selectedIndex > index)
selectedIndex--;
RefreshAll();
}
private void OnImport()
{
var json = jsonEdit.Text;
if (string.IsNullOrWhiteSpace(json))
return;
try
{
var lights = JsonSerializer.Deserialize<List<LightDef>>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
if (lights == null || lights.Count == 0)
return;
foreach (var h in handles)
h.QueueFree();
handles.Clear();
controller.Clear();
foreach (var light in lights)
{
controller.AddLight(light);
AddHandle(light, controller.Lights.Count - 1);
}
selectedIndex = -1;
RefreshAll();
}
catch (Exception ex)
{
GameLogger.Log("LIGHT_EDITOR", $"Import JSON failed: {ex.Message}", LogLevel.Error);
}
}
}
_x.Value = light.X;
_y.Value = light.Y;
_radius.Value = light.Radius;
_influence.Value = light.Influence;
_color.Color = light.ToColor();
}
}
}
+129 -160
View File
@@ -2,39 +2,35 @@ using System;
using System.Collections.Generic;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.UI.Tool;
using Cthangover.Mods.Map;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Tools.MapEditor
{
/// <summary>
/// Interactive preview of the edited map: draws the background with the
/// exact aspect-preserving layout used by <c>MapWidget</c>, overlays every
/// zone polygon, marker and text label, and hosts the draggable handles.
/// Implements the two editor modes:
/// Interactive preview of the edited map: the background (laid out by
/// <see cref="BackgroundCanvas"/> exactly like <c>MapWidget</c>), every zone
/// polygon, marker and text label, and the draggable handles of the
/// selected zone. Two modes:
/// <list type="bullet">
/// <item><b>select</b> — clicking picks the topmost zone (polygon first,
/// <item><b>select</b> — a click picks the topmost zone (polygon first,
/// then marker/label dots); handles move vertices and positions;</item>
/// <item><b>draw</b> — clicking appends polygon vertices to the selected
/// region and double-click finishes the polygon and returns to select
/// mode.</item>
/// <item><b>draw</b> — clicks append polygon vertices to the selected
/// region while a dashed rubber band shows the next edge; a double-click
/// (or Enter/Esc via the window) finishes the polygon.</item>
/// </list>
/// Right-click deletes a polygon vertex in either mode: the vertex nearest
/// to the pointer. While drawing, a click that misses every vertex removes
/// the last one ("undo point"); in select mode only a targeted vertex is
/// deleted.
/// The canvas owns no data: it reads and mutates
/// <see cref="MapEditorController"/> and raises <see cref="Changed"/> so the
/// window can refresh the sidebar and JSON preview. Drawing is connected
/// through the "draw" signal and input through the GuiInput event, because
/// virtual method overrides are unreliable in mod assemblies.
/// Right-click deletes the polygon vertex nearest to the pointer; while
/// drawing, a click that misses every vertex removes the last one ("undo
/// point"), in select mode only a targeted vertex is deleted.
/// <para/>
/// Zone fills and outlines are painted through <see cref="MapZoneStyle"/> —
/// the same definition the game's <c>MapZonesLayer</c> and the window's
/// game-like preview use — so a zone the author sees here and the same zone
/// on the live map are guaranteed to look identical.
/// The canvas owns no data: it reads and mutates
/// <see cref="MapEditorController"/> and raises <see cref="Changed"/> so
/// the window can refresh the sidebar and JSON. Zone fills and outlines are
/// painted through <see cref="MapZoneStyle"/> — the definition the game's
/// <c>MapZonesLayer</c> uses — so the editor and the live map look the same.
/// </summary>
public partial class MapEditorCanvas : Control
public partial class MapEditorCanvas : BackgroundCanvas
{
/// <summary>Mode constant: clicking selects zones and drags handles.</summary>
public const string ModeSelect = "select";
@@ -42,129 +38,97 @@ namespace Cthangover.Tools.MapEditor
/// <summary>Mode constant: clicking appends polygon vertices.</summary>
public const string ModeDraw = "draw";
/// <summary>Handle index of the marker position.</summary>
public const int MarkerHandle = -2;
/// <summary>Handle index of the label position.</summary>
public const int LabelHandle = -3;
private const float PickRadius = 24f;
private static readonly Color VertexColor = MapZoneStyle.VertexHandle;
private static readonly Color MarkerColor = MapZoneStyle.MarkerHandle;
private static readonly Color LabelColor = MapZoneStyle.LabelHandle;
private readonly List<MapEditorHandle> _handles = new();
/// <summary>Data being edited; set once by the window.</summary>
public MapEditorController Controller;
/// <summary>Reference background; <c>null</c> falls back to a flat rectangle.</summary>
public Texture2D Background;
/// <summary>Current interaction mode, one of <see cref="ModeSelect"/> / <see cref="ModeDraw"/>.</summary>
public string Mode { get; private set; } = ModeSelect;
/// <summary>Raised whenever the canvas mutates controller data (drag, draw, pick).</summary>
/// <summary>Raised whenever the canvas mutates controller data (drag, draw, delete).</summary>
public event Action Changed;
/// <summary>Raised with the newly picked zone index (-1 for empty space).</summary>
public event Action<int> ZonePicked;
/// <summary>Raised when the canvas leaves draw mode on its own (double-click finishing a polygon).</summary>
public event Action ModeChanged;
private Vector2? _pointer;
public MapEditorCanvas()
{
MouseFilter = MouseFilterEnum.Stop;
ClipContents = true;
MapZoneStyle.Prepare(this);
GuiInput += OnGuiInput;
MouseExited += OnMouseExited;
Resized += OnResized;
Connect("draw", Callable.From((Action)DrawAll));
}
/// <summary>Switches the interaction mode and refreshes the handles.</summary>
public void SetMode(string mode)
{
Mode = mode == ModeDraw ? ModeDraw : ModeSelect;
MouseDefaultCursorShape = Mode == ModeDraw ? CursorShape.Cross : CursorShape.Arrow;
RebuildHandles();
QueueRedraw();
}
/// <summary>Assigns the background texture and rebuilds the layout.</summary>
public void SetBackground(Texture2D texture)
/// <summary>Leaves draw mode (double-click, Enter, Esc) and tells the window.</summary>
public void FinishDrawing()
{
Background = texture;
RebuildHandles();
QueueRedraw();
if (Mode != ModeDraw)
return;
SetMode(ModeSelect);
_pointer = null;
ModeChanged?.Invoke();
}
/// <summary>Recreates the handles from the current selection. Call after structural changes.</summary>
public void RebuildHandles()
public override void RebuildHandles()
{
foreach (var handle in _handles)
handle.QueueFree();
_handles.Clear();
ClearHandles();
var zone = Controller?.Selected;
if (zone == null)
return;
var rect = GetBackgroundRect();
if (rect.Size.X <= 0f || rect.Size.Y <= 0f)
if (zone == null || GetBackgroundRect().Size.X <= 0f)
return;
if (zone.Points != null)
{
for (var i = 0; i < zone.Points.Count; i++)
AddHandle(rect, zone.Points[i], VertexColor, i);
{
var index = i;
var handle = AddHandle(zone.Points[i], VertexColor, i, p => OnHandleDrag(index, p));
handle.RightClicked = () => RemoveVertexAt(index);
handle.DragEnd = () => Changed?.Invoke();
}
}
if (HasMarker(zone))
AddHandle(rect, zone.ImagePos, MarkerColor, MapEditorHandle.MarkerHandle);
AddHandle(zone.ImagePos, MarkerColor, MarkerHandle, p => OnHandleDrag(MarkerHandle, p), 18f).DragEnd = () => Changed?.Invoke();
if (zone.LabelPos.HasValue)
AddHandle(rect, zone.LabelPos.Value, LabelColor, MapEditorHandle.LabelHandle);
AddHandle(zone.LabelPos.Value, LabelColor, LabelHandle, p => OnHandleDrag(LabelHandle, p), 18f).DragEnd = () => Changed?.Invoke();
}
/// <summary>
/// Aspect-preserving rectangle the background occupies inside the
/// canvas. Must stay identical to the runtime layout: the saved
/// normalised coordinates mean nothing if the editor and the game
/// disagree about this rectangle.
/// </summary>
public Rect2 GetBackgroundRect()
{
var controlSize = Size;
if (controlSize.X <= 0f || controlSize.Y <= 0f)
return new Rect2();
var textureSize = Background?.GetSize() ?? new Vector2(1920, 1080);
if (textureSize.X <= 0f || textureSize.Y <= 0f)
textureSize = new Vector2(1920, 1080);
var scale = Mathf.Min(controlSize.X / textureSize.X, controlSize.Y / textureSize.Y);
var size = textureSize * scale;
return new Rect2((controlSize - size) / 2f, size);
}
private void AddHandle(Rect2 rect, Vector2 normalized, Color color, int index)
{
var handle = new MapEditorHandle(color, index);
handle.DragUpdate = center => OnHandleDrag(index, center);
handle.DragEnd = () => Changed?.Invoke();
handle.RightClicked = () => RemoveVertexHandle(index);
AddChild(handle);
handle.GlobalPosition = GlobalPosition + rect.Position + Clamp01(normalized) * rect.Size - handle.Size / 2f;
_handles.Add(handle);
}
private void OnHandleDrag(int index, Vector2 globalCenter)
private void OnHandleDrag(int index, Vector2 normalized)
{
if (Controller == null)
return;
var rect = GetBackgroundRect();
if (rect.Size.X <= 0f || rect.Size.Y <= 0f)
return;
var local = globalCenter - GlobalPosition - rect.Position;
var normalized = Clamp01(new Vector2(local.X / rect.Size.X, local.Y / rect.Size.Y));
if (index == MapEditorHandle.MarkerHandle)
if (index == MarkerHandle)
Controller.SetImagePos(normalized);
else if (index == MapEditorHandle.LabelHandle)
else if (index == LabelHandle)
Controller.SetLabelPos(normalized);
else
Controller.SetVertex(index, normalized);
@@ -175,19 +139,39 @@ namespace Cthangover.Tools.MapEditor
private void OnGuiInput(InputEvent @event)
{
if (Controller == null || @event is not InputEventMouseButton mb || !mb.Pressed)
if (Controller == null)
return;
if (@event is InputEventMouseMotion motion)
{
if (Mode == ModeDraw)
{
_pointer = motion.Position;
QueueRedraw();
}
return;
}
if (@event is not InputEventMouseButton mb || !mb.Pressed)
return;
if (mb.ButtonIndex == MouseButton.Left)
{
if (Mode == ModeDraw)
{
Controller.AddVertex(Clamp01(PixelToNorm(mb.Position)));
// The first click of a double-click already placed the
// vertex; the double-click itself only finishes the polygon.
if (mb.DoubleClick)
Mode = ModeSelect;
RebuildHandles();
Changed?.Invoke();
QueueRedraw();
{
FinishDrawing();
}
else
{
Controller.AddVertex(Clamp01(LocalToNorm(mb.Position)));
RebuildHandles();
Changed?.Invoke();
QueueRedraw();
}
}
else
{
@@ -197,7 +181,6 @@ namespace Cthangover.Tools.MapEditor
Controller.Select(hit);
RebuildHandles();
ZonePicked?.Invoke(hit);
Changed?.Invoke();
QueueRedraw();
}
}
@@ -213,12 +196,7 @@ namespace Cthangover.Tools.MapEditor
private void OnMouseExited()
{
QueueRedraw();
}
private void OnResized()
{
RebuildHandles();
_pointer = null;
QueueRedraw();
}
@@ -238,41 +216,51 @@ 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)
{
if (zone.Points != null && zone.Points.Count >= 2)
MapZoneStyle.Draw(this, ToPixels(zone.Points, rect), selected, false, zone.Locked);
{
var locked = MapZoneRules.IsLocked(zone, !zone.Locked);
MapZoneStyle.Draw(this, ToPixels(zone.Points, rect), selected, false, locked, zone.Hidden);
}
if (HasMarker(zone))
DrawMarker(zone, ToPixel(zone.ImagePos, rect), selected);
@@ -337,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)
{
@@ -383,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);
@@ -395,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++)
{
@@ -409,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();
}
}
}
+17 -1
View File
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.UI.Tool;
using Cthangover.Mods.Map;
using Cthangover.Tools.Services;
using Godot;
@@ -70,6 +70,22 @@ namespace Cthangover.Tools.MapEditor
return Append(zone);
}
/// <summary>
/// Creates a region without vertices and selects it — the start of
/// drawing a polygon point by point on the canvas.
/// </summary>
public MapZone StartRegion()
{
var zone = new MapZone
{
ID = MakeUniqueId("region"),
Title = string.Empty,
Points = new List<Vector2>(),
};
return Append(zone);
}
/// <summary>
/// Creates an icon marker at the map centre with no icon chosen yet and
/// selects it. The author assigns <c>image</c> and <c>scene</c> in the
-85
View File
@@ -1,85 +0,0 @@
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.MapEditor
{
/// <summary>
/// Draggable 16x16 handle on the map editor canvas. One handle represents
/// either a polygon vertex, the marker position or the label position,
/// distinguished by <see cref="Index"/> with negative values reserved for
/// the position handles. Subscribes to the GuiInput C# event rather than
/// overriding <c>_GuiInput</c> for reliable event delivery in mod
/// assemblies, following <c>InteractiveEditorHandle</c>.
/// </summary>
public partial class MapEditorHandle : ColorRect
{
/// <summary>Handle role: 0..n — vertex index, <see cref="MarkerHandle"/> or <see cref="LabelHandle"/>.</summary>
public const int MarkerHandle = -2;
/// <summary>Handle role used for the label position handle.</summary>
public const int LabelHandle = -3;
/// <summary>Vertex index or one of the negative role constants.</summary>
public int Index;
/// <summary>Invoked during drag with the handle centre in global coordinates.</summary>
public System.Action<Vector2> DragUpdate;
/// <summary>Invoked when the handle is clicked (mouse down).</summary>
public System.Action Clicked;
/// <summary>Invoked when the drag ends (mouse up).</summary>
public System.Action DragEnd;
/// <summary>
/// Invoked on a right-click over the handle. Handles stop mouse input,
/// so without this hook a right-click aimed exactly at a vertex would
/// never reach the canvas and the vertex could not be deleted there.
/// </summary>
public System.Action RightClicked;
private bool _dragging;
private Vector2 _dragOffset;
/// <summary>Creates a handle with the given colour and role index.</summary>
public MapEditorHandle(Color color, int index)
{
Index = index;
Size = new Vector2(16, 16);
Color = color;
MouseFilter = MouseFilterEnum.Stop;
MouseDefaultCursorShape = CursorShape.PointingHand;
GuiInput += OnGuiInput;
}
private void OnGuiInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb && mb.ButtonIndex == MouseButton.Left)
{
if (mb.Pressed)
{
_dragging = true;
_dragOffset = GetGlobalMousePosition() - GlobalPosition;
Clicked?.Invoke();
}
else
{
_dragging = false;
DragEnd?.Invoke();
}
AcceptEvent();
}
else if (@event is InputEventMouseButton right && right.ButtonIndex == MouseButton.Right && right.Pressed)
{
RightClicked?.Invoke();
AcceptEvent();
}
else if (@event is InputEventMouseMotion && _dragging)
{
var center = GlobalPosition + Size / 2;
GlobalPosition = GetGlobalMousePosition() - _dragOffset;
DragUpdate?.Invoke(center);
}
}
}
}
+1
View File
@@ -1,6 +1,7 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Tools.MapEditor
{
/// <summary>
File diff suppressed because it is too large. Load diff
+31 -30
View File
@@ -2,36 +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
{
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 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;
@@ -48,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;
@@ -63,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);
@@ -79,6 +72,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
AddChild(_graphView);
}
/// <summary>Opens the scenario in a new tab, or just activates its existing tab.</summary>
public void OpenFile(string key)
{
for (int i = 0; i < _tabs.Count; i++)
@@ -108,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);
}
@@ -123,13 +118,14 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
}
/// <summary>Marks the active tab clean, remembering the editor text as the saved baseline.</summary>
public void SaveCurrentFile()
{
if (_activeTabIndex < 0 || _activeTabIndex >= _tabs.Count)
return;
var tab = _tabs[_activeTabIndex];
tab.SavedText = _textEdit.Text;
tab.Buffer = _textEdit.Text;
if (tab.IsDirty)
{
tab.IsDirty = false;
@@ -138,6 +134,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
}
/// <summary>Replaces the editor text without firing the text-changed handler.</summary>
public void SetTextSilent(string text)
{
_suppressTextChanged = true;
@@ -145,6 +142,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_suppressTextChanged = false;
}
/// <summary>Switches to the graph tab when no file tabs are open.</summary>
public void SetViewToGraph()
{
if (_tabs.Count > 0)
@@ -156,6 +154,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
GraphTabSelected?.Invoke();
}
/// <summary>Rebuilds the graph from the current scenario files, if the graph tab is visible.</summary>
public void RefreshGraph()
{
if (_graphView.Visible)
@@ -165,6 +164,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
}
/// <summary>Returns false when any tab still has unsaved changes.</summary>
public bool ConfirmCloseAll()
{
for (int i = _tabs.Count - 1; i >= 0; i--)
@@ -206,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)
@@ -284,6 +284,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
UpdateTabTitle(_activeTabIndex);
UpdateGlobalDirty();
}
BufferChanged?.Invoke();
}
private void UpdateTabTitle(int index)
@@ -303,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)
@@ -1,6 +1,7 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Registers the scenario editor as a tool window. Creates <see cref="ScenarioEditorWindow"/> instances.</summary>
+139 -130
View File
@@ -1,46 +1,59 @@
using System;
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;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// 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 string _returnRoute;
private string _returnScene;
private CanvasLayer _stopOverlay;
public ScenarioEditorWindow() : base("tools/scenario_editor/title")
{
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>
public override void _Ready()
{
_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)
{
if (_playActive && !SceneManager.IsTestPlayActive)
@@ -52,8 +65,7 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_editLayout = new Control
{
AnchorRight = 1f,
AnchorBottom = 1f,
Visible = true
AnchorBottom = 1f
};
AddChild(_editLayout);
@@ -63,111 +75,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)
@@ -180,15 +151,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);
}
@@ -196,26 +171,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()
@@ -230,22 +217,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);
}
}
@@ -257,14 +242,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()
@@ -277,7 +264,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;
}
@@ -291,7 +278,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);
@@ -299,14 +285,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;
@@ -314,18 +296,32 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
Visible = false;
// The screen the editor was opened from (the novel_editor hub, a
// mod menu) is freed by the shell load below; remember its route so
// the play-test returns there instead of to the engine's main menu.
var sceneService = GetNodeOrNull<GodotSceneService>("/root/GodotSceneService");
var current = sceneService?.CurrentGodotSceneName;
_returnRoute = !string.IsNullOrEmpty(current) && SceneRouteRegistry.TryGet(current, out _) ? current : null;
// Opened inside a running game: come back into the scene the
// player was in, not to the main menu (there is no way back to the
// editor from there).
var sceneManager = GetNodeOrNull<SceneManager>("/root/SceneManager");
var gameScene = sceneManager?.CurrentSceneName;
_returnScene = _returnRoute == null && current != "mainmenu" && !string.IsNullOrEmpty(gameScene) && sceneManager.HasScene(gameScene)
? gameScene
: null;
_stopOverlay = CreateStopOverlay();
var tree = Godot.Engine.GetMainLoop() as SceneTree;
tree?.Root.AddChild(_stopOverlay);
var sceneManager = GetNodeOrNull<SceneManager>("/root/SceneManager");
if (sceneManager != null)
{
sceneManager.Initialize();
sceneManager.PendingSceneName = metadata.Scene;
}
var sceneService = GetNodeOrNull<GodotSceneService>("/root/GodotSceneService");
sceneService?.LoadShell();
_playActive = true;
@@ -340,8 +336,6 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
private void ReturnFromPlay()
{
_playActive = false;
_playLayout.Visible = false;
_editLayout.Visible = true;
Visible = true;
if (_stopOverlay != null)
@@ -351,23 +345,38 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
}
var sceneService = GetNodeOrNull<GodotSceneService>("/root/GodotSceneService");
sceneService?.SwitchToMenu();
var sceneManager = GetNodeOrNull<SceneManager>("/root/SceneManager");
if (_returnRoute != null && SceneRouteRegistry.TryGet(_returnRoute, out var route))
{
sceneService?.SwitchRoute(route);
}
else if (_returnScene != null && sceneManager != null)
{
sceneManager.PendingSceneName = _returnScene;
sceneService?.LoadShell();
}
else
{
sceneService?.SwitchToMenu();
}
_returnRoute = null;
_returnScene = null;
SetStatus(ToolUi.Tr("tools/scenario_editor/play_stopped"));
}
/// <summary>Stop button shown over the running game while the scenario is play-tested.</summary>
private CanvasLayer CreateStopOverlay()
{
var layer = new CanvasLayer { Layer = 128 };
var btn = new Button
{
Text = TranslationServer.Translate("tools/scenario_editor/stop"),
CustomMinimumSize = new Vector2(120, 40)
};
btn.Pressed += OnStopPressed;
var btn = ToolUi.Button("tools/scenario_editor/stop", OnStopPressed, ButtonKind.Danger);
btn.Theme = ToolTheme.Get();
btn.CustomMinimumSize = new Vector2(120, 36);
var margin = new MarginContainer();
margin.AddThemeConstantOverride("margin_top", 8);
margin.AddThemeConstantOverride("margin_left", 8);
margin.AddThemeConstantOverride("margin_top", UiTokens.Space3);
margin.AddThemeConstantOverride("margin_left", UiTokens.Space3);
margin.AddChild(btn);
layer.AddChild(margin);
+100 -49
View File
@@ -6,80 +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, with a search field that keeps only matching files (and
/// expands their groups). Raises <see cref="FileSelected"/> with the
/// scenario key on picks.
/// <para/>
/// Built from <c>scenario_file_tree_panel.tscn</c>: the tscn builder creates
/// the script node through its constructor before the child nodes exist,
/// so children are looked up lazily (<see cref="Bind"/>) — not in the
/// constructor, which is what used to crash the whole editor on open.
/// </summary>
public partial class ScenarioFileTreePanel : VBoxContainer
{
private Tree _fileTree;
private readonly Dictionary<string, string> _fileItemToKey = new();
private readonly Dictionary<string, TreeItem> _keyToTreeItem = new();
private LineEdit _search;
private List<ModScenarioTree> _mods = new();
private readonly Dictionary<TreeItem, string> _itemToKey = new();
private readonly Dictionary<string, TreeItem> _keyToItem = new();
private bool _isProgrammaticSelection;
private string _selectedKey;
public event Action<string> FileSelected;
public ScenarioFileTreePanel()
/// <summary>Binds child nodes and fills the tree once the panel enters the tree.</summary>
public override void _Ready()
{
Reload();
}
/// <summary>Re-scans the mods' scenario files and rebuilds the tree.</summary>
public void Reload()
{
Bind();
_mods = ScenarioFileService.ScanScenarioFiles();
Populate();
}
private void Bind()
{
if (_fileTree != null)
return;
_fileTree = GetNode<Tree>("FileTree");
_fileTree.CellSelected += OnCellSelected;
Populate();
_search = GetNode<LineEdit>("Search");
_search.PlaceholderText = TranslationServer.Translate("tools/scenario_editor/search_files");
_search.TextChanged += _ => Populate();
}
private void Populate()
{
_fileTree.Clear();
_fileItemToKey.Clear();
_keyToTreeItem.Clear();
_itemToKey.Clear();
_keyToItem.Clear();
var filter = _search.Text.Trim();
var root = _fileTree.CreateItem();
var mods = ScenarioFileService.ScanScenarioFiles();
foreach (var mod in mods)
foreach (var mod in _mods)
{
var modItem = _fileTree.CreateItem(root);
modItem.SetText(0, $"[{mod.ModId}]");
modItem.SetSelectable(0, false);
modItem.SetCustomColor(0, UiTokens.Info);
TreeItem modItem = null;
foreach (var group in mod.Groups)
{
TreeItem parentItem;
if (string.IsNullOrEmpty(group.DirectoryPath))
{
parentItem = modItem;
}
else
{
parentItem = _fileTree.CreateItem(modItem);
parentItem.SetText(0, group.DirectoryPath + "/");
parentItem.SetSelectable(0, false);
}
TreeItem groupItem = null;
foreach (var file in group.Files)
{
var fileItem = _fileTree.CreateItem(parentItem);
if (filter.Length > 0 && file.RelativePath.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
continue;
if (modItem == null)
{
modItem = _fileTree.CreateItem(root);
modItem.SetText(0, mod.ModId);
modItem.SetSelectable(0, false);
modItem.SetCustomColor(0, UiTokens.AccentBright);
}
if (groupItem == null)
{
if (string.IsNullOrEmpty(group.DirectoryPath))
{
groupItem = modItem;
}
else
{
groupItem = _fileTree.CreateItem(modItem);
groupItem.SetText(0, group.DirectoryPath + "/");
groupItem.SetSelectable(0, false);
groupItem.SetCustomColor(0, UiTokens.Muted);
groupItem.Collapsed = filter.Length == 0;
}
}
var fileItem = _fileTree.CreateItem(groupItem);
fileItem.SetText(0, file.Name);
fileItem.SetMetadata(0, Variant.From("scenario"));
_fileItemToKey[GetItemKey(fileItem)] = file.Key;
_keyToTreeItem[file.Key] = fileItem;
fileItem.SetTooltipText(0, ScenarioFileService.GetDisplayPath(file.ModId, file.RelativePath));
_itemToKey[fileItem] = file.Key;
_keyToItem[file.Key] = fileItem;
}
}
}
if (_selectedKey != null && _keyToItem.TryGetValue(_selectedKey, out var selected))
SelectSilently(selected);
}
private static string GetItemKey(TreeItem item)
{
return item.GetInstanceId().ToString();
}
/// <summary>Selects the tree row of a scenario key and raises <see cref="FileSelected"/>.</summary>
public void SelectFile(string key)
{
if (!_keyToTreeItem.TryGetValue(key, out var item))
return;
Bind();
_selectedKey = key;
if (!_keyToItem.TryGetValue(key, out var item))
{
_search.Text = string.Empty;
Populate();
if (!_keyToItem.TryGetValue(key, out item))
return;
}
SelectSilently(item);
FileSelected?.Invoke(key);
}
private void SelectSilently(TreeItem item)
{
for (var parent = item.GetParent(); parent != null; parent = parent.GetParent())
parent.Collapsed = false;
_isProgrammaticSelection = true;
item.Select(0);
_fileTree.ScrollToItem(item);
_isProgrammaticSelection = false;
FileSelected?.Invoke(key);
}
private void OnCellSelected()
@@ -88,18 +146,11 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
return;
var item = _fileTree.GetSelected();
if (item == null)
if (item == null || !_itemToKey.TryGetValue(item, out var key))
return;
var meta = item.GetMetadata(0);
if (meta.VariantType != Variant.Type.String || meta.AsString() != "scenario")
return;
var itemKey = GetItemKey(item);
if (!_fileItemToKey.TryGetValue(itemKey, out var fileKey))
return;
FileSelected?.Invoke(fileKey);
_selectedKey = key;
FileSelected?.Invoke(key);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// Layered left-to-right layout of the scene graph: a scene's column is
/// its distance (in scene switches) from the start scenes — the
/// <c>start_scene</c> node, or every scene nothing switches to when there
/// is none — so the story reads from left to right. Scenes no start
/// reaches are stacked in one extra column on the right. Inside a column
/// scenes are sorted by name, which keeps the picture stable between
/// rebuilds.
/// </summary>
public static class ScenarioGraphLayout
{
private const float ColumnWidth = 300f;
private const float RowHeight = 130f;
private const float Margin = 40f;
/// <summary>Canvas position per lower-cased scene name.</summary>
public static Dictionary<string, Vector2> Compute(SceneGraphData data)
{
var names = data.Nodes.Select(n => n.SceneName.ToLowerInvariant()).Distinct().ToList();
var outgoing = names.ToDictionary(n => n, _ => new List<string>());
var incoming = new HashSet<string>();
foreach (var edge in data.Edges)
{
var from = edge.FromScene.ToLowerInvariant();
var to = edge.ToScene.ToLowerInvariant();
if (!outgoing.ContainsKey(from) || !outgoing.ContainsKey(to) || from == to)
continue;
outgoing[from].Add(to);
incoming.Add(to);
}
var starts = data.Nodes.Where(n => n.IsStartScene).Select(n => n.SceneName.ToLowerInvariant()).ToList();
if (starts.Count == 0)
starts = names.Where(n => !incoming.Contains(n)).ToList();
var depth = new Dictionary<string, int>();
var queue = new Queue<string>();
foreach (var start in starts)
{
depth[start] = 0;
queue.Enqueue(start);
}
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var next in outgoing[current])
{
if (depth.ContainsKey(next))
continue;
depth[next] = depth[current] + 1;
queue.Enqueue(next);
}
}
var unreachableColumn = depth.Count > 0 ? depth.Values.Max() + 1 : 0;
var result = new Dictionary<string, Vector2>();
foreach (var column in names.GroupBy(n => depth.TryGetValue(n, out var d) ? d : unreachableColumn).OrderBy(g => g.Key))
{
var row = 0;
foreach (var name in column.OrderBy(n => n, StringComparer.Ordinal))
result[name] = new Vector2(Margin + column.Key * ColumnWidth, Margin + row++ * RowHeight);
}
return result;
}
}
}
+26 -29
View File
@@ -5,8 +5,10 @@ using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Services;
using Godot;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Cross-scene graph of all scenario files: a GraphEdit of scene nodes linked by switch targets, with a detail panel for the selected scenario.</summary>
public partial class ScenarioGraphView : HSplitContainer
{
private GraphEdit _graph;
@@ -39,27 +41,27 @@ 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);
}
/// <summary>Rebuilds the graph nodes and edges, preserving the nodes' saved canvas positions.</summary>
public void Populate(SceneGraphData data)
{
var savedPositions = SaveNodePositions();
@@ -68,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)
{
@@ -87,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
{
@@ -96,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)
@@ -120,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)
@@ -135,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()
@@ -178,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))
{
@@ -199,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());
+53 -14
View File
@@ -1,9 +1,12 @@
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
{
/// <summary>Read-only metadata panel for the scenario in the active text tab: scene, priority, condition, switch targets, locale keys and quest references.</summary>
public partial class ScenarioInfoPanel : VBoxContainer
{
private Label _infoScene;
@@ -13,25 +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") + ":";
_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");
Bind();
}
/// <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();
@@ -49,8 +86,10 @@ namespace Cthangover.Core.UI.Tool.ScenarioEditor
_infoQuestRefs.Text = refs.QuestRefs.Count > 0 ? string.Join(", ", refs.QuestRefs.Distinct()) : "-";
}
/// <summary>Resets every metadata row to the "-" placeholder.</summary>
public void Clear()
{
Bind();
_infoScene.Text = "-";
_infoPriority.Text = "-";
_infoCondition.Text = "-";
+13 -9
View File
@@ -4,15 +4,18 @@ using Godot;
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>Row of background thumbnails referenced by the scenario in the active text tab.</summary>
public partial class ScenarioThumbnailPanel : ScrollContainer
{
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)
{
Clear();
@@ -24,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")
});
@@ -37,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,
@@ -48,18 +51,19 @@ 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)
});
}
}
}
/// <summary>Removes every thumbnail from the row.</summary>
public new void Clear()
{
foreach (var c in _thumbContainer.GetChildren())
foreach (var c in Thumbs.GetChildren())
c.QueueFree();
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Cthangover.Core.UI.Tool.ScenarioEditor
{
/// <summary>
/// State of one open scenario file tab: identity, the current text buffer
/// (including unsaved edits while the tab is in the background) and the
/// dirty flag.
/// </summary>
public sealed class TabData
{
public string Key;
public string ModId;
public string RelativePath;
public string FileName;
public string Buffer;
public bool IsDirty;
}
}
+1
View File
@@ -10,6 +10,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private string _key;
/// <summary>Binds the checkbox to the property key and seeds it with the current value.</summary>
public void Setup(InspectableProperty property, bool value)
{
_key = property.Key;
+1
View File
@@ -10,6 +10,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private string _key;
/// <summary>Binds the picker to the property key and seeds it with the current color.</summary>
public void Setup(InspectableProperty property, Color value)
{
_key = property.Key;
+1
View File
@@ -15,6 +15,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private InspectableProperty _property;
/// <summary>Populates the dropdown from the property's enum labels and selects the current value, if known.</summary>
public void Setup(InspectableProperty property, long currentValue)
{
_property = property;
@@ -17,6 +17,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private string _key;
private bool _integer;
/// <summary>Applies the property's min/max/step and integer mode, then seeds the initial (clamped) value.</summary>
public void Setup(InspectableProperty property, double initialValue)
{
_key = property.Key;
+23 -8
View File
@@ -4,6 +4,8 @@ using Cthangover.Core.UI;
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>
@@ -49,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);
@@ -79,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();
@@ -112,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;
@@ -125,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)
@@ -168,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);
+1
View File
@@ -10,6 +10,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private string _key;
/// <summary>Binds the line edit to the property key and seeds it with the current text.</summary>
public void Setup(InspectableProperty property, string value)
{
_key = property.Key;
@@ -16,6 +16,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private SpinBox _x;
private SpinBox _y;
/// <summary>Builds the x/y spin pair bound to the property key and seeds both axes.</summary>
public void Setup(InspectableProperty property, Vector2 initialValue)
{
_key = property.Key;
+13 -6
View File
@@ -251,8 +251,9 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
/// <summary>
/// Recursively walks the loaded scene tree and populates the given
/// <see cref="Tree"/> control with nodes labelled <c>Name (Type)</c>.
/// Skips the internal highlight rect.
/// <see cref="Tree"/> control with nodes labelled <c>Name · Class</c>:
/// the mod scene root in gold, editable mod nodes in ink, read-only
/// base-scene nodes faint. Skips the internal highlight rect.
/// </summary>
public void BuildHierarchy(Tree tree)
{
@@ -271,9 +272,12 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private void AddNodeToTree(Tree tree, TreeItem parent, Node node)
{
var item = tree.CreateItem(parent);
var typeName = node.GetType().Name;
item.SetText(0, $"{node.Name} ({typeName})");
item.SetText(0, $"{node.Name} · {node.GetClass()}");
item.SetSelectable(0, true);
// Mod-scene nodes are the editable ones; base-scene nodes stay faint.
item.SetCustomColor(0, IsEditableModNode(node) ? UiTokens.Ink : UiTokens.Faint);
if (node == _modRoot)
item.SetCustomColor(0, UiTokens.AccentBright);
_nodeToItem[node] = item;
_itemToNode[item] = node;
@@ -341,6 +345,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
}
}
/// <summary>Hierarchy tree item for a live node; null when unmapped.</summary>
public TreeItem GetItemForNode(Node node)
{
_nodeToItem.TryGetValue(node, out var item);
@@ -392,6 +397,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
return string.Join("/", segments);
}
/// <summary>Topmost loaded-scene node whose visual rect contains the viewport position; null when nothing matches.</summary>
public Node FindNodeAtPosition(Vector2 position)
{
if (_loadedRoot == null)
@@ -423,8 +429,9 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
/// <summary>Hides the node highlight overlay and resets its colour.</summary>
public void HideHighlight()
{
if (_highlight != null)
_highlight.Visible = false;
if (_highlight == null)
return;
_highlight.Visible = false;
_highlight.Color = UiTokens.Alpha(UiTokens.Accent, 0.35f);
}
}
@@ -1,6 +1,7 @@
using Cthangover.Core.UI.Tool;
using Godot;
using Cthangover.Mods.Interface.Tool;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>Registers the scene builder as a tool window. Creates <see cref="SceneBuilderWindow"/> instances.</summary>
+225 -166
View File
@@ -5,6 +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>
@@ -32,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 =
@@ -63,6 +67,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
PopulateBaseList();
PopulateModList();
RebuildPreview();
SetHint(ToolUi.Tr("tools/scene_builder/hint"));
}
private void BuildUI()
@@ -73,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;
@@ -160,97 +202,64 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
clickCatcher.GuiInput += OnViewportClick;
_viewport.AddChild(clickCatcher);
var viewportContainer = new SubViewportContainer();
viewportContainer.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
viewportContainer.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
viewportContainer.Stretch = true;
viewportContainer.StretchShrink = 1;
var viewportContainer = new SubViewportContainer { Stretch = true };
viewportContainer.AddChild(_viewport);
previewContainer.AddChild(viewportContainer);
aspect.AddChild(viewportContainer);
_selectionOverlay = new SelectionOverlay();
previewContainer.AddChild(_selectionOverlay);
_selectionOverlay.Setup(_viewport, viewportContainer);
aspect.AddChild(_selectionOverlay);
_selectionOverlay.Setup(_viewport, viewportContainer, GameResolution);
_selectionOverlay.PickRequested += OnOverlayPick;
_selectionOverlay.BatchCommitted += OnOverlayBatchCommitted;
_selectionOverlay.EnsureConstructed();
_autosaveTimer = new Timer { OneShot = true, WaitTime = 0.4 };
_autosaveTimer.Timeout += OnAutosaveTimeout;
AddChild(_autosaveTimer);
return previewContainer;
}
// Compact tabbed sidebar: inspector and tscn source share one column,
// the output strip stays below the tabs so errors are always visible.
var rightVBox = new VBoxContainer();
rightVBox.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
rightVBox.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
var rightPanel = CreateSidebar(rightVBox, 360);
mainHBox.AddChild(rightPanel);
/// <summary>Inspector and tscn source share one tabbed column.</summary>
private Control BuildSidePanel()
{
var panel = new PanelContainer { CustomMinimumSize = new Vector2(380, 0) };
panel.AddThemeStyleboxOverride("panel", SidebarBox());
_sideTabs = new TabContainer();
_sideTabs.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_sideTabs.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
rightVBox.AddChild(_sideTabs);
var inspectorTab = new VBoxContainer
_sideTabs = new TabContainer
{
Name = TranslationServer.Translate("tools/scene_builder/tab_inspector"),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
_sideTabs.AddChild(inspectorTab);
panel.AddChild(_sideTabs);
_inspector = new InspectorPanel();
inspectorTab.AddChild(_inspector);
var inspectorScroll = new ScrollContainer { HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled };
_sideTabs.AddChild(inspectorScroll);
_sideTabs.SetTabTitle(0, ToolUi.Tr("tools/scene_builder/tab_inspector"));
_inspector = new InspectorPanel { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
inspectorScroll.AddChild(_inspector);
_inspector.PropertyEdited += OnInspectorPropertyEdited;
_inspector.EnsureConstructed();
_inspector.ShowMessage(TranslationServer.Translate("tools/scene_builder/inspector_select"));
_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));
// Titles are set explicitly: a node name cannot hold "." (".tscn").
var tscnTab = new VBoxContainer
{
Name = TranslationServer.Translate("tools/scene_builder/tab_tscn"),
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsVertical = Control.SizeFlags.ExpandFill
};
_sideTabs.AddChild(tscnTab);
_sideTabs.SetTabTitle(1, ToolUi.Tr("tools/scene_builder/tab_tscn"));
_tscnEditor = new TextEdit();
_tscnEditor.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
_tscnEditor.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
_tscnEditor.WrapMode = TextEdit.LineWrappingMode.None;
_tscnEditor.AddThemeFontOverride("font", GetMonospaceFont());
_tscnEditor.TextChanged += () => SetDirty();
_tscnEditor = ToolUi.Code();
_tscnEditor.TextChanged += OnTscnTextChanged;
tscnTab.AddChild(_tscnEditor);
tscnTab.AddChild(ToolUi.Hint(ToolUi.Tr("tools/scene_builder/tscn_hint")));
_saveBtn = new Button();
_saveBtn.Text = TranslationServer.Translate("tools/scene_builder/save");
_saveBtn.Pressed += OnSavePressed;
tscnTab.AddChild(_saveBtn);
var outputHeader = new HBoxContainer();
var outputLabel = new Label { Text = TranslationServer.Translate("tools/scene_builder/output") };
outputLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
outputHeader.AddChild(outputLabel);
var clearOutputBtn = new Button { Text = TranslationServer.Translate("tools/scene_builder/clear") };
clearOutputBtn.Pressed += () => _outputLabel.Text = "";
outputHeader.AddChild(clearOutputBtn);
rightVBox.AddChild(outputHeader);
_outputLabel = new RichTextLabel();
_outputLabel.BbcodeEnabled = true;
_outputLabel.FitContent = false;
_outputLabel.ScrollActive = true;
_outputLabel.ScrollFollowing = true;
_outputLabel.CustomMinimumSize = new Vector2(0, 70);
rightVBox.AddChild(_outputLabel);
return panel;
}
private void PopulateBaseList()
{
_baseDropdown.Clear();
_baseDropdown.AddItem(TranslationServer.Translate("tools/scene_builder/empty_scene"));
_baseDropdown.AddItem(ToolUi.Tr("tools/scene_builder/empty_scene"));
_baseScenes = _controller.GetBaseSceneList();
foreach (var s in _baseScenes)
_baseDropdown.AddItem(s.Display);
@@ -260,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)
@@ -268,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;
}
@@ -283,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;
@@ -302,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);
@@ -316,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>
@@ -339,43 +352,93 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
private void ShowTscnText()
{
_loadingText = true;
_tscnEditor.Text = "";
if (TryGetSelectedModScene(out var scene))
{
var text = _controller.ReadSceneText(scene.ModId, scene.RelativePath);
_tscnEditor.Text = text ?? "";
}
_tscnEditor.ClearUndoHistory();
_loadingText = false;
MarkClean();
}
/// <summary>Hand edits of the source mark the window dirty; they reach the preview on save.</summary>
private void OnTscnTextChanged()
{
if (!_loadingText)
SetDirty();
}
private void OnBaseSelected(long index)
{
RebuildPreview();
RunDiscardingChanges(RebuildPreview, RestoreDropdowns);
}
private void OnModSelected(long index)
{
var modId = index == 0 ? null : _mods[(int)index - 1];
PopulateSceneListForMod(modId);
RebuildPreview();
RunDiscardingChanges(() =>
{
var modId = index == 0 ? null : _mods[(int)index - 1];
PopulateSceneListForMod(modId);
RebuildPreview();
}, RestoreDropdowns);
}
private void OnSceneSelected(long index)
{
RebuildPreview();
RunDiscardingChanges(RebuildPreview, RestoreDropdowns);
}
/// <summary>Rolls the selectors back to the scene still shown after a refused switch.</summary>
private void RestoreDropdowns()
{
_baseDropdown.Select(_shownBase);
_modDropdown.Select(_shownMod);
PopulateSceneListForMod(_shownMod <= 0 ? null : _mods[_shownMod - 1]);
_sceneDropdown.Select(_shownScene);
}
private void OnReloadPressed()
{
if (TryGetSelectedModScene(out var scene))
_controller.InvalidateScene(scene.RelativePath);
RunDiscardingChanges(() =>
{
if (TryGetSelectedModScene(out var scene))
_controller.InvalidateScene(scene.RelativePath);
RebuildPreview();
ShowOutput(TranslationServer.Translate("tools/scene_builder/reloaded"), false);
RebuildPreview();
ShowOutput(ToolUi.Tr("tools/scene_builder/reloaded"), false);
});
}
/// <summary>Ctrl+S: save the source and rebuild the preview from it.</summary>
protected override void OnSaveShortcut() => OnSavePressed();
/// <summary>Delete removes the selected mod node after a confirmation.</summary>
protected override bool HandleKey(InputEventKey key)
{
if (key.Keycode != Key.Delete || _selectedNode == null || !_controller.IsEditableModNode(_selectedNode))
return false;
ShowConfirm("tools/scene_builder/delete_node_title", "tools/scene_builder/delete_node_confirm", OnDeleteNodePressed);
return true;
}
/// <summary>
/// Saves the source and, when it was edited by hand, rebuilds the preview
/// from the saved file — hand edits do not reach the live preview
/// otherwise (inspector and overlay edits are applied live already).
/// </summary>
private void OnSavePressed()
{
SaveCurrentText(false);
var handEdited = _dirty;
if (!SaveCurrentText(false) || !handEdited || !TryGetSelectedModScene(out var scene))
return;
_controller.InvalidateScene(scene.RelativePath);
RebuildPreview();
ShowOutput(ToolUi.Tr("tools/scene_builder/saved_rebuilt"), false);
}
/// <summary>
@@ -388,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);
@@ -465,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);
}
}
@@ -500,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;
}
@@ -508,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;
}
@@ -526,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;
@@ -577,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;
}
@@ -601,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);
}
@@ -621,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;
}
@@ -639,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);
}
@@ -669,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;
}
@@ -684,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;
@@ -715,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);
}
}
}
+54 -9
View File
@@ -4,6 +4,7 @@ using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Cthangover.Core.UI.Tool.SceneBuilder
{
/// <summary>
@@ -15,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
{
@@ -42,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
{
@@ -53,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();
@@ -65,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();
}
@@ -249,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;
@@ -256,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();
@@ -290,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));
}
@@ -314,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);
}
@@ -452,17 +495,19 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
}
}
MouseDefaultCursorShape = Control.CursorShape.Arrow;
MouseDefaultCursorShape = _markers.Count > 0 && _selected is Control control
&& CanvasRectToOverlay(control.GetGlobalRect()).HasPoint(localPoint)
? Control.CursorShape.Move
: Control.CursorShape.Arrow;
}
// ── coordinate mapping ──────────────────────────────────────────
private Vector2 ComputeStretchScale()
{
var viewSize = _viewport.Size;
return new Vector2(
viewSize.X > 0 ? _containerArea.Size.X / viewSize.X : 1f,
viewSize.Y > 0 ? _containerArea.Size.Y / viewSize.Y : 1f);
_canvasSize.X > 0 ? _containerArea.Size.X / _canvasSize.X : 1f,
_canvasSize.Y > 0 ? _containerArea.Size.Y / _canvasSize.Y : 1f);
}
private Vector2 CanvasToOverlay(Vector2 canvasPoint)
+5
View File
@@ -13,6 +13,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
/// </summary>
public static class TscnLiteralReader
{
/// <summary>Parses a bare .tscn float literal; constructors and references return false.</summary>
public static bool TryReadFloat(string raw, out double value)
{
value = 0d;
@@ -21,6 +22,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
return double.TryParse(raw.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
}
/// <summary>Parses a bare .tscn integer literal; floats and constructors return false.</summary>
public static bool TryReadLong(string raw, out long value)
{
value = 0;
@@ -29,6 +31,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
return long.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
}
/// <summary>Parses a .tscn boolean literal (true/false).</summary>
public static bool TryReadBool(string raw, out bool value)
{
var t = raw?.Trim();
@@ -56,6 +59,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
.Replace("\\\\", "\\");
}
/// <summary>Parses a <c>Vector2(x, y)</c> constructor literal.</summary>
public static bool TryReadVector2(string raw, out Vector2 value)
{
value = Vector2.Zero;
@@ -68,6 +72,7 @@ namespace Cthangover.Core.UI.Tool.SceneBuilder
return true;
}
/// <summary>Parses a <c>Color(r, g, b[, a])</c> constructor literal.</summary>
public static bool TryReadColor(string raw, out Color value)
{
value = Colors.White;
+6 -102
View File
@@ -1,102 +1,21 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Providers;
using Cthangover.Core.UI.Tool;
using Cthangover.Mods.Map;
using Cthangover.Core.Utils;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Static service for discovering, reading and writing world map zone files
/// (<c>mods/&lt;mod&gt;/map/*.json</c>) from the map editor. Follows
/// <see cref="ScenarioFileService"/>: reading goes through the mod file
/// provider abstraction so zip mods are visible, writing is deliberately
/// limited to folder-backed mods because zip archives are read-only.
/// <para/>
/// New files are created through the provider's <c>RootPath</c> rather than
/// <c>GetFileSystemPath</c>, which reports <c>null</c> for files that do not
/// exist yet — the editor must be able to author a brand-new map file, not
/// only overwrite an existing one.
/// (De)serialisation of world map zone files (<c>mods/&lt;mod&gt;/map/*.json</c>)
/// for the map editor. File access itself goes through
/// <see cref="ModFileService"/>.
/// </summary>
public static class MapFileService
{
private const string MapDir = "map";
private const string JsonExtension = ".json";
/// <summary>
/// IDs of every loaded mod that can be written to (folder-backed).
/// Zip mods are skipped silently: they appear in the game but the editor
/// cannot save into them.
/// </summary>
public static List<string> ListWritableMods()
{
var result = new List<string>();
var manager = ModManager.Instance;
if (manager?.Mods == null)
return result;
foreach (var pair in manager.Mods)
{
if (pair.Value?.FileProvider is FolderModFileProvider)
result.Add(pair.Key);
}
result.Sort(StringComparer.OrdinalIgnoreCase);
return result;
}
/// <summary>
/// Mod-relative paths of all <c>.json</c> files under the mod's
/// <c>map/</c> directory, including nested subdirectories. Returns an
/// empty list for unknown mods.
/// </summary>
public static List<string> ListMapFiles(string modId)
{
var result = new List<string>();
if (string.IsNullOrEmpty(modId))
return result;
CollectJsonFiles(modId, MapDir, result);
result.Sort(StringComparer.OrdinalIgnoreCase);
return result;
}
/// <summary>Reads a map file through the mod provider; <c>null</c> when missing.</summary>
public static string ReadFile(string modId, string relativePath)
{
return ModManager.Instance.ReadFileText(modId, relativePath);
}
/// <summary>
/// Writes a map file into a folder-backed mod, creating the
/// <c>map/</c> directory and any missing parents. Returns <c>false</c>
/// for zip mods, unknown mods and empty paths.
/// </summary>
public static bool WriteFile(string modId, string relativePath, string text)
{
if (string.IsNullOrEmpty(modId) || string.IsNullOrEmpty(relativePath))
return false;
var mod = ModManager.Instance.GetMod(modId);
if (mod?.FileProvider is not FolderModFileProvider provider)
return false;
var root = provider.RootPath;
if (string.IsNullOrEmpty(root))
return false;
var absolute = Path.GetFullPath(Path.Combine(root, relativePath.Replace('\\', '/')));
var directory = Path.GetDirectoryName(absolute);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(absolute, text);
return true;
}
/// <summary>Folder of map zone files inside a mod.</summary>
public const string MapDir = "map";
/// <summary>
/// Parses the standard <c>{"Items": [...]}</c> envelope into zones.
@@ -122,20 +41,5 @@ namespace Cthangover.Tools.Services
var data = new FileData<MapZone> { Items = zones?.ToList() ?? new List<MapZone>() };
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
}
private static void CollectJsonFiles(string modId, string directory, List<string> result)
{
foreach (var entry in ModManager.Instance.ListFiles(modId, directory))
{
if (entry.EndsWith("/"))
{
CollectJsonFiles(modId, entry.TrimEnd('/'), result);
continue;
}
if (entry.EndsWith(JsonExtension, StringComparison.OrdinalIgnoreCase))
result.Add(entry.Replace('\\', '/'));
}
}
}
}
+115
View File
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.IO;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Providers;
namespace Cthangover.Tools.Services
{
/// <summary>
/// File access shared by the editors: listing a mod's files by folder and
/// extension, reading them through the mod provider and writing them back.
/// Reading works for every mod (folder, zip, test-suite subdir), writing only
/// for folder-backed mods — the only ones with a real directory behind them.
/// New files are created under the provider's <c>RootPath</c> because
/// <c>GetFileSystemPath</c> reports <c>null</c> for files that do not exist
/// yet, and an editor must be able to author a brand-new file.
/// </summary>
public static class ModFileService
{
/// <summary>IDs of every loaded folder-backed (writable) mod, sorted.</summary>
public static List<string> ListWritableMods()
{
var result = new List<string>();
var manager = ModManager.Instance;
if (manager?.Mods == null)
return result;
foreach (var pair in manager.Mods)
{
if (pair.Value?.FileProvider is FolderModFileProvider)
result.Add(pair.Key);
}
result.Sort(StringComparer.OrdinalIgnoreCase);
return result;
}
/// <summary>True when the mod is folder-backed, i.e. the editors can save into it.</summary>
public static bool IsWritable(string modId)
{
return !string.IsNullOrEmpty(modId)
&& ModManager.Instance.GetMod(modId)?.FileProvider is FolderModFileProvider;
}
/// <summary>
/// Mod-relative paths of the files under <paramref name="directory"/>
/// (recursively) ending with <paramref name="extension"/>, sorted.
/// </summary>
public static List<string> ListFiles(string modId, string directory, string extension)
{
var result = new List<string>();
if (string.IsNullOrEmpty(modId))
return result;
Collect(modId, directory, extension, result);
result.Sort(StringComparer.OrdinalIgnoreCase);
return result;
}
/// <summary>Reads a file through the mod provider; <c>null</c> when missing.</summary>
public static string ReadText(string modId, string relativePath)
{
return ModManager.Instance.ReadFileText(modId, relativePath);
}
/// <summary>
/// Writes a file into a folder-backed mod, creating missing directories.
/// Returns <c>false</c> for zip/virtual mods, unknown mods and empty paths.
/// </summary>
public static bool WriteText(string modId, string relativePath, string text)
{
var absolute = GetWritablePath(modId, relativePath);
if (absolute == null)
return false;
var directory = Path.GetDirectoryName(absolute);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(absolute, text);
return true;
}
/// <summary>
/// Absolute path a file of a folder-backed mod has (or would have once
/// written); <c>null</c> for read-only mods and empty paths.
/// </summary>
public static string GetWritablePath(string modId, string relativePath)
{
if (string.IsNullOrEmpty(modId) || string.IsNullOrEmpty(relativePath))
return null;
if (ModManager.Instance.GetMod(modId)?.FileProvider is not FolderModFileProvider provider
|| string.IsNullOrEmpty(provider.RootPath))
return null;
return Path.GetFullPath(Path.Combine(provider.RootPath, relativePath.Replace('\\', '/')));
}
private static void Collect(string modId, string directory, string extension, List<string> result)
{
foreach (var entry in ModManager.Instance.ListFiles(modId, directory))
{
if (entry.EndsWith("/"))
{
Collect(modId, entry.TrimEnd('/'), extension, result);
continue;
}
if (entry.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
result.Add(entry.Replace('\\', '/'));
}
}
}
}
+29 -3
View File
@@ -20,7 +20,8 @@ namespace Cthangover.Tools.Services
{
/// <summary>
/// Returns all unique background IDs (filenames without extension) from
/// the <c>"backgrounds"</c> file collection across all installed mods.
/// the <c>"backgrounds"</c> file collection across all installed mods,
/// sorted, without the <c>_albedo</c>/<c>_depth</c> lighting masks.
/// </summary>
public static List<string> GetBackgroundIds()
{
@@ -35,11 +36,13 @@ namespace Cthangover.Tools.Services
if (!string.IsNullOrWhiteSpace(id) && exts.Contains(ext.ToLower()))
{
var cleanId = id.Substring(0, id.LastIndexOf('.'));
if (!cleanId.EndsWith("_albedo") && !cleanId.EndsWith("_depth"))
var bare = cleanId.TrimEnd('_');
if (!bare.EndsWith("_albedo") && !bare.EndsWith("_depth"))
ids.Add(cleanId);
}
}
ids.Sort(StringComparer.OrdinalIgnoreCase);
return ids;
}
@@ -76,6 +79,29 @@ namespace Cthangover.Tools.Services
return ids;
}
/// <summary>
/// Texture IDs that make sense as small icons (map markers): every
/// texture except backgrounds and their <c>_depth</c>/<c>_albedo</c>
/// masks. Textures are keyed by file name, so backgrounds are excluded
/// by the file-name part of their IDs.
/// </summary>
public static List<string> GetIconIds()
{
var backgrounds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var id in GetBackgroundIds())
backgrounds.Add(id.Substring(id.LastIndexOf('/') + 1));
var result = new List<string>();
foreach (var id in GetTextureIds())
{
if (backgrounds.Contains(id) || id.EndsWith("_depth", StringComparison.OrdinalIgnoreCase)
|| id.EndsWith("_albedo", StringComparison.OrdinalIgnoreCase))
continue;
result.Add(id);
}
return result;
}
/// <summary>
/// Loads a background texture by ID via <see cref="BackgroundFactory.Instance.Get"/>.
/// Returns <c>null</c> on any error (missing resource, load failure).
+2
View File
@@ -1,4 +1,5 @@
using System;
using Cthangover.Mods.Dialog.Scenarios;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -288,6 +289,7 @@ namespace Cthangover.Tools.Services
return "mods/" + modId + "/" + relativePath;
}
/// <summary>Scans every mod's scenarios and builds the cross-scene graph (nodes per scene, edges per switch target).</summary>
public static SceneGraphData BuildSceneGraph()
{
var data = new SceneGraphData();
+4
View File
@@ -2,6 +2,7 @@ using System.Collections.Generic;
namespace Cthangover.Tools.Services
{
/// <summary>One scenario attached to a scene node (shown on the graph node and in its detail links).</summary>
public sealed class ScenarioGraphLink
{
public string Name { get; set; }
@@ -9,6 +10,7 @@ namespace Cthangover.Tools.Services
public string Key { get; set; }
}
/// <summary>One scene of the cross-scenario graph: owning mod, background list, start-scene flag and its scenarios.</summary>
public sealed class SceneGraphNode
{
public string SceneName { get; set; }
@@ -20,12 +22,14 @@ namespace Cthangover.Tools.Services
public bool HasErrors { get; set; }
}
/// <summary>Directed switch-target link between two scenes of the graph.</summary>
public sealed class SceneGraphEdge
{
public string FromScene { get; set; }
public string ToScene { get; set; }
}
/// <summary>Complete graph model consumed by <c>ScenarioGraphView</c>.</summary>
public sealed class SceneGraphData
{
public List<SceneGraphNode> Nodes { get; set; } = new();
+1
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using Cthangover.Mods.Dialog.Scenarios;
using Cthangover.Core.Scenarios;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.UI.Theming;
+80
View File
@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods.Resolvers;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Finds which background an editor should show for a piece of content,
/// so the author does not have to guess it from a list of hundreds:
/// <list type="bullet">
/// <item>a scene's background is the first <c>defaultBackground</c> of its
/// <c>scenes/*.json</c> definition;</item>
/// <item>an interactive belongs to the scene of the scenario that adds it
/// (<c>interactive_add &lt;id&gt;</c>).</item>
/// </list>
/// Both are best-effort hints: <c>null</c> means "no idea", and the author
/// can always pick another background by hand.
/// </summary>
public static class SceneBackgroundService
{
private const string InteractiveAddCommand = "interactive_add";
/// <summary>Default background ID of a logical scene, or <c>null</c>.</summary>
public static string ForScene(string sceneName)
{
if (string.IsNullOrEmpty(sceneName) || sceneName == "-")
return null;
var defs = SceneDefinitions.Collect();
if (defs == null)
return null;
foreach (var pair in defs)
{
if (!string.Equals(pair.Key, sceneName, StringComparison.OrdinalIgnoreCase) || pair.Value == null)
continue;
// Several mods may define the scene; the last one wins at runtime.
for (var i = pair.Value.Count - 1; i >= 0; i--)
{
if (pair.Value[i]?.DefaultBackground is { Count: > 0 } list)
return list[0];
}
}
return null;
}
/// <summary>
/// Scans every scenario once and maps interactive IDs to the scene of
/// the first scenario that adds them.
/// </summary>
public static Dictionary<string, string> MapInteractivesToScenes()
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var mod in ScenarioFileService.ScanScenarioFiles())
{
foreach (var group in mod.Groups)
{
foreach (var file in group.Files)
{
var text = ScenarioFileService.ReadAllText(file.ModId, file.RelativePath);
if (string.IsNullOrEmpty(text) || text.IndexOf(InteractiveAddCommand, StringComparison.Ordinal) < 0)
continue;
var scene = ScenarioFileService.ParseMetadata(text).Scene;
foreach (var raw in text.Split('\n'))
{
var tokens = ScenarioSyntaxService.Tokenize(raw.Trim());
if (tokens.Count >= 2 && tokens[0] == InteractiveAddCommand && !result.ContainsKey(tokens[1]))
result[tokens[1]] = scene;
}
}
}
}
return result;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "In-game Play-test Test",
"description": "Integration test of the scenario editor opened inside a running game (the novel template with the editors added): plays a scenario from the editor and checks the play-test stays in the game until Stop, then Stop returns to the game with the editor visible — never to the main menu. Activated by --itest=ingame_play.",
"sources": [
"src/*.cs"
],
"depends": [
"my_novel_game>=1"
]
}
@@ -0,0 +1,163 @@
using System.Linq;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Games;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.Tests.InGamePlay
{
/// <summary>
/// The player's path that used to throw them out to the main menu: inside
/// a running game (its dialog on screen) open the scenario editor, play a
/// scenario, and expect the play-test to keep running — the teardown of the
/// game's own dialog must not count as the end of the played scenario.
/// Stop, and the played scenario leaving its scene (an option's
/// <c>switch_scene</c>), must both come back into the game with the editor
/// visible. Ends with
/// <c>[TEST_RESULT] name=ingame_play passed=N failed=M</c> and the exit code.
/// </summary>
public partial class InGamePlayTestDriverNode : Node
{
private const string ResultName = "ingame_play";
private const string GameModId = "my_novel_game";
private const string ScenarioPath = "scenarios/my_novel_home.scenario";
private const float TickSeconds = 0.5f;
private const float TimeoutSeconds = 90f;
private const int GameSettleTicks = 6;
private const int PlaySettleTicks = 6;
private int _passed;
private int _failed;
private int _step;
private int _settle;
private double _elapsed;
private bool _finished;
private ScenarioEditorWindow _window;
/// <summary>Creates the driver ticking on its own timer.</summary>
public InGamePlayTestDriverNode()
{
var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
AddChild(timer);
timer.Timeout += Tick;
}
private void Tick()
{
if (_finished)
return;
_elapsed += TickSeconds;
if (_elapsed >= TimeoutSeconds)
{
Check(false, $"timeout ({TimeoutSeconds:0}s) at step {_step}");
Finish();
return;
}
var tree = GetTree();
var scenes = tree.Root.GetNodeOrNull<GodotSceneService>("GodotSceneService");
switch (_step)
{
case 0:
if (!Boot.IsReady || !ActiveGame.IsActive || InMainMenu(scenes) || GodotSceneService.IsTransitioning)
return;
if (++_settle < GameSettleTicks)
return;
Check(SceneManager.GetCurrentSceneName() == "my_novel_town", $"game entered its town (scene '{SceneManager.GetCurrentSceneName()}')");
_window = (ScenarioEditorWindow)ToolWindow.ShowWindow(new ScenarioEditorWindow());
var tabs = _window.FindChildren("*", "", true, false).OfType<ScenarioEditTabs>().FirstOrDefault();
tabs?.OpenFile(ScenarioFileService.MakeKey(GameModId, ScenarioPath));
Check(tabs?.HasActiveFile == true, "scenario editor opened the home scenario");
break;
case 1:
PressButton(_window, "tools/scenario_editor/play");
_settle = 0;
break;
case 2:
if (GodotSceneService.IsTransitioning)
return;
if (InMainMenu(scenes))
{
Check(false, "play-test threw the player out to the main menu");
Finish();
return;
}
if (++_settle < PlaySettleTicks)
return;
Check(SceneManager.IsTestPlayActive, "play-test is still running while its dialog waits for the player");
Check(!_window.Visible, "the editor is hidden during the play-test");
Check(SceneManager.GetCurrentSceneName() == "my_novel_home", $"play-test entered the scenario's scene (scene '{SceneManager.GetCurrentSceneName()}')");
PressButton(tree.Root, "tools/scenario_editor/stop");
_settle = 0;
break;
case 3:
if (GodotSceneService.IsTransitioning || ++_settle < PlaySettleTicks)
return;
Check(!InMainMenu(scenes), "Stop returns into the game, not to the main menu");
Check(IsInstanceValid(_window) && _window.Visible, "the editor is visible again after Stop");
Check(!SceneManager.IsTestPlayActive, "Stop ends the play-test");
Check(SceneManager.GetCurrentSceneName() == "my_novel_town", $"Stop returns to the scene the player was in (scene '{SceneManager.GetCurrentSceneName()}')");
break;
case 4:
PressButton(_window, "tools/scenario_editor/play");
_settle = 0;
break;
case 5:
if (GodotSceneService.IsTransitioning || ++_settle < PlaySettleTicks)
return;
// The played scenario's option "Back to the town" does exactly this.
tree.Root.GetNodeOrNull<SceneManager>("SceneManager")?.SwitchScene("my_novel_town");
_settle = 0;
break;
case 6:
if (GodotSceneService.IsTransitioning || ++_settle < PlaySettleTicks)
return;
Check(!InMainMenu(scenes), "a scene switch in the played scenario returns into the game, not to the main menu");
Check(!SceneManager.IsTestPlayActive, "leaving the played scene ends the play-test");
Check(IsInstanceValid(_window) && _window.Visible, "the editor is visible again after the played scenario left its scene");
Finish();
return;
}
_step++;
}
private static bool InMainMenu(GodotSceneService scenes) => scenes?.CurrentGodotSceneName == "mainmenu";
private static void PressButton(Node root, string textKey)
{
var text = ToolUi.Tr(textKey);
var button = root.FindChildren("*", nameof(Button), true, false).OfType<Button>().FirstOrDefault(b => b.Text == text);
button?.EmitSignal(BaseButton.SignalName.Pressed);
}
private void Check(bool condition, string label)
{
if (condition)
_passed++;
else
_failed++;
GameLogger.Log("TEST", $"InGamePlayTest: [{(condition ? "PASS" : "FAIL")}] {label}");
}
private void Finish()
{
_finished = true;
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={_passed} failed={_failed}");
GetTree().Quit(_failed == 0 ? 0 : 1);
}
}
}
@@ -0,0 +1,31 @@
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.Tests.InGamePlay
{
/// <summary>
/// Entry point of the in-game play-test GUI test, activated only by the
/// exact user arg <c>--itest=ingame_play</c>. The driver is added deferred:
/// resources-ready fires before the game's shell exists.
/// </summary>
public class InGamePlayTestInitializer : IModInitializer
{
private static bool driverQueued;
/// <summary>Nothing to do per mod: the driver is queued on resources-ready.</summary>
public void OnModLoaded(string modId) { }
/// <summary>Queues the driver once when the activation flag is present.</summary>
public void OnModResourcesReady()
{
if (driverQueued || !OS.GetCmdlineUserArgs().Any(a => a == "--itest=ingame_play"))
return;
driverQueued = true;
GameLogger.Log("TEST", "InGamePlayTestInitializer: activating in-game play-test test");
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new InGamePlayTestDriverNode());
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "Tool Windows Test",
"description": "Integration test of the tools mod: opens every registered tool window through ToolFactory exactly like the toolbar button does, checks the chrome and the window's own smoke checks, optionally saves a screenshot of each window (--shots=<dir>) and reports [TEST_RESULT]. Activated by --itest=tools_windows.",
"sources": [
"src/*.cs"
],
"depends": [
"cthangover_game>=1"
]
}
@@ -0,0 +1,5 @@
scene: home_basement
priority: 0
---
delay 60 hidden=true
end
@@ -0,0 +1,105 @@
using System;
using System.Linq;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Hub;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.Tests.Windows
{
/// <summary>
/// Play-test round trip of the scenario editor opened from the
/// <see cref="NovelEditorHub"/>: plays this suite's own scenario (so the
/// editor's save-before-play rewrites a test file, not game content), lets
/// the play end the way a finished dialog ends it
/// (<see cref="SceneManager.IsTestPlayActive"/> drops), then plays again and
/// presses Stop. Both times the game must come back to the hub with the
/// editor visible — not to the engine's main menu. Advanced one step per
/// driver tick; <see cref="Tick"/> returns true when done.
/// </summary>
public class ScenarioPlayReturnCheck
{
private const string SuiteModId = "editor_tools__windows";
private const string ScenarioPath = "scenarios/playtest_return.scenario";
private const int ShellSettleTicks = 3;
private readonly Action<bool, string> _check;
private ScenarioEditorWindow _window;
private int _step;
private int _settle;
/// <summary>Creates the check; <paramref name="check"/> records one assertion.</summary>
public ScenarioPlayReturnCheck(Action<bool, string> check)
{
_check = check;
}
/// <summary>Advances the round trip by one step; true once finished.</summary>
public bool Tick(SceneTree tree)
{
switch (_step)
{
case 0:
_window = (ScenarioEditorWindow)ToolWindow.ShowWindow(new ScenarioEditorWindow());
var tabs = _window.FindChildren("*", "", true, false).OfType<ScenarioEditTabs>().FirstOrDefault();
tabs?.OpenFile(ScenarioFileService.MakeKey(SuiteModId, ScenarioPath));
_check(tabs?.HasActiveFile == true, "scenario_editor: test scenario opened");
break;
case 1:
case 4:
PressButton(_window, "tools/scenario_editor/play");
_settle = ShellSettleTicks;
break;
case 2:
if (!InGame(tree))
return false;
SceneManager.IsTestPlayActive = false;
break;
case 5:
if (!InGame(tree))
return false;
PressButton(tree.Root, "tools/scenario_editor/stop");
break;
case 3:
case 6:
if (tree.CurrentScene is not NovelEditorHub || !_window.Visible)
return false;
_check(true, _step == 3
? "scenario_editor: the scenario's end returns to the editor hub"
: "scenario_editor: Stop returns to the editor hub");
if (_step == 6)
{
_window.QueueFree();
return true;
}
break;
}
_step++;
return false;
}
/// <summary>The play-test left the hub for the game shell and the shell settled.</summary>
private bool InGame(SceneTree tree)
{
if (tree.CurrentScene is NovelEditorHub || GodotSceneService.IsTransitioning || --_settle > 0)
return false;
_check(!_window.Visible, "scenario_editor: play hides the editor and enters the game");
return true;
}
private static void PressButton(Node root, string textKey)
{
var text = ToolUi.Tr(textKey);
var button = root.FindChildren("*", nameof(Button), true, false).OfType<Button>().FirstOrDefault(b => b.Text == text);
button?.EmitSignal(BaseButton.SignalName.Pressed);
}
}
}
@@ -0,0 +1,40 @@
using System.Collections.Generic;
using Godot;
namespace Cthangover.Tools.Tests.Windows
{
/// <summary>
/// Engine logger that records every error Godot reports while the test
/// runs. An exception thrown inside a signal handler (a button toggled
/// during construction, a draw callback) never reaches the code that opened
/// the window — Godot only prints it — so without this the test would pass
/// windows that are visibly broken. Errors may arrive from any thread.
/// </summary>
public partial class ToolWindowsErrorLogger : Logger
{
private readonly object _lock = new();
private readonly List<string> _errors = new();
/// <summary>Records the error text (rationale or code, whichever is set).</summary>
public override void _LogError(string function, string file, int line, string code, string rationale,
bool editorNotify, int errorType, Godot.Collections.Array<ScriptBacktrace> scriptBacktraces)
{
if (errorType == (int)ErrorType.Warning)
return;
lock (_lock)
_errors.Add($"{(string.IsNullOrEmpty(rationale) ? code : rationale)} ({file}:{line})");
}
/// <summary>Returns and clears the errors collected so far.</summary>
public List<string> Drain()
{
lock (_lock)
{
var copy = new List<string>(_errors);
_errors.Clear();
return copy;
}
}
}
}
@@ -0,0 +1,132 @@
using System;
using System.Linq;
using Cthangover.Core.UI.Tool.LightEditor;
using Cthangover.Core.UI.Tool.ScenarioEditor;
using Cthangover.Tools.InteractiveEditor;
using Cthangover.Tools.MapEditor;
using Godot;
namespace Cthangover.Tools.Tests.Windows
{
/// <summary>
/// Per-tool interaction checks run on an open window, through the same
/// public surface the UI uses: canvas input is delivered by emitting the
/// canvas' <c>gui_input</c> signal (the editors subscribe to it), data is
/// read back from the canvas' public controller. Nothing is saved — the
/// window is closed without writing afterwards.
/// </summary>
public static class ToolWindowsExercises
{
/// <summary>Runs the checks for <paramref name="toolId"/>; <paramref name="check"/> records each result.</summary>
public static void Run(string toolId, Window window, Action<bool, string> check)
{
switch (toolId)
{
case "map_editor":
MapDrawing(window, check);
break;
case "interactive_editor":
InteractivePolygon(window, check);
break;
case "light_editor":
LightAdd(window, check);
break;
case "scenario_editor":
var tree = Find<ScenarioFileTreePanel>(window)?.FindChildren("*", nameof(Tree), true, false).OfType<Tree>().FirstOrDefault();
check(tree?.GetRoot()?.GetChildCount() > 0, "scenario_editor: file tree lists mods with scenarios");
break;
}
}
/// <summary>Draws a triangle; the finishing double-click must not add a fourth vertex.</summary>
private static void MapDrawing(Window window, Action<bool, string> check)
{
var canvas = Find<MapEditorCanvas>(window);
if (canvas?.Controller == null)
{
check(false, "map_editor: canvas found");
return;
}
var zone = canvas.Controller.StartRegion();
canvas.SetMode(MapEditorCanvas.ModeDraw);
var rect = canvas.GetBackgroundRect();
Click(canvas, rect.Position + rect.Size * new Vector2(0.2f, 0.2f), false);
Click(canvas, rect.Position + rect.Size * new Vector2(0.4f, 0.2f), false);
Click(canvas, rect.Position + rect.Size * new Vector2(0.3f, 0.4f), false);
Click(canvas, rect.Position + rect.Size * new Vector2(0.3f, 0.4f), true);
check(zone.Points.Count == 3, $"map_editor: drawn region has 3 vertices (got {zone.Points.Count})");
check(canvas.Mode == MapEditorCanvas.ModeSelect, "map_editor: double-click finishes drawing");
check(Mathf.Abs(zone.Points[0].X - 0.2f) < 0.01f && Mathf.Abs(zone.Points[0].Y - 0.2f) < 0.01f,
$"map_editor: vertex lands at the clicked background point (got {zone.Points[0]})");
}
/// <summary>A new object switched to polygon gets 4 vertices; a click on empty background appends one.</summary>
private static void InteractivePolygon(Window window, Action<bool, string> check)
{
var canvas = Find<InteractiveEditorCanvas>(window);
if (canvas?.Controller == null)
{
check(false, "interactive_editor: canvas found");
return;
}
var def = canvas.Controller.Add();
InteractiveEditorController.SetHitType(def, "polygon");
canvas.RebuildHandles();
check(def.HitArea.Vertices?.Length == 4, "interactive_editor: rect converts to a 4-vertex polygon");
var rect = canvas.GetBackgroundRect();
Click(canvas, rect.Position + rect.Size * new Vector2(0.97f, 0.97f), false);
check(def.HitArea.Vertices?.Length == 5, $"interactive_editor: click on empty background adds a vertex (got {def.HitArea.Vertices?.Length})");
}
/// <summary>A double-click on the background adds a light at that point.</summary>
private static void LightAdd(Window window, Action<bool, string> check)
{
var canvas = Find<LightEditorCanvas>(window);
if (canvas?.Controller == null)
{
check(false, "light_editor: canvas found");
return;
}
var before = canvas.Controller.Lights.Count;
if (before >= LightEditorController.MaxLights)
return;
var rect = canvas.GetBackgroundRect();
Click(canvas, rect.Position + rect.Size * new Vector2(0.75f, 0.25f), true);
var light = canvas.Controller.Selected;
check(canvas.Controller.Lights.Count == before + 1 && light != null
&& Mathf.Abs(light.X - 0.75f) < 0.01f && Mathf.Abs(light.Y - 0.25f) < 0.01f,
"light_editor: double-click adds a light at the clicked point");
}
private static void Click(Control target, Vector2 local, bool doubleClick)
{
var ev = new InputEventMouseButton
{
ButtonIndex = MouseButton.Left,
Pressed = true,
DoubleClick = doubleClick,
Position = local
};
target.EmitSignal(Control.SignalName.GuiInput, ev);
}
private static T Find<T>(Node root) where T : Node
{
foreach (var child in root.GetChildren())
{
if (child is T match)
return match;
var nested = Find<T>(child);
if (nested != null)
return nested;
}
return null;
}
}
}
@@ -0,0 +1,230 @@
using System.Collections.Generic;
using System.IO;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Games;
using Cthangover.Core.UI.Tool;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Menu;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Hub;
using Godot;
namespace Cthangover.Tools.Tests.Windows
{
/// <summary>
/// Opens every registered tool window one by one through the same path the
/// toolbar button uses (<see cref="ToolFactory"/> → <see cref="ToolWindow.ShowWindow"/>),
/// lets it lay out for a couple of ticks, checks that it entered the tree
/// visible and inside the game viewport, optionally saves a screenshot and
/// closes it. A window that throws while constructing or laying out fails
/// only its own check, the others still run. Ends with the runner contract
/// <c>[TEST_RESULT] name=tools_windows passed=N failed=M</c> and the exit code.
/// <para/>
/// Launched as the <c>novel_editor</c> game (<c>--game=novel_editor --new</c>)
/// it first waits for the collection's entry scene, the
/// <see cref="NovelEditorHub"/>, and checks it lists a launcher button for
/// every registered tool before opening the windows; after the windows it
/// plays a scenario from the editor and checks that both the scenario's
/// end and Stop return to the hub (<see cref="ScenarioPlayReturnCheck"/>).
/// </summary>
public partial class ToolWindowsTestDriverNode : Node
{
private const string ResultName = "tools_windows";
private const float TickSeconds = 0.5f;
private const float TimeoutSeconds = 90f;
private const int SettleTicks = 4;
private const int ExerciseTick = 2;
private readonly string _shotsDir;
private readonly List<IToolProvider> _tools = new();
private int _passed;
private int _failed;
private int _toolIndex = -1;
private int _settle;
private double _elapsed;
private bool _finished;
private Window _window;
private bool _inHub;
private ScenarioPlayReturnCheck _playCheck;
private readonly ToolWindowsErrorLogger _errors = new();
/// <summary>Creates the driver; <paramref name="shotsDir"/> null or empty disables screenshots.</summary>
public ToolWindowsTestDriverNode(string shotsDir)
{
_shotsDir = shotsDir;
OS.AddLogger(_errors);
var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
AddChild(timer);
timer.Timeout += Tick;
}
private void Tick()
{
if (_finished)
return;
_elapsed += TickSeconds;
if (_elapsed >= TimeoutSeconds)
{
Fail($"timeout ({TimeoutSeconds:0}s) at tool #{_toolIndex}");
Finish();
return;
}
if (_toolIndex < 0)
{
var tree = Godot.Engine.GetMainLoop() as SceneTree;
if (!Boot.IsReady || tree?.CurrentScene == null)
return;
_inHub = ActiveGame.Id == NovelEditorHub.SceneName;
if (_inHub && tree.CurrentScene is not NovelEditorHub)
return;
_tools.AddRange(ToolFactory.Instance.GetAll());
Check(_tools.Count > 0, $"tools registered ({_tools.Count})");
if (_inHub)
CheckHub(tree.CurrentScene);
OpenNext();
return;
}
if (_playCheck != null)
{
if (_playCheck.Tick(GetTree()))
Finish();
return;
}
if (_window == null)
return;
if (--_settle == ExerciseTick)
{
try
{
ToolWindowsExercises.Run(_tools[_toolIndex].Id, _window, Check);
}
catch (System.Exception ex)
{
Fail($"{_tools[_toolIndex].Id}: interaction threw {ex.GetType().Name}: {ex.Message}");
}
}
if (_settle > 0)
return;
InspectAndClose();
OpenNext();
}
private void OpenNext()
{
_toolIndex++;
if (_toolIndex >= _tools.Count)
{
if (_inHub)
_playCheck = new ScenarioPlayReturnCheck(Check);
else
Finish();
return;
}
var tool = _tools[_toolIndex];
_errors.Drain();
try
{
_window = ToolWindow.ShowWindow(tool.CreateWindow());
_settle = SettleTicks;
}
catch (System.Exception ex)
{
Fail($"{tool.Id}: open threw {ex.GetType().Name}: {ex.Message}");
_window = null;
OpenNext();
}
}
/// <summary>Every registered tool has its launcher button on the hub screen.</summary>
private void CheckHub(Node hub)
{
var keys = new HashSet<string>();
CollectButtonKeys(hub, keys);
foreach (var tool in _tools)
Check(keys.Contains(tool.LocaleKey), $"hub: launcher button for {tool.Id}");
SaveShot("novel_editor_hub");
}
private static void CollectButtonKeys(Node node, HashSet<string> keys)
{
if (node is Button button && button.HasMeta(MenuBuilder.LocaleMetaKey))
keys.Add(button.GetMeta(MenuBuilder.LocaleMetaKey).AsString());
foreach (var child in node.GetChildren())
CollectButtonKeys(child, keys);
}
private void InspectAndClose()
{
var id = _tools[_toolIndex].Id;
var window = _window;
_window = null;
var errors = _errors.Drain();
Check(errors.Count == 0, $"{id}: no engine errors while opening" + (errors.Count > 0 ? ": " + string.Join(" | ", errors) : ""));
Check(GodotObject.IsInstanceValid(window) && window.IsInsideTree() && window.Visible, $"{id}: window visible in tree");
if (!GodotObject.IsInstanceValid(window))
return;
var viewport = GetTree().Root.GetVisibleRect().Size;
Check(window.Size.X <= viewport.X + 1 && window.Size.Y <= viewport.Y + 1,
$"{id}: window {window.Size} fits viewport {viewport}");
SaveShot(id);
window.QueueFree();
}
private void SaveShot(string id)
{
if (string.IsNullOrEmpty(_shotsDir) || DisplayServer.GetName() == "headless")
return;
var dir = ProjectSettings.GlobalizePath(_shotsDir);
Directory.CreateDirectory(dir);
var image = GetViewport().GetTexture().GetImage();
var path = Path.Combine(dir, id + ".png");
image.SavePng(path);
Log($"screenshot {path}");
}
private void Check(bool ok, string name)
{
if (ok)
{
_passed++;
Log($"[PASS] {name}");
}
else
{
Fail(name);
}
}
private void Fail(string message)
{
_failed++;
Log($"[FAIL] {message}");
}
private void Finish()
{
if (_finished)
return;
_finished = true;
OS.RemoveLogger(_errors);
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={_passed} failed={_failed}");
GetTree().Quit(_failed == 0 ? 0 : 1);
}
private static void Log(string message) => GameLogger.Log("TEST", message);
}
}
@@ -0,0 +1,43 @@
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.Tests.Windows
{
/// <summary>
/// Entry point of the tool windows GUI test. Activated only by the exact
/// user arg <c>--itest=tools_windows</c>; <c>--shots=&lt;dir&gt;</c> additionally
/// saves a screenshot of every opened window (needs a real display, e.g.
/// xvfb — the headless renderer produces no image). The driver is added to
/// the tree deferred because resources-ready fires before the shell scene
/// exists; the driver waits for the boot itself.
/// </summary>
public class ToolWindowsTestInitializer : IModInitializer
{
private static bool driverQueued;
/// <summary>Nothing to do per mod: the driver is queued on resources-ready.</summary>
public void OnModLoaded(string modId) { }
/// <summary>Queues the driver once when the activation flag is present.</summary>
public void OnModResourcesReady()
{
if (driverQueued)
return;
var args = OS.GetCmdlineUserArgs();
if (!args.Any(a => a == "--itest=tools_windows"))
return;
var shots = args
.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal))
.Select(a => a.Substring("--shots=".Length))
.FirstOrDefault();
driverQueued = true;
GameLogger.Log("TEST", $"ToolWindowsTestInitializer: activating tool windows test, shots='{shots ?? ""}'");
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new ToolWindowsTestDriverNode(shots));
}
}
}