iOS SpriteKit how to make a touch fallthrough?

181 Views Asked by At

I need to make a sprite node to ignore touch, so that it fall through to other nodes behind it (lower z order).

In UIKit this can be done by overriding hitTest, but there doesn't seem to be a way here in SpriteKit. Another approach in UIKit is to change the zPosition of the CALayer. This way we can setup the view such that its zOrder is at the top (so that it renders on top), but it's touch behavior is still based on the relative order in superview's subviews array. Basically, we can decouple the touch handling and rendering order. I dont think we can do that in SpriteKit either.

Note that setting isUserInteractionEnabled = false doesn't work, since it still swallows the touch and prevent the nodes behind to receive touch event.

Anyone who used cocos2d before - this is basically the swallow touch flag.

My use case is that, I have a particle effect added to the screen during game play (it's added to the top of screen). Currently the particle swallows touches on its region, and affect my game play. I want to just show the particle, but do not interfere with touches for other components on screen.

4

There are 4 best solutions below

0
OMGPOP On BEST ANSWER

I want to thank @Luca for the 2 solutions. They are very close to what I want, and inspired me with my final solution.


Luca's first solution has a few issues:

  1. z needs to be the global z of a node
  2. if particle is removed after touch began is relayed, it won't relay touch ended anymore, causing dangling touch and bug in my game logic
  3. SKEmitterNode subclass doesn't work if sks file is in a separate bundle

Luca's 2nd solution addressed some of these, but also has a few issues. For example, we have to introduce a new flag isUserInteractionEnabled_2, and also need to traverse the scene to find the top most node. Also, it requires me to change the way I write existing games (rather than simply an infra change). So I strongly prefer Luca's 1st solution, because I can completely encapsulate the logic in my infra module, so all my games benefit from it without any change in game logic.


So I improved Luca's 1st solution, and here's how I address the above 3 problems.

  1. z needs to be the global z of a node

the exiting pointAt API doesn't work as I explained above. Looks like tree traversal is unavoidable. So here it is:


private func dfs(
  _ parent: SKNode,
  _ parentFinalZ: CGFloat,
  _ cur: SKNode,
  _ pointWrtParent: CGPoint,
  _ maxNode: inout SKNode?,
  _ maxFinalZ: inout CGFloat?)
{
  // If invisible, no point traversing, even if the child is visible, it's still not shown.
  guard cur.visible else { return }
  
  let curFinalZ = parentFinalZ + cur.z
  let pointWrtCur = cur.convert(pointWrtParent, from: parent)
  for child in cur.children.reversed() {
    dfs(cur, curFinalZ, child, pointWrtCur, &maxNode, &maxFinalZ)
  }
  
  // It's possible that parent interaction is not enabled, but the child is. So we want to check after traversing children.
  guard cur.isUserInteractionEnabled else { return }
  // It's possible that the children's bound is outside parent's bound
  guard cur.contains(pointWrtParent) else { return }
  // ignore SKEmitter
  if cur is SKEmitterNode { return }
  
  // if curFinalZ == maxFinalZ, do not update maxNode, because we search the children first, which take precedence over parent, if they have the same z
  if maxFinalZ == nil || curFinalZ > maxFinalZ! {
    maxNode = cur
    maxFinalZ = curFinalZ
  }
}

fileprivate extension SKScene {
  
  private func topNodeOrSelf(_ touches: Set<UITouch>) -> SKNode {
    let p = touches.first!.location(in: self)
    var maxNode: SKNode? = nil
    var maxZ: CGFloat? = nil
    for child in children.reversed() {
      dfs(self, z, child, p, &maxNode, &maxZ)
    }
    return maxNode ?? self
  }
}

This code does a DFS traversal to find the node with max global Z (accumulating all the z's in the path).

  1. if particle is removed after touch began is relayed, it won't relay touch ended anymore, causing dangling touch and bug in my game logic

I address this problem by keeping the particle (but making it invisible), until it's done its job to relay touch ended/cancelled events.

Here's how I do it:


    
  open override func removeFromParent() {
    if !hasDanglingTouchBegan {
      // Either not touched, or touch ended/canceled. Directly remove from parent.
      // Need to reset alpha and hasBegan, since we re-cycle emitter node
      self.alpha = 1
      self.hasDanglingTouchBegan = false
      // actually remove (super call)
      super.removeFromParent()
    } else {
      // Touch has began, but it needs to remove before touches ended/canceled
      // We cannot remove directly, because it will stop relaying touch ended/canceled event to the scene
      // Instead, we set it to transparent, and retry after 1 sec interval
      // Don't use isHidden flag, since we use that to traverse the tree. (Though in our case isHidden would happen to work because we want to ignore emitters during traversal, but it's better to rely on the type SKEmitterNode when filtering out emitter nodes)
      self.alpha = 0
      run(after: 1) {
        self.removeFromParent()
      }
    }
  }
  1. SKEmitterNode subclass doesn't work if sks file is in a separate bundle.

Luckily, the internal implementation of SKEmitterNode is objc (rather than swift), so that I can overwrite functions (touchesBegan, etc) in the extension.

However, it's still better to subclass, in case we have a scenario where we do want to swallow touch. So I am keeping my another question open: SpriteKit unable to unarchive an SKEmitterNode subclass from sks file

Here's a complete implementation:


import Foundation
import SpriteKit

// For some reason, NSKeyedUnarchiver doesn't support reading an sks file into a subclass of SKEmitterNode.
// https://stackoverflow.com/questions/77587789/spritekit-unable-to-unarchive-an-skemitternode-subclass-from-sks-file
// The workaround is simply to use extension, which should be fine because we intend the same bahavior for all emitters.
// But it's still better to use a subclass if possible, in case in the future we may have emitter node that swallows touch.
extension SKEmitterNode {
  
  static func load(fnWithoutExtension: String, in bundle: Bundle) -> SKEmitterNode? {
    guard
      let sksPath = bundle.path(forResource: fnWithoutExtension, ofType: "sks"),
      let sksData = try? Data(contentsOf: URL(fileURLWithPath: sksPath)),
      let emitter = try? NSKeyedUnarchiver.unarchivedObject(ofClass: SKEmitterNode.self, from: sksData),
      let texturePath = bundle.path(forResource: fnWithoutExtension, ofType: "png"),
      let textureImage = UIImage(contentsOfFile: texturePath)
    else { return nil }
    
    // We still need to set texture, because the texture file is not in main bundle
    emitter.particleTexture = SKTexture(image: textureImage)
    
    // Have to enable user interaction to receive touch
    emitter.isUserInteractionEnabled = true
    return emitter
  }
  
  
  private var hasDanglingTouchBegan: Bool {
    get {
      let dictionary = userData ?? [:]
      return dictionary["hasDanglingTouchBegan"] as? Bool ?? false
    }
    set {
      let dictionary = userData ?? [:] // use let since userData itself is mutable dictionary
      dictionary["hasDanglingTouchBegan"] = newValue
      userData = dictionary
    }
  }
  
  public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    hasDanglingTouchBegan = true
    scene?.relayTouchesBegan(touches, with: event)
  }
  
  open override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    scene?.relayTouchesMoved(touches, with: event)
  }
  
  open override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    hasDanglingTouchBegan = false
    scene?.relayTouchesEnded(touches, with: event)
  }
  
  open override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
    hasDanglingTouchBegan = false
    scene?.relayTouchesCancelled(touches, with: event)
  }
    
  open override func removeFromParent() {
    if !hasDanglingTouchBegan {
      // Either not touched, or touch ended/canceled. Directly remove from parent.
      // Need to reset alpha and hasBegan, since we re-cycle emitter node
      self.alpha = 1
      self.hasDanglingTouchBegan = false
      // actually remove (super call)
      super.removeFromParent()
    } else {
      // Touch has began, but it needs to remove before touches ended/canceled
      // We cannot remove directly, because it will stop relaying touch ended/canceled event to the scene
      // Instead, we set it to transparent, and retry after 1 sec interval
      // Don't use isHidden flag, since we use that to traverse the tree. (Though in our case isHidden would happen to work because we want to ignore emitters during traversal, but it's better to rely on the type SKEmitterNode when filtering out emitter nodes)
      self.alpha = 0
      run(after: 1) {
        self.removeFromParent()
      }
    }
  }
}

