Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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.

like image 553
Shuri2060 Avatar asked Jul 11 '16 09:07

Shuri2060


2 Answers

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

like image 144
Epsilon Avatar answered Oct 10 '22 00:10

Epsilon


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)
}
like image 43
OwlOCR Avatar answered Oct 10 '22 00:10

OwlOCR