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

3

9

Bug — hit-testing doesn't work as intended when siblings overlap:

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.

According to the Sprite Kit documentation:

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


This is a bug as siblings of a node are rendered before their parents — a sibling should be the next to be tested, and not its parent. In addition, if a node has userInteractionEnabled = NO, then surely it should be 'transparent' with regards to hit-testing — but here it is not as it results in a change of behaviour as a node is skipped over in the test.

I have searched online, but can't find anyone also reporting or posting about this bug. So should I report this?


And then the reason why I've posted this here is because I would like a suggestion for a 'fix' of this bug (ie. a suggestion for an implementation of some code somewhere so that SpriteKit works in the 'intended' manner for hit-testing)


To replicate the bug:

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.


Note: I am using Xcode 7.3.1, Swift, iOS — I haven't tested to see if this bug is universal, yet.


Extra detail: I did some additional debugging (slight complication to the replication above) and determined that the hit-test is sent to the parent afterwards and therefore not necessarily to the scene.

Ambrosia answered 11/7, 2016 at 9:59 Comment(3)
I'd appreciate any and all help with this problem I have - I daren't start my new project until this is sorted out as all sorts of bugs can occur thanks to it when nodes overlap. Surely others have noticed/experienced this bug when making projects too as it's pretty fundamental?Ambrosia
Not sure if I'd consider this a bug... really depends on game context which way is preferably. I could easily imagine a game where if one sprite is blocking the other it makes sense that the one below it is not called but instead a scene generic action is checked.Purport
@GOR No - it must be a bug. The behaviour goes against what the documentation says, which means it is unintended. In addition, it makes no logical sense because the userInteractionEnabled property is meant to represent transparency with regards to hit-testing, which fails because of this behaviour.Ambrosia
B
3

I suspect it's either a bug or the documentation is incorrect. Either way, here's a workaround that may be what you're looking for.

It sounds like you would like to interact with a node that may be

  1. obscured by one or more nodes that have userInteractionEnabled property set to false
  2. a child of a "background" node
  3. deep in the node tree

nodesAtPoint is a good starting point. It returns an array of nodes that intersects the tap point. Add this to the scene's touchesBegan and filter the nodes that don't have userInteractionEnabled set to true by

let nodes = nodesAtPoint(location).filter {
    $0.userInteractionEnabled
}

At this point, you can sort the array of nodes by zPosition and node-tree depth. You can use the following extension to determine these properties for a node:

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

and sort the array with

let nodes = nodesAtPoint(location)
    .filter {$0.userInteractionEnabled}
    .sort {$0.depth.z == $1.depth.z ? $0.depth.level > $1.depth.level : $0.depth.z > $1.depth.z}

To test the above code, define a SKSpriteNode subclass that allows user interaction

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

and add the following touch handlers to the SKScene subclass

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 {$0.userInteractionEnabled}
            .sort {$0.depth.z == $1.depth.z ? $0.depth.level > $1.depth.level : $0.depth.z > $1.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
    }
}

The following movie shows how the above code can be used to drag/drop sprites that are under other sprites (with userInteractionEnabled = true). Note that even though the sprites are children of the blue background sprite that covers the entire scene, the scene's touchesBegan is called when a user drags a sprite.

enter image description here

Bun answered 17/7, 2016 at 2:37 Comment(0)
S
2

The discrepancy appears to occur because the template's SKView instance has its ignoresSiblingOrder property set to true in the implementation of viewDidLoad on GameViewController. This property is false by default.

From the docs:

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.

So in your case you should be able to simply delete this line to get the usual behavior. As noted by @Fujia in the comments, this should only affect rendering order, not hit testing.

Like UIKit, SpriteKit's direct descendants of UIResponder presumably implement its touch-handling methods in order to forward events down the responder chain. So this inconsistency may be caused by the overridden implementations of these methods on SKNode. If you're reasonably sure the issue lies with these inherited methods, you could work around the issue by overriding them with your own event-forwarding logic. If that's the case, it'd also be nice to file a bug report with your test project.

Sari answered 14/7, 2016 at 23:15 Comment(7)
ignoresSiblingOrder only affects the rendering behavior, not hit testing.Crawler
I agree with @Crawler — I tested it with/without ignoresSiblingOrder and it made no difference. Even if this was the case, and the sibling order was swapped, the above occurrence wouldn't have taken place — the hit-test should not have skipped a sibling node either way.Ambrosia
In that case, I'd just recommend double-checking your child node's implementation of the UIResponder methods. If those look correct, though, this may indeed be a SpriteKit bug.Sari
@ErikFoss How do I do that? I haven't added any more code.Ambrosia
If you're not doing any further overriding yourself, you should get the expected behavior. In general, direct descendants of UIResponder within UIKit (which should include SKNode) provide overridden implementations of its methods for handling touch events. So if you've pared your code down to an isolated example, and still see the wrong behavior, I'd recommend filing a radar with it.Sari
Yes - there is nothing more to the code than what I've said to do in reproducing the bug. However, from what you've said, perhaps it is possible to override code in the implementation of the UIResponder directly to fix this issue?Ambrosia
Sure. While it would be a workaround, you could certainly override them to do the ordering yourself. This is pretty much the approach provided by @Epsilon's answer.Sari
P
2

You can workaround the issue by overwriting your scene mouseDown (or equivalent touch events) as below. Basically you check the nodes at the point and find the one that has the highest zPosition and userInteractionEnabled. This works as fallback for the situation when you don't have such a node as the highest position to begin with.

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)
}
Purport answered 15/7, 2016 at 11:45 Comment(3)
Thanks. I'm guessing the nodes array is ordered in the order they should be checked (from bottommost parent which is the scene to the topmost node)? However, surely this doesn't quite work: the scene's mouseDown isn't called when there are nodes at a point as a click doesn't 'pierce' through everything.Ambrosia
Sure it works. If there is a node at the location that has userInteractionEnabled then the mouseDown of that node is going to be called. If the top-most node at that location is something that has userInteractionEnabled=false, then the scene gets called as you noticed and thus this function. If there are no nodes the location then the scene gets called as well. It works, give it a try. As for the nodes order question; that's why the zPositions are compared in the loop.Purport
1. When z positions are the same, node order should be compared by rendering order - which surely is missing unless nodesAtPoint(...) gives the correct order. 2. The problem I'm thinking of is that this 'patch' will only get called when the scene's mouseDown is called. If I add a 'blanket' node covering the whole scene, and adjust the code so that everything is added to this 'blanket' instead of the scene, then the aforementioned problem will still occur. The scene's mouseDown isn't necessarily called if the topmost node doesn't have it as the parent.Ambrosia

© 2022 - 2024 — McMap. All rights reserved.