如何使 Godot 中的用户控件自动将其子控件显示在我选择的内部容器内?

Bli*_*ndy 6 godot godot4

考虑一个充当用户提供的控件(如窗框)装饰器的控件。我希望我的控件具有窗口的所有常见逻辑(标题栏、可拖动窗口边框、隐藏窗口的按钮等),并且任何时候在主场景中实例化它时,我希望它“吃掉”其任何节点子节点并将它们放入我选择的容器中。

这是我制作的控件,LinesContainer容器是我希望其子级驻留的位置:

控制树 控制视觉

为了绝对清楚我的意思,当它被实例化到如下场景时,我希望它的子节点(在本例中为标签)表现得像节点的子节点LinesContainer一样:

在此输入图像描述

如果您完全熟悉 .Net XAML,这就是标签ContentPresenter在控件中的作用,它“吃掉”了Content整个控件的属性(即控件实例的子级,如上所述)并将其显示在该标签内,允许我在它周围(或者在它后面,或者在它上面,等等)创建我需要的任何东西。

有什么内置的东西吗ContentPresenter?或者如果没有,我将如何自己制作一些东西?如果可能的话,这也可以在编辑器中正常工作,允许我根据需要添加和删除项目并使其布局正确。

The*_*aot 1

以下是我按照您的要求做的最佳尝试,如所写。我已经在 Godot 4.1 和 4.2.dev 2 中进行了一些测试,并且它可以使用我将要讨论的一些警告。

但在开始之前,我想提一下,Container不推荐使用 base,因为它Container什么也不做。因此,您可以使用常规的Control. 是的,我提出的解决方案可以修改为与常规一起使用Control,我将在最后讨论。

附录:我想 Godot 标准方法是使用“可编辑子项”(您可以在场景停靠栏的场景实例的上下文菜单中找到它),我想提一下,以防万一您不知道这一点。另外,您想要的功能可以提出一个很好的提案:https://github.com/godotengine/godot-proposals/issues(至少考虑展开讨论:https://github.com/godotengine/godot-proposals/讨论

设计注意事项和注意事项:

  • 既然你说这就像ContentPresenterXAML 中的 a ,我就调用新类ContentPresenter
  • 我不想删除子项Control,也不想重新设置它们的父级,因为这可能会导致问题(例如, aNodePath不再正确,或者控件上的脚本无法处理从场景树中删除)。
  • 我不需要向孩子添加代码Control。这一切都在新ContentPresenter班级里。
  • 也许您打算对位置进行硬编码Container,但是我正在处理可能发生变化的情况Container,因为您删除这部分代码比添加它更容易。也许你会在这个过程中学到一些东西。
  • 由于这必须在编辑器中工作,因此我们将有一个@tool脚本。
  • 考虑到我想要一个@tool脚本对另一个节点有一个变量引用...我不会公开一个Container属性,而是一个NodePath属性(我在 Godot 4.2.dev5 中开发这个属性时尝试使用该Container属性时发生了崩溃 - 无论它是什么)希望在稳定版本中得到修复 - 但我又回到了旧的方式)。
  • 由于容器被设计为对其子级进行操作(设置其位置和大小),并且您将使用哪种类型Container尚不清楚,因此我希望有一些实际上作为Container.
  • 因此,我必须在 上创建 - 我称之为 -dual的子级Container,并来回复制它们的位置属性。
  • 您不会dual在场景树中看到 s。这是因为它们没有owner,因此也不会与场景一起存储。相反,它们将在场景加载时重新生成(无论是在编辑器中还是在运行时)。
  • 我使用元数据将duals 映射到孩子们Control,我将其临时编译成Dictionary. 在我存储之前,Dictionary但遇到了它不同步的问题。我也确实考虑过使用s ,但是如果您想拥有已经存在的子级(例如) name,这可能会引起冲突。ContainerCursor
  • 说到Container已经存在的孩子,代码会将duals放在它们后面children_to_skip(如果您想要它们放在前面,请用替换的计算0)。
  • 我正在使用这些信号child_entered_treechild_order_changedchild_order_changed确保正确的对偶以正确的顺序存在。minimum_size_changed以及触发item_rect_changed持仓属性复制的信号。这里item_rect_changed似乎resized多余了,我没有找到另一种方法来处理位置的变化(除了检查每一帧,这会降低效率)。
  • 说到检查每一帧,我没有找到一种在调整大小标志更改时获取信号的方法,因此要检测它们是否更改,我必须检查每一帧(在_process)中,但是,我没有将其包含在这个答案中(代码_process大部分时间都会保持禁用状态以减少性能影响)。因此,大小标志的更改不会立即反映出来,您可以利用dual重新生成的 s 在编辑器中修复它,调用_invalidate_childdren也应该修复它。
  • 这并不能解决 Z 排序问题。如果您希望某些东西位于 child 的顶部Control,则将其放在 s 的顶部Container是行不通的。
  • 我必须弄清楚如何获取全局坐标中的位置属性以正确复制它们。