private func dfs(
  _ parent: SKNode,
  _ parentFinalZ: CGFloat,
  _ cur: SKNode,
  _ pointWrtParent: CGPoint,
  _ maxNode: inout SKNode?,
  _ maxFinalZ: inout CGFloat?)
{
  // If invisible, no point traversing, even if the child is visible, it's still not shown.
  guard cur.visible else { return }
  
  let curFinalZ = parentFinalZ + cur.z
  let pointWrtCur = cur.convert(pointWrtParent, from: parent)
  for child in cur.children.reversed() {
    dfs(cur, curFinalZ, child, pointWrtCur, &maxNode, &maxFinalZ)
  }
  
  // It's possible that parent interaction is not enabled, but the child is. So we want to check after traversing children.
  guard cur.isUserInteractionEnabled else { return }
  // It's possible that the children's bound is outside parent's bound
  guard cur.contains(pointWrtParent) else { return }
  // ignore SKEmitter
  if cur is SKEmitterNode { return }
  
  // if curFinalZ == maxFinalZ, do not update maxNode, because we search the children first, which take precedence over parent, if they have the same z
  if maxFinalZ == nil || curFinalZ > maxFinalZ! {
    maxNode = cur
    maxFinalZ = curFinalZ
  }
}

fileprivate extension SKScene {
  
  private func topNodeOrSelf(_ touches: Set<UITouch>) -> SKNode {
    let p = touches.first!.location(in: self)
    var maxNode: SKNode? = nil
    var maxZ: CGFloat? = nil
    for child in children.reversed() {
      dfs(self, z, child, p, &maxNode, &maxZ)
    }
    return maxNode ?? self
  }
  
  func relayTouchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    topNodeOrSelf(touches).touchesBegan(touches, with: event)
  }
  
  func relayTouchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    topNodeOrSelf(touches).touchesMoved(touches, with: event)
  }
  
  func relayTouchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    topNodeOrSelf(touches).touchesEnded(touches, with: event)
  }
  
  func relayTouchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
    topNodeOrSelf(touches).touchesCancelled(touches, with: event)
  }
}

