错误:在 Sprite Kit 中使用同级节点和 userInteractionEnabled 属性 进行命中测试

Bug: hit-testing with sibling nodes and the userInteractionEnabled property in Sprite Kit

错误 — 当兄弟姐妹重叠时,命中测试无法按预期工作:

There are 2 overlapping nodes in a scene which have the same parent (ie. siblings)

The topmost node has userInteractionEnabled = NO whilst the other node has userInteractionEnabled = YES.

If the overlap is touched, after the topmost node is hit-tested and fails (because userInteractionEnabled = NO), instead of the bottom node being the next to be hit-tested, it is skipped and the parent of the 2 siblings is hit-tested.

What should happen is that the next sibling (the bottom node) is hit-tested rather than the hit-test jumping to the parent.

根据 Sprite Kit 文档:

"In a scene, when Sprite Kit processes touch or mouse events, it walks the scene to find the closest node that wants to accept the event. If that node doesn’t want the event, Sprite Kit checks the next closest node, and so on. The order in which hit-testing is processed is essentially the reverse of drawing order. For a node to be considered during hit-testing, its userInteractionEnabled property must be set to YES. The default value is NO for any node except a scene node."


这是一个错误,因为节点的兄弟节点在其父节点之前呈现 - 兄弟节点应该是下一个要测试的节点,而不是其父节点。此外,如果一个节点有 userInteractionEnabled = NO,那么在命中测试方面它肯定应该是 'transparent'——但这里不是,因为它会导致行为发生变化,因为在测试。

我在网上搜索过,但找不到任何人也报告或发布过此错误。那我应该举报吗?


然后我在这里发布这个的原因是因为我想要一个关于这个错误的 'fix' 的建议(即关于在某处实现一些代码的建议,以便 SpriteKit 在'intended' 命中测试的方式)


要复制错误:

Use the "Hello World" template provided when you start a new "Game" project in Xcode (it has "Hello World" and adds rocket sprites when you click).

