├── .gitignore ├── .gitattributes ├── dummy_add_object.gd ├── RuntimeSelector.tscn ├── project.godot ├── README.md ├── icon.svg ├── icon.svg.import ├── RuntimeSelectorAutoload.gd ├── node_3d.tscn └── RuntimeSelector.gd /.gitignore: -------------------------------------------------------------------------------- 1 | # Godot 4+ specific ignores 2 | .godot/ 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize EOL for all files that Git considers text files. 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /dummy_add_object.gd: -------------------------------------------------------------------------------- 1 | extends Node3D 2 | 3 | 4 | # Called when the node enters the scene tree for the first time. 5 | func _ready(): 6 | var meshInstance = MeshInstance3D.new() 7 | meshInstance.name = "dummy" 8 | meshInstance.mesh = PrismMesh.new() 9 | add_child(meshInstance) 10 | -------------------------------------------------------------------------------- /RuntimeSelector.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=2 format=3 uid="uid://bir52feueu1c"] 2 | 3 | [ext_resource type="Script" path="res://RuntimeSelector.gd" id="1_mn781"] 4 | 5 | [node name="RuntimeSelector" type="Node2D"] 6 | 7 | [node name="RuntimeSelector" type="Node3D" parent="."] 8 | script = ExtResource("1_mn781") 9 | -------------------------------------------------------------------------------- /project.godot: -------------------------------------------------------------------------------- 1 | ; Engine configuration file. 2 | ; It's best edited using the editor UI and not directly, 3 | ; since the parameters that go here are not all obvious. 4 | ; 5 | ; Format: 6 | ; [section] ; section goes between [] 7 | ; param=value ; assign values to parameters 8 | 9 | config_version=5 10 | 11 | [application] 12 | 13 | config/name="select-node" 14 | run/main_scene="res://node_3d.tscn" 15 | config/features=PackedStringArray("4.2", "Forward Plus") 16 | config/icon="res://icon.svg" 17 | 18 | [autoload] 19 | 20 | RuntimeSelectorAutoload="*res://RuntimeSelectorAutoload.gd" 21 | 22 | [dotnet] 23 | 24 | project/assembly_name="select-node" 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Allows for clicking on nodes in a running game and they will be displayed in the Godot inspector ready for immediate editing. 2 | 3 | Add the `RuntimeSelectorAutload.gd` script to your AutoLoads to let the editor listen for clicked_node messages, then just add the `RuntimeSelector` scene to your scene. You will need to open the remote tab at least once due to the way the remote tree selection works - kinda hacky as there is no direct API for interacting with these trees. 4 | 5 | https://github.com/DigitallyTailored/godot-runtime-node-selector/assets/13086157/5c3e2127-2041-4391-b5cd-7269effa1f77 6 | 7 | Importantly it also supports dynamically added nodes too as shown in the video. It only works for 3D at the moment though but would just need to change the picker to handle 2D I think. 8 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /icon.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://c0sdu4egmu0u7" 6 | path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://icon.svg" 14 | dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/hdr_compression=1 22 | compress/normal_map=0 23 | compress/channel_pack=0 24 | mipmaps/generate=false 25 | mipmaps/limit=-1 26 | roughness/mode=0 27 | roughness/src_normal="" 28 | process/fix_alpha_border=true 29 | process/premult_alpha=false 30 | process/normal_map_invert_y=false 31 | process/hdr_as_srgb=false 32 | process/hdr_clamp_exposure=false 33 | process/size_limit=0 34 | detect_3d/compress_to=1 35 | svg/scale=1.0 36 | editor/scale_with_editor_scale=false 37 | editor/convert_colors_with_editor_theme=false 38 | -------------------------------------------------------------------------------- /RuntimeSelectorAutoload.gd: -------------------------------------------------------------------------------- 1 | @tool 2 | extends EditorPlugin 3 | 4 | class ExampleEditorDebugger extends EditorDebuggerPlugin: 5 | 6 | var found = false 7 | var node_path = "" 8 | 9 | func _has_capture(prefix): 10 | return prefix == "clicked_node" 11 | 12 | func _capture(message, data, session_id): 13 | if message == "clicked_node:node": 14 | found = false 15 | node_path = str(data[0]) 16 | 17 | var root_node = EditorInterface.get_edited_scene_root().get_node('/root').find_child("*EditorDebuggerTree*", true, false) 18 | 19 | check_editor_trees(root_node) 20 | 21 | if !found: 22 | print("Node not found. Please check the remote tab is open") 23 | 24 | 25 | func check_editor_trees(node : Node, indent_level=0): 26 | if node == null: 27 | return 28 | if node.is_class("Tree"): 29 | var root = node.get_root() 30 | recurse_tree_items(root, "") 31 | for child in node.get_children(): 32 | check_editor_trees(child, indent_level + 1) 33 | 34 | func recurse_tree_items(item: TreeItem, prefix): 35 | if item == null: 36 | return 37 | if "/" + prefix + item.get_text(0) == node_path: #we found the clicked node! 38 | item.select(0) 39 | found = true 40 | if item.get_children(): 41 | for treeItem in item.get_children(): 42 | recurse_tree_items(treeItem, prefix + item.get_text(0) + "/") 43 | item = item.get_next() 44 | 45 | var debugger = (ExampleEditorDebugger as Variant).new() 46 | 47 | func _enter_tree(): 48 | add_debugger_plugin(debugger) 49 | 50 | func _exit_tree(): 51 | remove_debugger_plugin(debugger) 52 | -------------------------------------------------------------------------------- /node_3d.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=8 format=3 uid="uid://bm2hv68a6hdnx"] 2 | 3 | [ext_resource type="Script" path="res://dummy_add_object.gd" id="1_5nf40"] 4 | [ext_resource type="PackedScene" uid="uid://bir52feueu1c" path="res://RuntimeSelector.tscn" id="2_xbx3g"] 5 | 6 | [sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_3dgwj"] 7 | sky_horizon_color = Color(0.64625, 0.65575, 0.67075, 1) 8 | ground_horizon_color = Color(0.64625, 0.65575, 0.67075, 1) 9 | 10 | [sub_resource type="Sky" id="Sky_wjcjp"] 11 | sky_material = SubResource("ProceduralSkyMaterial_3dgwj") 12 | 13 | [sub_resource type="Environment" id="Environment_jbmsv"] 14 | background_mode = 2 15 | sky = SubResource("Sky_wjcjp") 16 | tonemap_mode = 2 17 | glow_enabled = true 18 | 19 | [sub_resource type="BoxMesh" id="BoxMesh_p27eq"] 20 | 21 | [sub_resource type="SphereMesh" id="SphereMesh_i08sq"] 22 | 23 | [node name="Node3D" type="Node3D"] 24 | script = ExtResource("1_5nf40") 25 | 26 | [node name="WorldEnvironment" type="WorldEnvironment" parent="."] 27 | environment = SubResource("Environment_jbmsv") 28 | 29 | [node name="MeshInstance3D" type="MeshInstance3D" parent="."] 30 | transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2, 0, 0) 31 | mesh = SubResource("BoxMesh_p27eq") 32 | 33 | [node name="MeshInstance3D2" type="MeshInstance3D" parent="."] 34 | transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0) 35 | mesh = SubResource("SphereMesh_i08sq") 36 | 37 | [node name="Camera3D" type="Camera3D" parent="."] 38 | transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 4.66319) 39 | 40 | [node name="RuntimeSelector" parent="." instance=ExtResource("2_xbx3g")] 41 | -------------------------------------------------------------------------------- /RuntimeSelector.gd: -------------------------------------------------------------------------------- 1 | extends Node3D 2 | 3 | var collision_added = [] 4 | var collision_nodes = [] # List to keep track of the generated collision nodes 5 | 6 | func _ready(): 7 | # Make sure to enable input processing 8 | set_process_input(true) 9 | 10 | func _input(event): 11 | if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: 12 | 13 | # Detect click 14 | iterative_add_collision() 15 | var camera = get_viewport().get_camera_3d() 16 | var space_state = get_world_3d().direct_space_state 17 | var mouse_vector2 = get_viewport().get_mouse_position() 18 | var raycast_origin_vector3 = camera.project_ray_origin(mouse_vector2) 19 | var raycast_end = camera.project_position(mouse_vector2, 1000) 20 | var query = PhysicsRayQueryParameters3D.create(raycast_origin_vector3, raycast_end) 21 | var intersect = space_state.intersect_ray(query) 22 | 23 | remove_added_collision() 24 | 25 | if intersect and intersect.collider: 26 | var clicked_path = intersect.collider.get_parent().get_path() 27 | EngineDebugger.send_message("clicked_node:node", [clicked_path]) 28 | 29 | func iterative_add_collision(node: Node = get_tree().root): 30 | if node.is_class("MeshInstance3D"): 31 | if not node.is_class("CollisionShape3D") and not node.is_class("StaticBody3D"): 32 | collision_added.append(node) 33 | else: 34 | pass 35 | for childNode in node.get_children(): 36 | iterative_add_collision(childNode) 37 | 38 | # Temporarily add collision to these nodes 39 | for collision_node in collision_added: 40 | add_collision_to_node(collision_node) 41 | 42 | func add_collision_to_node(node: MeshInstance3D): 43 | # Create a StaticBody for collisions 44 | var collision_body = StaticBody3D.new() 45 | collision_body.name = "GeneratedCollisionBody" 46 | collision_body.input_ray_pickable = true 47 | 48 | # Create a CollisionShape for the StaticBody 49 | var collision_shape = CollisionShape3D.new() 50 | var shape = BoxShape3D.new() 51 | 52 | var mesh = node.mesh 53 | if mesh: 54 | # Calculate the extents based on the AABB (Axis-Aligned Bounding Box) of the mesh 55 | var aabb = mesh.get_aabb() 56 | shape.extents = aabb.size * 0.5 # Extents is half the size of the AABB 57 | 58 | collision_shape.shape = shape 59 | collision_body.add_child(collision_shape) 60 | collision_shape.owner = collision_body 61 | node.add_child(collision_body) 62 | collision_body.owner = node 63 | 64 | collision_nodes.append(collision_body) # Add the collision body to the list of collision nodes 65 | 66 | 67 | func remove_added_collision(): 68 | for collision_node in collision_nodes: 69 | collision_node.queue_free() # Queue the generated collision body for deletion 70 | collision_added.clear() # Clear the list of nodes that had collision added 71 | collision_nodes.clear() # Clear the list of generated collision nodes 72 | --------------------------------------------------------------------------------