这是代码:

@tool
class_name ContentPresenter
extends Control

# Reference to the container that children will behave as if they were in
var target_container:Container
# NodePath to the container that children will behave as if they were in
@export var target_container_path:NodePath:
    set(mod_value):
        # Update the NodePath
        target_container_path = mod_value
        # Update the reference et.al. only if this node is ready
        # If this node is not ready, the reponsability falls to _ready
        if is_node_ready():
            _update_target_container()


var invalidated_children:bool:
    set(mod_value):
        if invalidated_children == mod_value:
            return

        invalidated_children = mod_value


var invalidated_container:bool:
    set(mod_value):
        if invalidated_container == mod_value:
            return

        invalidated_container = mod_value


func _invalidate_children() -> void:
    invalidated_children = true
    set_process(true)


func _invalidate_container() -> void:
    invalidated_container = true
    set_process(true)


# Runs when this node is ready
func _ready() -> void:
    # Make sure child_entered_tree is connected
    if not child_entered_tree.is_connected(_child_entered_tree):
        child_entered_tree.connect(_child_entered_tree)

    # Make sure child_exiting_tree is connected
    if not child_exiting_tree.is_connected(_child_exiting_tree):
        child_exiting_tree.connect(_child_exiting_tree)

    # Make sure child_order_changed is connected
    if not child_order_changed.is_connected(_child_order_changed):
        child_order_changed.connect(_child_order_changed)

    # Update the container reference if necessary
    _update_target_container()
    _invalidate_children()


# Runs when this node leaves the scene tree
func _exit_tree() -> void:
    # Request to run _ready next time it enters the scene tree
    # This is so it can update the reference to the container
    request_ready()


# Called by the Godot edito to get warning
func _get_configuration_warnings() -> PackedStringArray:
    # If we don't have a valid reference to the container put up a warning
    if not is_instance_valid(target_container):
        return ["Target Container Not Found"]
    
    return []


