Add Transition Configurator plugin for easy exit configuration

Features:
- Fuzzy search for finding destination rooms
- Lists all TransitionPieces in selected room as arrival points
- Bidirectional wiring - updates return transition automatically
- Auto-reloads destination scene in editor
- UndoRedo support for source scene changes

Files added:
- addons/transition_configurator/plugin.cfg
- addons/transition_configurator/transition_configurator.gd
- addons/transition_configurator/transition_inspector_plugin.gd
- addons/transition_configurator/config_dialog.gd
- addons/transition_configurator/fuzzy_search.gd
- addons/transition_configurator/README.md
This commit is contained in:
2026-03-16 09:26:03 -07:00
parent 31aa91fc3b
commit 4954732552
11 changed files with 609 additions and 1 deletions

3
.gitignore vendored
View File

@@ -1,7 +1,8 @@
.godot/**
tools/venv/**
.import
addons
addons/*
!addons/transition_configurator/
build/
tmp/**
.tmp/**

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://1zh0uy36mkm11

View File

@@ -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"])

View File

@@ -0,0 +1 @@
uid://zeiq1cspn49j

View File

@@ -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"

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://3een9hfa2figm

View File

@@ -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))

View File

@@ -0,0 +1 @@
uid://1c4ywc7gtm8u