Note that I have a few helpers in other files, for example, visible is simply opposite of isHidden, and z is simply zPosition

0
Fault On

override your touch functions at the SKScene level, then filter by SKNode class name. this will bypass any particles in front

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    for t in touches {
        let location = t.location(in: self)
        let touchNodes = self.nodes(at: location) //all nodes at the point
            .compactMap{ $0 as? TouchMe } //filter by class. alternate: filter by sknode name 
        
        for node in touchNodes {
            node.touch() //pass the touch down 
        }
    }
}

and here is the corresponding SKNode

class TouchMe: SKNode {
    func touch() {
        print("TouchMe.touch")
    }
}
15
Luca Angeletti On

I guess you are looking for this kind of behaviour.

enter image description here

Let's see how to implement it!

The isSwallowTouchEnabled

Let's add the property to SKNode.

extension SKNode {
    var isSwallowTouchEnabled: Bool {
        get {
            userData?["isSwallowTouchEnabled"] as? Bool ?? false
        }
        set {
            if let userData {
                userData["isSwallowTouchEnabled"] = newValue
            } else {
                userData = ["isSwallowTouchEnabled": newValue]
            }

        }
    }
}

As you can see the default value for this property is false.

Enhancing SKScene

We need to provide SKScene with a method capable of searching the SKNode affected by a touch and forward the touch event to it. During this search the method will skip the nodes where isSwallowTouchEnabled == true.

extension SKScene {
    func handleTouchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        for touch in touches {
            let location = touch.location(in: self)
            nodes(at: location)
                .filter { !$0.isSwallowTouchEnabled }
                // .max { $0.zPosition > $1.zPosition }?
                .max { $0.zPosition < $1.zPosition }?
                .touchesBegan(touches, with: event)
        }
    }
}

The EmitterNodeWithSwallowTouch class

Now we need to subclass SKEmitterNode in order be able to forward a touch event to the scene.

class EmitterNodeWithSwallowTouch: SKEmitterNode {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard !isSwallowTouchEnabled else {
            self.scene?.handleTouchesBegan(touches, with: event)
            return
        }
        print("touchesBegan on EmitterNodeWithSwallowTouch")
    }
    
}

The Square class

Finally, I have created this class to test the functionality.