func _process(_delta: float) -> void:
    if is_instance_valid(target_container):
        var control_by_dual := {}
        var dual_by_control := {}
        var duals_to_remove:Array[Control] = []
        var children_to_skip := 0
        for dual_candidate in target_container.get_children():
            if dual_candidate.has_meta("__dual_of"):
                var control := _validate_control(dual_candidate.get_meta("__dual_of", null))
                if is_instance_valid(control):
                    control_by_dual[dual_candidate] = control
                    dual_by_control[control] = dual_candidate
                else:
                    duals_to_remove.append(dual_candidate)
            else:
                children_to_skip += 1

        if invalidated_container:
            for dual in control_by_dual.keys():
                var control:Control = control_by_dual[dual]
                _copy_positioning(dual, control, false)

        if invalidated_children:
            # Make sure all the children Controls have a dual, and what should their order be
            var order:Array[Control] = []
            for control_candidate in get_children():
                var control := _validate_control(control_candidate)
                if not is_instance_valid(control):
                    continue

                var dual:Control = dual_by_control.get(control, null)
                if not is_instance_valid(dual):
                    dual = Control.new()
                    # When the child control changes its minimum size, update the dual
                    if not control.minimum_size_changed.is_connected(_invalidate_children):
                        control.minimum_size_changed.connect(_invalidate_children)

                    # When the child control moves or resizes, update the dual
                    if not control.item_rect_changed.is_connected(_invalidate_children):
                        control.item_rect_changed.connect(_invalidate_children)

                    # When the dual moves or resizes, update the child control
                    if not dual.item_rect_changed.is_connected(_invalidate_container):
                        dual.item_rect_changed.connect(_invalidate_container)

                    dual.set_meta("__dual_of", control)
                    control_by_dual[dual] = control
                    dual_by_control[control] = dual
                    target_container.add_child(dual)
                    #dual.owner = owner if owner != null else self

                order.append(dual)

            # Remove any duals whose child Control is no longer valid
            for dual in duals_to_remove:
                target_container.remove_child(dual)
                dual.queue_free()

            # Clear the list to remove so we don't remove them again
            duals_to_remove = []

            # Make sure the dual is in the correct order in the container children
            for index in order.size():
                target_container.move_child(order[index], children_to_skip + index)

            # Update the duals position
            for dual in control_by_dual.keys():
                var control = control_by_dual[dual]
                _copy_positioning(control, dual, true)

        # Remove any duals whose child Control is no longer valid (if they weren't removed before)
        for dual in duals_to_remove:
            target_container.remove_child(dual)
            dual.queue_free()

    set_process(false)


# Called by _ready or target_container_path's setter
func _update_target_container():
    # Figure out the new reference to the container
    var new_target_container:Container = null
    if not target_container_path.is_empty():
        new_target_container = get_node_or_null(target_container_path)

    # If it is the same reference do nothing
    if new_target_container == target_container:
        update_configuration_warnings()
        return

    # Since we are going to change container, remove duals from the old one
    if is_instance_valid(target_container):
        var children := target_container.get_children()
        for child in children:
            if child.has_meta("__dual_of"):
                target_container.remove_child(child)

        if target_container.item_rect_changed.is_connected(_invalidate_container):
            target_container.item_rect_changed.disconnect(_invalidate_container)

    # Update the container reference
    target_container = new_target_container
    if is_instance_valid(target_container):
        if not target_container.item_rect_changed.is_connected(_invalidate_container):
            target_container.item_rect_changed.connect(_invalidate_container)

    _invalidate_container()
    _invalidate_children()
    # Tell Godot to update warning
    update_configuration_warnings()


# Handler for child_entered_tree
func _child_entered_tree(node:Node) -> void:
    var control := _validate_control(node)
    if control == null:
        return

    _invalidate_children()


# Handler for child_exiting_tree
func _child_exiting_tree(node:Node) -> void:
    var control := _validate_control(node)
    if control == null:
        return

    _invalidate_children()


# Handler for child_order_changed
func _child_order_changed() -> void:
    _invalidate_children()


# Called from _child_entered_tree and _child_exiting_tree
func _validate_control(node:Node) -> Control:
    if node.owner == self:
        # We got a node that is part of the scene
        return null

    var control = node as Control
    if not is_instance_valid(control):
        # We got a node that is not a Control
        return null

    if control.get_parent() != self:
        return null

    if (
        is_instance_valid(target_container)
        and (
            control == target_container
            or control.is_ancestor_of(target_container)
        )
    ):
        # We got a Control that contains the container
        return null

    # return the Control
    return control


