diff --git a/.gitignore b/.gitignore index 21e9507..5b737d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ .godot/** tools/venv/** .import -addons +addons/* +!addons/transition_configurator/ build/ tmp/** .tmp/** diff --git a/addons/transition_configurator/README.md b/addons/transition_configurator/README.md new file mode 100644 index 0000000..f2e799a --- /dev/null +++ b/addons/transition_configurator/README.md @@ -0,0 +1,58 @@ +# Transition Configurator Plugin + +A Godot 4.x editor plugin that simplifies configuring TransitionPiece exits in the King's Quest IV remake. + +## Features + +- **Fuzzy search** for finding rooms quickly +- **Visual selection** of destination TransitionPieces as arrival points +- **Bidirectional wiring** - automatically updates return transitions +- **Validation** - warns if destination doesn't have appropriate arrival points +- **Auto-reload** - refreshes scenes in editor after changes + +## Installation + +1. Copy the `addons/transition_configurator/` folder to your project's `addons/` directory +2. Enable the plugin in Project Settings → Plugins +3. Restart the editor (recommended) + +## Usage + +1. Select a TransitionPiece node in your scene +2. In the Inspector, click the **"Configure Exit..."** button +3. Use the search box to filter rooms (fuzzy matching) +4. Click on a room to see its available TransitionPieces +5. Select an arrival point +6. Click **Apply Changes** + +The plugin will: +- Update the selected TransitionPiece's `target` and `appear_at_node` properties +- Update (or create) the return transition in the destination room +- Reload the destination scene in the editor + +## How It Works + +- **Room Discovery**: Scans `res://scenes/` for `.tscn` files starting with `kq4_` +- **Arrival Points**: Lists all TransitionPiece instances in the selected room +- **Bidirectional Updates**: Finds or creates a return TransitionPiece named after the current room +- **UID Resolution**: Extracts UIDs from scene files for proper target references + +## Files + +- `plugin.cfg` - Plugin metadata +- `transition_configurator.gd` - Main EditorPlugin entry point +- `transition_inspector_plugin.gd` - Adds UI to TransitionPiece inspector +- `config_dialog.gd` - Configuration dialog with search and selection +- `fuzzy_search.gd` - Fuzzy text matching utility + +## Requirements + +- Godot 4.x +- TransitionPiece class (class_name TransitionPiece) +- Room scenes in `res://scenes/` following `kq4_XXX_*` naming convention + +## Notes + +- The plugin uses UndoRedo for source scene changes +- Destination scene changes are saved and reloaded automatically +- If bidirectional update fails, the source changes are still applied diff --git a/addons/transition_configurator/config_dialog.gd b/addons/transition_configurator/config_dialog.gd new file mode 100644 index 0000000..ee601e5 --- /dev/null +++ b/addons/transition_configurator/config_dialog.gd @@ -0,0 +1,417 @@ +@tool +extends Window + +const FUZZY_SEARCH = preload("res://addons/transition_configurator/fuzzy_search.gd") +const TRANSITION_PIECE_SCENE = "res://TransitionPiece.tscn" + +var transition_piece: TransitionPiece = null + +# UI elements +@onready var search_input: LineEdit +@onready var room_list: ItemList +@onready var room_filter_label: Label +@onready var arrival_list: ItemList +@onready var arrival_label: Label +@onready var status_label: Label +@onready var apply_button: Button + +# Data +var all_rooms: Array[Dictionary] = [] +var current_room_path: String = "" + +func _ready() -> void: + title = "Configure Transition Exit" + min_size = Vector2i(900, 600) + + var main_vbox = VBoxContainer.new() + main_vbox.set_anchors_preset(Control.PRESET_FULL_RECT) + main_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + main_vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL + main_vbox.add_theme_constant_override("separation", 10) + add_child(main_vbox) + + # Add margins + var margin = MarginContainer.new() + margin.add_theme_constant_override("margin_left", 20) + margin.add_theme_constant_override("margin_right", 20) + margin.add_theme_constant_override("margin_top", 20) + margin.add_theme_constant_override("margin_bottom", 20) + margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + margin.size_flags_vertical = Control.SIZE_EXPAND_FILL + main_vbox.add_child(margin) + + var content_vbox = VBoxContainer.new() + content_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content_vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL + content_vbox.add_theme_constant_override("separation", 15) + margin.add_child(content_vbox) + + # Title + var title_label = Label.new() + title_label.text = "Configure Exit for: %s" % (transition_piece.name if transition_piece else "Unknown") + title_label.add_theme_font_size_override("font_size", 18) + content_vbox.add_child(title_label) + + # Main content - HSplitContainer + var split = HSplitContainer.new() + split.size_flags_horizontal = Control.SIZE_EXPAND_FILL + split.size_flags_vertical = Control.SIZE_EXPAND_FILL + split.split_offset = 300 + content_vbox.add_child(split) + + # Left side - Room selection + var left_vbox = VBoxContainer.new() + left_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + left_vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL + left_vbox.add_theme_constant_override("separation", 10) + split.add_child(left_vbox) + + var search_label = Label.new() + search_label.text = "Search Rooms:" + left_vbox.add_child(search_label) + + search_input = LineEdit.new() + search_input.placeholder_text = "Type to filter rooms..." + search_input.text_changed.connect(_on_search_changed) + left_vbox.add_child(search_input) + + room_filter_label = Label.new() + room_filter_label.text = "Showing all rooms" + room_filter_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + left_vbox.add_child(room_filter_label) + + room_list = ItemList.new() + room_list.size_flags_vertical = Control.SIZE_EXPAND_FILL + room_list.item_selected.connect(_on_room_selected) + left_vbox.add_child(room_list) + + # Right side - Arrival point selection + var right_vbox = VBoxContainer.new() + right_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + right_vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL + right_vbox.add_theme_constant_override("separation", 10) + split.add_child(right_vbox) + + arrival_label = Label.new() + arrival_label.text = "Select a room to see arrival points" + arrival_label.add_theme_font_size_override("font_size", 14) + right_vbox.add_child(arrival_label) + + arrival_list = ItemList.new() + arrival_list.size_flags_vertical = Control.SIZE_EXPAND_FILL + arrival_list.item_selected.connect(_validate_selection) + right_vbox.add_child(arrival_list) + + # Status label + status_label = Label.new() + status_label.text = "" + status_label.add_theme_color_override("font_color", Color(1, 0.5, 0.2)) + content_vbox.add_child(status_label) + + # Buttons + var button_hbox = HBoxContainer.new() + button_hbox.alignment = BoxContainer.ALIGNMENT_END + button_hbox.add_theme_constant_override("separation", 10) + content_vbox.add_child(button_hbox) + + apply_button = Button.new() + apply_button.text = "Apply Changes" + apply_button.disabled = true + apply_button.custom_minimum_size = Vector2(150, 40) + apply_button.pressed.connect(_on_apply) + button_hbox.add_child(apply_button) + + var cancel_button = Button.new() + cancel_button.text = "Cancel" + cancel_button.custom_minimum_size = Vector2(100, 40) + cancel_button.pressed.connect(hide) + button_hbox.add_child(cancel_button) + + # Close on escape + close_requested.connect(hide) + +func populate_rooms() -> void: + all_rooms.clear() + room_list.clear() + + var scene_files = _find_room_scenes() + for file_path in scene_files: + var room_name = file_path.get_file().get_basename() + var uid = _get_scene_uid(file_path) + all_rooms.append({ + "name": room_name, + "path": file_path, + "uid": uid + }) + + # Sort by name + all_rooms.sort_custom(func(a, b): return a["name"] < b["name"]) + + _update_room_list(all_rooms) + + room_filter_label.text = "Showing %d rooms" % all_rooms.size() + +func _update_room_list(rooms: Array) -> void: + room_list.clear() + for room in rooms: + room_list.add_item(room["name"]) + room_list.set_item_metadata(room_list.item_count - 1, room) + +func _on_search_changed(new_text: String) -> void: + if new_text.is_empty(): + _update_room_list(all_rooms) + room_filter_label.text = "Showing all %d rooms" % all_rooms.size() + else: + var filtered = FUZZY_SEARCH.sort_by_match(new_text, all_rooms, "name") + _update_room_list(filtered) + room_filter_label.text = "Showing %d of %d rooms" % [filtered.size(), all_rooms.size()] + +func _on_room_selected(index: int) -> void: + arrival_list.clear() + apply_button.disabled = true + + if index < 0 or index >= room_list.item_count: + return + + var room_data = room_list.get_item_metadata(index) + current_room_path = room_data["path"] + + arrival_label.text = "Arrival Points in %s:" % room_data["name"] + + # Find all TransitionPieces in the selected room + var transitions = _find_transition_pieces_in_scene(current_room_path) + + if transitions.is_empty(): + arrival_list.add_item("No TransitionPieces found in this room") + arrival_list.set_item_disabled(0, true) + status_label.text = "Warning: No arrival points available" + else: + for trans in transitions: + arrival_list.add_item(trans["name"]) + arrival_list.set_item_metadata(arrival_list.item_count - 1, trans) + status_label.text = "" + + _validate_selection() + +func _validate_selection() -> void: + var room_idx = room_list.get_selected_items() + var arrival_idx = arrival_list.get_selected_items() + + if room_idx.is_empty(): + apply_button.disabled = true + status_label.text = "Please select a destination room" + return + + if arrival_idx.is_empty(): + apply_button.disabled = true + status_label.text = "Please select an arrival point" + return + + var arrival_data = arrival_list.get_item_metadata(arrival_idx[0]) + if arrival_data == null or arrival_data.is_empty(): + apply_button.disabled = true + status_label.text = "Invalid arrival point selected" + return + + apply_button.disabled = false + status_label.text = "" + +func _on_apply() -> void: + var room_indices = room_list.get_selected_items() + var arrival_indices = arrival_list.get_selected_items() + + if room_indices.is_empty() or arrival_indices.is_empty(): + return + + var room_data = room_list.get_item_metadata(room_indices[0]) + var arrival_data = arrival_list.get_item_metadata(arrival_indices[0]) + + var dest_path = room_data["path"] + var dest_uid = room_data["uid"] + var arrival_node_name = arrival_data["name"] + + if transition_piece == null: + push_error("TransitionPiece is null") + return + + # Update the source TransitionPiece + var undo_redo = EditorInterface.get_editor_undo_redo() + undo_redo.create_action("Configure Transition Exit") + + # Update target and appear_at_node on source + undo_redo.add_undo_property(transition_piece, "target", transition_piece.target) + undo_redo.add_undo_property(transition_piece, "appear_at_node", transition_piece.appear_at_node) + undo_redo.add_do_property(transition_piece, "target", dest_uid) + undo_redo.add_do_property(transition_piece, "appear_at_node", arrival_node_name) + + # Apply changes to source + transition_piece.target = dest_uid + transition_piece.appear_at_node = arrival_node_name + transition_piece.notify_property_list_changed() + + # Try to update bidirectionally + var bidirectional_result = _update_bidirectional_connection(dest_path, arrival_node_name) + + undo_redo.commit_action() + + if bidirectional_result.is_empty(): + print("Transition configured successfully!") + else: + push_warning(bidirectional_result) + + hide() + +func _update_bidirectional_connection(dest_scene_path: String, arrival_node_name: String) -> String: + """ + Update the destination scene to create/update the return transition. + Returns empty string on success, error message on failure. + """ + var current_scene_path = transition_piece.get_tree().edited_scene_root.scene_file_path + var current_room_name = transition_piece.name + + # Load destination packed scene + var dest_packed = load(dest_scene_path) as PackedScene + if dest_packed == null: + return "Failed to load destination scene: %s" % dest_scene_path + + var dest_state = dest_packed.get_state() + var found_transition_idx = -1 + var found_transition_node_name = "" + + # Find existing TransitionPiece that points back to current room + for i in range(dest_state.get_node_count()): + var node_instance = dest_state.get_node_instance(i) + if node_instance == null: + continue + + # Check if this is a TransitionPiece instance + var instance_path = node_instance.resource_path + if instance_path != TRANSITION_PIECE_SCENE: + continue + + var node_name = dest_state.get_node_name(i) + if node_name == current_room_name: + found_transition_idx = i + found_transition_node_name = node_name + break + + # Reload scene for editing + var dest_scene = dest_packed.instantiate() + if dest_scene == null: + return "Failed to instantiate destination scene" + + var return_transition: TransitionPiece = null + + if found_transition_idx >= 0: + # Update existing transition + return_transition = dest_scene.get_node_or_null(found_transition_node_name) + if return_transition == null: + return "Found transition index but couldn't get node: %s" % found_transition_node_name + else: + # Create new TransitionPiece + return_transition = load(TRANSITION_PIECE_SCENE).instantiate() + return_transition.name = current_room_name + dest_scene.add_child(return_transition) + return_transition.owner = dest_scene + + # Update the return transition properties + var current_scene_uid = _get_scene_uid(current_scene_path) + return_transition.target = current_scene_uid + return_transition.appear_at_node = transition_piece.name + + # Save the modified scene + var new_packed = PackedScene.new() + var pack_result = new_packed.pack(dest_scene) + if pack_result != OK: + dest_scene.free() + return "Failed to pack modified scene: %s" % error_string(pack_result) + + var save_result = ResourceSaver.save(new_packed, dest_scene_path) + dest_scene.free() + + if save_result != OK: + return "Failed to save destination scene: %s" % error_string(save_result) + + # Reload the scene in editor + EditorInterface.reload_scene_from_path(dest_scene_path) + + return "" + +func _find_room_scenes(path: String = "res://scenes/") -> PackedStringArray: + var results: PackedStringArray = [] + var dir = DirAccess.open(path) + + if not dir: + push_error("Could not open directory: " + path) + return results + + dir.list_dir_begin() + var file_name = dir.get_next() + + while file_name != "": + var full_path = path.path_join(file_name) + + if dir.current_is_dir(): + if not file_name.begins_with(".") and file_name != "addons": + results.append_array(_find_room_scenes(full_path)) + else: + if file_name.ends_with(".tscn") and file_name.begins_with("kq4_"): + results.append(full_path) + + file_name = dir.get_next() + + dir.list_dir_end() + return results + +func _get_scene_uid(scene_path: String) -> String: + """Get the UID for a scene file.""" + var file = FileAccess.open(scene_path, FileAccess.READ) + if not file: + # Fallback to using the path + return scene_path + + var first_line = file.get_line() + file.close() + + # Parse uid="uid://xxxxx" from the header + var regex = RegEx.new() + regex.compile('uid="(uid://[^"]+)"') + var result = regex.search(first_line) + + if result: + return result.get_string(1) + + # Fallback: use ResourceUID + var uid_path = ResourceUID.path_to_uid(scene_path) + if uid_path.begins_with("uid://"): + return uid_path + + return scene_path + +func _find_transition_pieces_in_scene(scene_path: String) -> Array[Dictionary]: + """Find all TransitionPiece nodes in a scene file.""" + var results: Array[Dictionary] = [] + + var packed = load(scene_path) as PackedScene + if packed == null: + return results + + var state = packed.get_state() + + for i in range(state.get_node_count()): + var node_instance = state.get_node_instance(i) + if node_instance == null: + continue + + # Check if this is a TransitionPiece instance + var instance_path = node_instance.resource_path + if instance_path != TRANSITION_PIECE_SCENE: + continue + + var node_name = state.get_node_name(i) + results.append({ + "name": node_name, + "index": i + }) + + return results diff --git a/addons/transition_configurator/config_dialog.gd.uid b/addons/transition_configurator/config_dialog.gd.uid new file mode 100644 index 0000000..5fb19a5 --- /dev/null +++ b/addons/transition_configurator/config_dialog.gd.uid @@ -0,0 +1 @@ +uid://1zh0uy36mkm11 diff --git a/addons/transition_configurator/fuzzy_search.gd b/addons/transition_configurator/fuzzy_search.gd new file mode 100644 index 0000000..500454a --- /dev/null +++ b/addons/transition_configurator/fuzzy_search.gd @@ -0,0 +1,64 @@ +extends RefCounted +class_name FuzzySearch + +## Simple fuzzy matching algorithm for text search +## Returns a score between 0 (no match) and 1 (perfect match) + +static func match(query: String, target: String) -> float: + if query.is_empty(): + return 1.0 + + query = query.to_lower() + target = target.to_lower() + + # Exact match + if target == query: + return 1.0 + + # Contains query as substring + if target.find(query) != -1: + return 0.9 + + # Fuzzy match - all characters in query must appear in order in target + var query_idx: int = 0 + var target_idx: int = 0 + var matches: int = 0 + var consecutive_bonus: float = 0.0 + var last_match_idx: int = -1 + + while query_idx < query.length() and target_idx < target.length(): + if query[query_idx] == target[target_idx]: + matches += 1 + if last_match_idx != -1 and target_idx == last_match_idx + 1: + consecutive_bonus += 0.1 + last_match_idx = target_idx + query_idx += 1 + target_idx += 1 + + # All characters matched + if query_idx == query.length(): + var base_score: float = float(matches) / float(query.length()) + var bonus: float = min(consecutive_bonus, 0.3) # Cap bonus at 0.3 + return base_score * 0.7 + bonus + + return 0.0 + +## Sort an array of items by fuzzy match score +## items should be Dictionary with at least a "name" or specified key field +static func sort_by_match(query: String, items: Array, key: String = "name") -> Array: + if query.is_empty(): + return items.duplicate() + + var scored: Array[Dictionary] = [] + for item in items: + var item_name: String = item.get(key, "") + var score: float = match(query, item_name) + if score > 0: + scored.append({"item": item, "score": score}) + + # Sort by score descending + scored.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return a["score"] > b["score"] + ) + + return scored.map(func(s): return s["item"]) diff --git a/addons/transition_configurator/fuzzy_search.gd.uid b/addons/transition_configurator/fuzzy_search.gd.uid new file mode 100644 index 0000000..764b951 --- /dev/null +++ b/addons/transition_configurator/fuzzy_search.gd.uid @@ -0,0 +1 @@ +uid://zeiq1cspn49j diff --git a/addons/transition_configurator/plugin.cfg b/addons/transition_configurator/plugin.cfg new file mode 100644 index 0000000..d9456c6 --- /dev/null +++ b/addons/transition_configurator/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Transition Configurator" +description="Easy configuration of TransitionPiece exits with fuzzy search and bidirectional wiring" +author="Game Developer" +version="1.0" +script="transition_configurator.gd" diff --git a/addons/transition_configurator/transition_configurator.gd b/addons/transition_configurator/transition_configurator.gd new file mode 100644 index 0000000..09c1ba0 --- /dev/null +++ b/addons/transition_configurator/transition_configurator.gd @@ -0,0 +1,14 @@ +@tool +extends EditorPlugin + +const INSPECTOR_PLUGIN = preload("res://addons/transition_configurator/transition_inspector_plugin.gd") + +var inspector_plugin: EditorInspectorPlugin + +func _enter_tree() -> void: + inspector_plugin = INSPECTOR_PLUGIN.new() + add_inspector_plugin(inspector_plugin) + +func _exit_tree() -> void: + remove_inspector_plugin(inspector_plugin) + inspector_plugin = null diff --git a/addons/transition_configurator/transition_configurator.gd.uid b/addons/transition_configurator/transition_configurator.gd.uid new file mode 100644 index 0000000..caef4e2 --- /dev/null +++ b/addons/transition_configurator/transition_configurator.gd.uid @@ -0,0 +1 @@ +uid://3een9hfa2figm diff --git a/addons/transition_configurator/transition_inspector_plugin.gd b/addons/transition_configurator/transition_inspector_plugin.gd new file mode 100644 index 0000000..d0e47e5 --- /dev/null +++ b/addons/transition_configurator/transition_inspector_plugin.gd @@ -0,0 +1,43 @@ +@tool +extends EditorInspectorPlugin + +const CONFIG_DIALOG = preload("res://addons/transition_configurator/config_dialog.gd") + +var config_dialog: Window = null + +func _can_handle(object: Object) -> bool: + return object is TransitionPiece + +func _parse_begin(object: Object) -> void: + var container = VBoxContainer.new() + container.add_theme_constant_override("separation", 10) + + # Header label + var header = Label.new() + header.text = "Exit Configuration" + header.add_theme_font_size_override("font_size", 14) + container.add_child(header) + + # Configure button + var button = Button.new() + button.text = "Configure Exit..." + button.custom_minimum_size = Vector2(200, 40) + button.pressed.connect(_on_configure_pressed.bind(object)) + container.add_child(button) + + # Separator + var separator = HSeparator.new() + separator.add_theme_constant_override("separation", 10) + container.add_child(separator) + + add_custom_control(container) + +func _on_configure_pressed(transition_piece: TransitionPiece) -> void: + if config_dialog == null: + config_dialog = CONFIG_DIALOG.new() + add_child(config_dialog) + + config_dialog.transition_piece = transition_piece + config_dialog.populate_rooms() + + EditorInterface.popup_dialog_centered(config_dialog, Vector2i(900, 600)) diff --git a/addons/transition_configurator/transition_inspector_plugin.gd.uid b/addons/transition_configurator/transition_inspector_plugin.gd.uid new file mode 100644 index 0000000..fa5d03c --- /dev/null +++ b/addons/transition_configurator/transition_inspector_plugin.gd.uid @@ -0,0 +1 @@ +uid://1c4ywc7gtm8u