Search code examples
iosswiftsprite-kituikit

iOS SpriteKit how to make a touch fallthrough?


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.


Solution

  • 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