# Copies data between the children Controls and their duals
func _copy_positioning(from:Control, to:Control, is_push:bool) -> void:
    # global transform of from
    var from_global_transform := from.get_global_transform()
    
    # global transform of the parent of to
    var to_parent_global_transform := Transform2D.IDENTITY
    var to_parent := to.get_parent_control()
    if to_parent != null:
        to_parent_global_transform = to_parent.get_global_transform()

    # transform of from relative to the parent of to
    var from_to_local_transform := to_parent_global_transform.affine_inverse() * from_global_transform

    if is_push:
        to.visible = from.visible
        to.size_flags_horizontal = from.size_flags_horizontal
        to.size_flags_vertical = from.size_flags_vertical
        to.size_flags_stretch_ratio = from.size_flags_stretch_ratio
        to.custom_minimum_size = from.get_combined_minimum_size()

    to.size = from.size
    to.global_position = from_global_transform.origin
    to.rotation = from_to_local_transform.get_rotation()
    to.scale = from_to_local_transform.get_scale()

Run Code Online (Sandbox Code Playgroud)

正如您所看到的,我添加了一些评论,希望有助于理解正在发生的事情。

不过,我想进一步阐述一些事情:

  • 您将看到我is_node_ready在更新对 的引用之前进行了检查Container,这是因为我想在尝试访问它(以查询 )之前确保该节点位于场景树中NodePath。如果节点还没有准备好,那么_ready将调用该方法来更新引用。如果节点从场景树中删除(可能NodePath在不在场景树中时进行了修改)并再次添加,我需要再次更新引用,因为我用它来request_ready确保_ready再次运行(否则_ready只会运行第一次)。
  • 该方法_validate_control检查 是否ContentPresenter是所有者,在 是根的Node场景中添加到编辑器中的 s就是这种情况。ContentPresenter所以这可以很容易地跳过那些Nodes。它还检查是否Control实际上是 的子级ContentPresenter,从而允许检测 a 是否dual指向已删除的实例Control,否则该实例仍然是有效的实例。
  • 该方法_copy_positioning确实是其核心(并且需要更多时间来弄清楚)。如果您甚至不需要所有额外的设置,它可能对您有用。我会讲到这一点。
  • _process方法将通过set_process(false)调用并禁用自身_invalidate_children_invalidate_container再次启用它。
  • _get_configuration_warningsGodot 将调用该方法来获取警告。它们显示为Node场景树中 旁边的黄色三角形。
  • 我要重申,这并不能修复 Z 顺序。

现在,如果您不需要目标来获取 a Container,则只需在几个地方更改它:

  1. var target_container:Container
  2. var new_target_container:Container = null

另外,_get_configuration_warnings变量和方法的 、 和名称中也提到了它,但这些不是功能性的。

dual.item_rect_changed如果目标不是 a ,您还可以删除连接的行Container,假设只有Containers 会移动或调整其子级的大小。


我还想指出,将其作为转换来处理是有问题的。这将是一个无限的反馈循环:

  • 获取 的位置Control
  • 改造它。
  • 更新 的位置Control
  • 位置改变Control,重复。

因此,我需要跟踪原始值,并且以一种仍然允许您编辑它们的方式。因此,我相信duals 是一个很好的解决方案。


我最初发布这个答案时没有使用_process,但我遇到了信号的执行顺序问题。哪些重要:假设孩子Container和孩子都Control移动了,哪个先从另一个更新很重要。

现在使用_process我优先考虑Container移动孩子Control,这可以最大限度地减少他们行为不正确的情况。


也许你实际上并不需要照顾多个孩子。相反,您可能只想将一个Control(例如Container)的位置复制到另一个Control(例如您放置在 的“内部”的位置Container)。在这种情况下,该方法_copy_positioning可能对您仍然有用......但您可以摆脱所有设置dual

因此,我请您考虑指定您将Container使用 a放入其中的内容NodePath

不,如果您希望成为 的父级,则将Container位置复制到 是ContentPresenter行不通的。但是,如果您可以不将 当作 的子项,那么可能还有另一种方法隐藏在那里。例如,它可能类似于( / ),但对于s。但无论如何,这不是所写的问题。ContentPresenterContainerContainerContentPresenterRemoteTransform2D3DControl