Property forwarder (show/edit select nested node properties in a target node)

Hi forum. I thought this little util script might be worth sharing. (Not technically a plugin, but this may be the most suitable place.)

As probably everyone here, I sometimes create scenes with a sub-node structure that I then add as instances to my main scene. But oftentimes, I want to be able to control properties of sub-nodes in that sub-scene structure. It’s easy enough to write a getter and setter in the scene’s root node, but since I needed a lot of such “forwarded properties”, I wrote a PropertyForwarder util class.

Here’s a random example.
I have a scene for a light which consists of:

Light (scene root node)
└─ LightSource
. . . . └─ StencilLight

So in my main scene, I only have the Light scene. I don’t want to use “editable children”; all should be in an easily accessible interface in the Light inspector.

So, below on the right are some properties from nested children that I forward using the PropertyForwarder so that they can be controlled from the root node.

And in the same way, I can access and modify these properties in my main scene, all neatly packed in collapsible property groups named by the child nodes:

I also added an optional feature so that select properties emit a forwarded_property_changed signal. I had a need for this because when I set my LightSource colour, by default, the nested StencilLight min and max colours should be applied based on that value. I’m handling that in the root Light node.

Unfortunately, this util class still requires some basic setup in the node that the properties are forwarded to (the Light node in this example). I don’t think there is a way to overwrite _get_property_list, _set and _get from the outside, but that step is required for this util to work. Also, the node must have the @tool keyword for the properties to show in the inspector. And, naturally, if you want to observe the changes you make to the child nodes in the sub-scene, these also need to be @tools.

Here’s a basic sample setup of the “receiving node” (the Light root node in the example above):

@tool # required for the PropertyForwarder to work
extends Node

# optional: declare the signal for property change notifications (only needed if you defined any emit_changed_signal properties)
signal forwarded_property_changed(node_path: String, property: StringName, new_value: Variant, old_value: Variant)

var property_forwarder: PropertyForwarder

func _init() -> void:
	property_forwarder = PropertyForwarder.new(self, {
		"LightSource": {
			"group_name": "Light Source", # optional custom name, defaults to the node name
			"properties": ["color", "render", "offset", "float_range", "float_frequency", "random_delay", "unique"], # the forwarded properties
			"emit_changed_signal": ["color"] # optional: properties that emit forwarded_property_changed signals
		},
		"LightSource/StencilLight": {
			"group_name": "Stencil Light", # optional custom name, defaults to the node name
			"properties": ["radius", "max_color", "min_color", "color_balance", "color_falloff", "depth_range", "flicker_strength", "flicker_speed", "flicker_offset", "radius_to_color_strength", "edge_smoothness", "mesh_radius", "unique"], # the forwarded properties
		}
	})

func _ready() -> void:
	forwarded_property_changed.connect(_on_forwarded_property_changed) # (only needed if you defined any emit_changed_signal properties)

func _get_property_list() -> Array:
	return property_forwarder.get_forwarded_property_list()

func _set(property: StringName, value: Variant) -> bool:
	return property_forwarder.set_property(property, value)

func _get(property: StringName) -> Variant:
	return property_forwarder.get_property(property)

# (only needed if you defined any emit_changed_signal properties)
func _on_forwarded_property_changed(node_path: String, property: StringName, new_value: Variant, old_value: Variant) -> void:
	print("Forwarded property changed: ", node_path, ".", property, ", new value: ", new_value, ", old value: ", old_value)

And the actual PropertyForwarder util class:

## A util class to forward properties from nodes to a target node.
## This allows grouping related properties in the inspector under a single node. This is especially useful when nested
## tscn sub scenes are used in a main scene, so that we can change select child node properties directly from the sub
## scene root node in the inspector.
## This can be done manually by creating getters and setters for each property in the parent node, but this class
## automates that process.
## Note that the the `target_node` needs to have the `@tool` keyword for this to work. Also, nodes whose properties are
## forwarded need to have the `@tool` keyword to ensure that property changes are reflected in the editor.

class_name PropertyForwarder
extends RefCounted

var target_node: Node
var forwarded_nodes: Dictionary = {}
var _cached_values: Dictionary = {}

func _init(node: Node, forwards: Dictionary = {}) -> void:
	target_node = node
	forwarded_nodes = forwards

func get_forwarded_property_list() -> Array:
	var properties := []

	for node_path: String in forwarded_nodes:
		var config: Dictionary = forwarded_nodes[node_path]
		var node: Node = target_node.get_node_or_null(NodePath(node_path))
		var group_name: StringName = config.get("group_name", "")

		add_forwarded_properties(properties, node, config.properties, group_name)

	return properties

func set_property(property: StringName, value: Variant) -> bool:
	for node_path: String in forwarded_nodes:
		var config: Dictionary = forwarded_nodes[node_path]
		var node: Node = target_node.get_node_or_null(NodePath(node_path))

		if set_forwarded_property(property, value, node, config.properties, node_path):
			return true
	return false

func get_property(property: StringName) -> Variant:
	for node_path: String in forwarded_nodes:
		var config: Dictionary = forwarded_nodes[node_path]
		var node: Node = target_node.get_node_or_null(NodePath(node_path))

		var result: Variant = get_forwarded_property(property, node, config.properties)
		if result != null:
			return result
	return null

func add_forwarded_properties(properties: Array, node: Node, forwarded_props: Array, group_name: StringName = "") -> void:
	if not node:
		return

	var display_name: StringName = group_name if group_name != "" else node.name

	properties.append({
		"name": display_name,
		"type": TYPE_NIL,
		"usage": PROPERTY_USAGE_GROUP
	})

	var child_props := node.get_property_list()

	for prop in child_props:
		if prop.name in forwarded_props:
			var new_prop := prop.duplicate()
			new_prop.name = prop.name
			new_prop.usage = PROPERTY_USAGE_DEFAULT
			properties.append(new_prop)

func set_forwarded_property(property: StringName, value: Variant, node: Node, forwarded_props: Array, node_path: String) -> bool:
	if not node or not property in forwarded_props:
		return false

	var config: Dictionary = forwarded_nodes[node_path]
	var emit_changed_signal: Array = config.get("emit_changed_signal", [])
	var old_value: Variant = null
	var should_emit_signal := property in emit_changed_signal

	if should_emit_signal:
		if target_node.has_signal("forwarded_property_changed"):
			var cache_key := node_path + ":" + property
			old_value = _cached_values.get(cache_key)
		else:
			push_warning("Target node does not have 'forwarded_property_changed' signal declared. Signal will not be emitted.")
			should_emit_signal = false

	node.set(property, value)

	if should_emit_signal:
		var cache_key := node_path + ":" + property
		_cached_values[cache_key] = value

		if old_value != value:
			target_node.emit_signal("forwarded_property_changed", node_path, property, value, old_value)

	return true

func get_forwarded_property(property: StringName, node: Node, forwarded_props: Array) -> Variant:
	if not node or not property in forwarded_props:
		return null

	return node.get(property)

Maybe this is helpful in some setups.

1 Like