We will put the Square node behind the particle effect in the next paragraph.

class Square: SKShapeNode {
    init(fillColor: SKColor, edge: CGFloat) {
        super.init()
        self.path = .init(rect: .init(origin: .zero, size: .init(width: edge, height: edge)), transform: nil)
        self.fillColor = fillColor
        
    }
    
    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("touchesBegan on Square")
    }
}

That's it! Let's test it

class GameScene: SKScene {
    override func didMove(to view: SKView) {
        let rain = EmitterNodeWithSwallowTouch(fileNamed: "Rain.sks")! // This force unwrap is just for simplicity, never do it.
        rain.zPosition = 1
        rain.isUserInteractionEnabled = true
        rain.isSwallowTouchEnabled = true
        rain.position.y = frame.maxY
        addChild(rain)
        
        let blueSquare = Square(fillColor: .blue, edge: 300)
        blueSquare.isUserInteractionEnabled = true
        blueSquare.zPosition = 0
        blueSquare.position.x = -150
        blueSquare.position.y = -150
        addChild(blueSquare)
    }
}

Hope it helps.

P.S. Cocos2D was a fun game engine.

2
Luca Angeletti On

Solution (Take 2)

My previous answer was not suitable for the OP because of 2 reasons:

  1. The subclass EmitterNodeWithSwallowTouch does not work well with emitters defined in sks files which are in another module.
  2. The answer does not work well with hierarchies because the zPosition of a note represented its z position in the parent. This generates wrong results.

This is my second attempt to solve the original problem taking in considerations these 2 issues.

The idea

The idea now is different.

  1. Let's leave the isUserInteractionEnabled == false for every node in the scene, except the scene itself. This will cause all the descendants of scene to ignore the touch which will eventually be managed by the scene itself.
  2. The scene takes responsibility of redirecting the touches to the correct node. In this step we will skip SKEmitterNode.

Code

First of all let's define the Square class used for testing purposes

class Square: SKShapeNode {
    init(fillColor: SKColor, edge: CGFloat) {
        super.init()
        self.path = .init(rect: .init(origin: .zero, size: .init(width: edge, height: edge)), transform: nil)
        self.fillColor = fillColor
        
    }
    
    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        print("touchesBegan on Square")
    }
}

Now let's define the GameScene.

class GameScene: SKScene {
    let rain = SKEmitterNode(fileNamed: "Rain.sks")!
    
    override func didMove(to view: SKView) {
        rain.zPosition = 1
        rain.position.y = frame.maxY
        addChild(rain)
        
        let blueSquare = Square(fillColor: .blue, edge: 300)
        assert(!blueSquare.isUserInteractionEnabled)
        blueSquare.zPosition = 0
        blueSquare.position.x = -150
        blueSquare.position.y = -150
        addChild(blueSquare)
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        // Temporary remove the node that should not receive touch.
        self.rain.removeFromParent()
        
        // Redirect the touches to the proper node.
        for touch in touches {
            let location = touch.location(in: self)
            let touchedNode = atPoint(location)
            if touchedNode != self {
                touchedNode.touchesBegan(touches, with: event)
            }
        }
        
        // Re-add the node.
        addChild(rain)
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        // TODO: same logic
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        // TODO: same logic
    }
    
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        // TODO: same logic
    }
}

How does it work?

The relevant part for the solution is in the touchesBegan(_:with:) method.

Since every node in the scene has isUserInteractionEnabled set to false (which is the default value for this property) now every touch event is detected by the scene.

So on every touch, the method GameScene.touchesBegan(_:with:) is called.

Step 1

First of all, the method removes the emitter from the scene.

self.rain.removeFromParent()

This way we are sure this node will not be detected on the next step.

Step 2

for touch in touches {
    let location = touch.location(in: self)
    let touchedNode = atPoint(location)
    if touchedNode != self {
       touchedNode.touchesBegan(touches, with: event)
    }
}

Now we use the atPoint() method to find the topmost node which is at the touch location. Then, if this node is not the scene itself, we forward the touch events to it.

Step 3

Finally, don't forget to re-add the emitter to the scene.

Since the drawing of the scene happens synchronously, removing and re-adding the emitter will not cause any flickering visual effect.

That's it.

enter image description here