Optional: [I also deleted the rocket sprite image from the project as the rectangle with the X which occurs when the image isn't found is easier to work with for debugging, visually]

Add a SKSpriteNode to the scene with userInteractionEnabled = YES (I'll refer to it as Node A from now on).

Run the code.

You'll notice that when you click on Node A, no rocket sprites are spawned. (expected behaviour since the hit-test should stop after it is successful - it stops as it succeeds on Node A.)

However, if you spawn a few rockets which are next to Node A, and then click on a place where Node A and a rocket overlaps, it is then possible to spawn another rocket on top of Node A — but this shouldn't be possible. This means that after the hit-test fails on the topmost node (the rocket which has userInteractionEnabled = NO by default), instead of testing Node A next, it tests the parent of the rocket instead which is the Scene.


注意:我正在使用 Xcode 7.3.1、Swift、iOS — 我还没有测试过这个错误是否普遍存在。


额外的细节:我做了一些额外的调试(上面的复制有点复杂)并确定命中测试随后发送到 parent,因此不一定发送到场景。

出现差异是因为模板的 SKView 实例在 [=13] 的实现中将其 ignoresSiblingOrder 属性 设置为 true =] 在 GameViewController 上。这个属性默认是false

来自文档:

A Boolean value that indicates whether parent-child and sibling relationships affect the rendering order of nodes in the scene. The default value is false, which means that when multiple nodes share the same z position, those nodes are sorted and rendered in a deterministic order. Parents are rendered before their children, and siblings are rendered in array order. When this property is set to true, the position of the nodes in the tree is ignored when determining the rendering order. The rendering order of nodes at the same z-position is arbitrary and may change every time a new frame is rendered. When sibling and parent order is ignored, SpriteKit applies additional optimizations to improve rendering performance. If you need nodes to be rendered in a specific and deterministic order, you must set the z-position of those nodes.

所以在你的情况下,你应该能够简单地删除这一行以获得通常的行为。正如@Fujia 在评论中指出的那样,这应该只会影响渲染顺序,未命中测试。

与 UIKit 一样,SpriteKit 的直接后代 UIResponder 可能会实现其触摸处理方法,以便将事件转发到响应链。因此,这种不一致可能是由这些方法在 SKNode 上的重写实现引起的。如果您有理由确定问题出在这些继承的方法上,则可以通过使用您自己的事件转发逻辑覆盖它们来解决该问题。如果是这样的话,最好在您的测试项目中提交错误报告。

您可以通过覆盖场景 mouseDown(或等效触摸事件)来解决此问题,如下所示。基本上,您检查该点的节点并找到具有最高 zPosition 和 userInteractionEnabled 的节点。当您没有这样的节点作为开始的最高位置时,这可以作为后备情况。

override func mouseDown(theEvent: NSEvent) {
    /* Called when a mouse click occurs */
    let nodes = nodesAtPoint(theEvent.locationInNode(self))

    var actionNode : SKNode? = nil
    var highestZPosition = CGFloat(-1000)

    for n in nodes
    {
        if n.zPosition > highestZPosition && n.userInteractionEnabled
        {
            highestZPosition = n.zPosition
            actionNode = n
        }
    }

    actionNode?.mouseDown(theEvent)
}

我怀疑这是一个错误或文档不正确。无论哪种方式,这可能是您正在寻找的解决方法。

听起来您想与一个可能是

的节点进行交互
  1. userInteractionEnabled 属性 设置为 false
  2. 的一个或多个节点遮挡
  3. "background" 节点的子节点
  4. 深入节点树

nodesAtPoint 是一个很好的起点。它 returns 与抽头点相交的节点数组。将其添加到场景的 touchesBegan 并通过

过滤未将 userInteractionEnabled 设置为 true 的节点
let nodes = nodesAtPoint(location).filter {
    [=10=].userInteractionEnabled
}

此时,您可以按 zPosition 节点树深度对节点数组进行排序。您可以使用以下扩展来确定节点的这些属性:

extension SKNode {
    var depth:(level:Int,z:CGFloat) {
        var node = parent
        var level = 0
        var zLevel:CGFloat = zPosition
        while node != nil {
            zLevel += node!.zPosition
            node = node!.parent
            level += 1
        }
        return (level, zLevel)
    }
}

并使用

对数组进行排序
let nodes = nodesAtPoint(location)
    .filter {[=12=].userInteractionEnabled}
    .sort {[=12=].depth.z == .depth.z ? [=12=].depth.level > .depth.level : [=12=].depth.z > .depth.z}

为了测试上面的代码,定义一个允许用户交互的SKSpriteNode子类

class Sprite:SKSpriteNode {
    var offset:CGPoint?
    // Save the node's relative location
    override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
        if let touch = touches.first {
            let location = touch.locationInNode(self)
            offset = location
        }
    }
    // Allow the user to drag the node to a new location
    override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
        if let touch = touches.first, parentNode = parent, relativePosition = offset {
            let location = touch.locationInNode(parentNode)
            position = CGPointMake(location.x-relativePosition.x, location.y-relativePosition.y)
        }
    }
    override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
        offset = nil
    }
}

并将以下触摸处理程序添加到 SKScene 子类

var selectedNode:SKNode?

override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
    if let touch = touches.first {
        let location = touch.locationInNode(self)
        // Sort and filter nodes that intersect with location
        let nodes = nodesAtPoint(location)
            .filter {[=14=].userInteractionEnabled}
            .sort {[=14=].depth.z == .depth.z ? [=14=].depth.level > .depth.level : [=14=].depth.z > .depth.z}
        // Forward the touch events to the appropriate node
        if let first = nodes.first {
            first.touchesBegan(touches, withEvent: event)
            selectedNode = first
        }
    }
}

override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
    if let node = selectedNode {
        node.touchesMoved(touches, withEvent: event)
    }
}

override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
    if let node = selectedNode {
        node.touchesEnded(touches, withEvent: event)
        selectedNode = nil
    }
}

下面的影片展示了如何将上述代码用于 drag/drop 个位于其他精灵(userInteractionEnabled = true)之下的精灵。请注意,即使精灵是覆盖整个场景的蓝色背景精灵的子元素,当用户拖动精灵时也会调用场景的 touchesBegan