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 hasuserInteractionEnabled = 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.
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
userInteractionEnabled
property set to false
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.
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)
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With