Search code examples
swiftsprite-kitgame-physicsskphysicsbody

Intermittent errors with Bitmask Collisions


I'm making a 2D scrolling shooter in Swift with SpriteKit. I've set up SKPhysicsBody and using bitmasks for collisions. I keep getting intermittent errors, where the collisions will work fine and then stop working. The error I get is Fatal error: Unexpectedly found nil while unwrapping an Optional value. I don't understand why I get nil value sometimes, when it gets a value other times. I have a few different sprites in the game and after testing a lot to see if there is any difference in the collisions, I can't seem to find the problem. For example, a few play throughs and I shoot an asteroid with the laser and it will work fine. The next day the exact same thing crashes the game. Another example asteroid hits player head on and works fine, asteroid hits player from the side crashes game but next day could work fine. I don't know if the problem is with the way I've set the PhysicsBody for each sprite, as I've tried changing that and still had problems, or have I got the SKPhysicsContact set up all wrong. Any help would be mostly appreciated, Thank you.

Striped down version of my code

import SpriteKit
import GameplayKit
import CoreMotion


@objcMembers
class GameScene: SKScene, SKPhysicsContactDelegate {
    
    //Player Image
    let player = SKSpriteNode(imageNamed: "Player.png")
    
    //Timer to spawn enemies
    var gameTimer:Timer!
    
    //Array for different astroids
    var astroidArray = ["astroid1", "astroid2"]
    
    //Array for differnet enemy ships
    var enemyArray = ["Enemy1"]
    
    //For collision
    let playerCategory:UInt32 = 0x1 << 1
    let playerLaserCategory:UInt32 = 0x1 << 2
    let astroidCategory:UInt32 = 0x1 << 3
    let enemyCategory:UInt32 = 0x1 << 4
    let bossCategory:UInt32 = 0x1 << 5
    
    override func didMove(to view: SKView) {
          
        //Position Player
        player.position.y = -400
        player.zPosition = 1
        addChild(player)
        
        //Player Physics for collision
        //player.physicsBody = SKPhysicsBody(rectangleOf: player.size)
        player.physicsBody = SKPhysicsBody(texture: player.texture!, size: player.size)
        player.physicsBody?.isDynamic = true
        
        player.physicsBody?.categoryBitMask = playerCategory
        player.physicsBody?.contactTestBitMask = astroidCategory | enemyCategory | bossCategory
        //avoid any unwanted collisions
        //player.physicsBody?.collisionBitMask = 0
        
        //Physics for World
        self.physicsWorld.gravity = CGVector(dx: 0, dy: 0)
        physicsWorld.contactDelegate = self
        
        //Timer to spawn astroids
        gameTimer = Timer.scheduledTimer(timeInterval: 0.75, target: self, selector: #selector(addAstroid), userInfo: nil, repeats: true)
        
        //Timer to spawn enemy
        gameTimer = Timer.scheduledTimer(timeInterval: 0.75, target: self, selector: #selector(addEnemy), userInfo: nil, repeats: true)
        
    }
    
    func addAstroid() {
        
        astroidArray = GKRandomSource.sharedRandom().arrayByShufflingObjects(in: astroidArray) as! [String]
        
        //Select astroid from array
        let astroid = SKSpriteNode(imageNamed: astroidArray[0])
        
        //GameplayKit randomization services to spawn different astroids
        let randomAstroidPosition = GKRandomDistribution(lowestValue: -350, highestValue: 350)
        //Randomly spawn astroid in different positions
        let position = CGFloat(randomAstroidPosition.nextInt())
        astroid.position = CGPoint(x: position, y: self.frame.size.height + astroid.size.height)
        
        astroid.zPosition = 1
        
        //Astroid Physics for collision
        astroid.physicsBody = SKPhysicsBody(circleOfRadius: astroid.size.width / 2)
      
        astroid.physicsBody?.isDynamic = true
        
        astroid.physicsBody?.categoryBitMask = astroidCategory
        astroid.physicsBody?.contactTestBitMask = playerLaserCategory | playerCategory
        //avoid any unwanted collisions
        //astroid.physicsBody?.collisionBitMask = 0
        
        addChild(astroid)
        
        //Astroid speed
        let animationDuration:TimeInterval = 6
        
        //Clean up, remove astroids once reached a certain distance
        var actionArray = [SKAction]()
        actionArray.append(SKAction.move(to: CGPoint(x: position, y: -700), duration: animationDuration))
        actionArray.append(SKAction.removeFromParent())
        
        astroid.run(SKAction.sequence(actionArray))
    }
    
    func addEnemy() {
        
        enemyArray = GKRandomSource.sharedRandom().arrayByShufflingObjects(in: enemyArray) as! [String]
        
        //Select enemy from array
        let enemy = SKSpriteNode(imageNamed: enemyArray[0])
        
        //GameplayKit randomization services to spawn different enemies
        let randomEnemyPosition = GKRandomDistribution(lowestValue: -350, highestValue: 350)
        //Randomly spawn enemy in different positions
        let position = CGFloat(randomEnemyPosition.nextInt())
        enemy.position = CGPoint(x: position, y: self.frame.size.height + enemy.size.height)
        
        enemy.zPosition = 1
        
        //Enemy Physics for collision
        enemy.physicsBody = SKPhysicsBody(circleOfRadius: enemy.size.width / 2)
        enemy.physicsBody?.isDynamic = true
        
        enemy.physicsBody?.categoryBitMask = enemyCategory
        enemy.physicsBody?.contactTestBitMask = playerLaserCategory | playerCategory
        //avoid any unwanted collisions
        //enemy.physicsBody?.collisionBitMask = 0
       
        if score >= 20 {
         addChild(enemy)
        }
        
        //Enemy speed
        let animationDuration:TimeInterval = 6
        
        //Clean up, remove enemy once reached a certain distance
        var actionArray = [SKAction]()
        actionArray.append(SKAction.move(to: CGPoint(x: position, y: -700), duration: animationDuration))
        actionArray.append(SKAction.removeFromParent())
        
        enemy.run(SKAction.sequence(actionArray))
    }
    
    func fireLaser() {
        
        //Sound effect
        self.run(SKAction.playSoundFileNamed("laser.wav", waitForCompletion: false))
        
        //Create and position laser
        let playerLaser = SKSpriteNode(imageNamed: "laser")
        playerLaser.position = player.position
        playerLaser.position.y += 65
        
        //Laser Physics
        playerLaser.physicsBody = SKPhysicsBody(circleOfRadius: playerLaser.size.width / 2)
        playerLaser.physicsBody?.isDynamic = true
        
        playerLaser.physicsBody?.categoryBitMask = playerLaserCategory
        playerLaser.physicsBody?.contactTestBitMask = astroidCategory | enemyCategory
        //avoid any unwanted collisions
        //playerLaser.physicsBody?.collisionBitMask = 0
        playerLaser.physicsBody?.usesPreciseCollisionDetection = true
        
        addChild(playerLaser)
        
        //Animation for laser firing
        let animationDuration:TimeInterval = 0.3
        
        //Clean up, removes laser blast from game
        var actionArray = [SKAction]()
        actionArray.append(SKAction.move(to: CGPoint(x: player.position.x, y: self.frame.size.height), duration: animationDuration))
        actionArray.append(SKAction.removeFromParent())
        
        playerLaser.run(SKAction.sequence(actionArray))
    }
    
    //Function for physics to know what object hit what
    func didBegin(_ contact: SKPhysicsContact) {
        var A:SKPhysicsBody
        var B:SKPhysicsBody
     
        if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask {
            A = contact.bodyA
            B = contact.bodyB
        } else {
            A = contact.bodyB
            B = contact.bodyA
        }
        
        //PlayerLaser is A and Astroid is B
        if (A.categoryBitMask & playerLaserCategory) != 0 && (B.categoryBitMask & astroidCategory) != 0 {
            playerLaserHitAstroid(laserNode: A.node as! SKSpriteNode, astroidNode: B.node as! SKSpriteNode)
        }
        //PlayerLaser is A and Enemy is B
        else if (A.categoryBitMask & playerLaserCategory) != 0 && (B.categoryBitMask & enemyCategory) != 0 {
            playerLaserHitEnemy(laserNode: A.node as! SKSpriteNode, enemyNode: B.node as! SKSpriteNode)
        }
        //Player is A and Astroid is B
        else if (A.categoryBitMask & playerCategory) != 0 && (B.categoryBitMask & astroidCategory) != 0 {
            playerHitAstroid(playerNode: A.node as! SKSpriteNode, astroidNode: B.node as! SKSpriteNode)
        }
        //Player is A and Enemy is B
        else if (A.categoryBitMask & playerCategory) != 0 && (B.categoryBitMask & enemyCategory) != 0 {
            playerHitEnemy(playerNode: A.node as! SKSpriteNode, enemyNode: B.node as! SKSpriteNode)
        }
    }
    
    //Function for playerLaser to destroy Astroid
    func playerLaserHitAstroid (laserNode:SKSpriteNode, astroidNode:SKSpriteNode) {
        
        //Create explosion effect
        let explosion = SKEmitterNode(fileNamed: "Explosion")!
        explosion.position = astroidNode.position
        addChild(explosion)
        
        //Play explosion sound effect
        self.run(SKAction.playSoundFileNamed("explosion.wav", waitForCompletion: false))
        
        //remove sprites
        laserNode.removeFromParent()
        astroidNode.removeFromParent()
        
        //Remove explosion effect after a delay
        self.run(SKAction.wait(forDuration: 2)) {
            explosion.removeFromParent()
        }
        print("laser hit astroid")
        
        //Add score
        score += 5
    }
    
    //Function for playerLaser to destroy Enemy
    func playerLaserHitEnemy (laserNode:SKSpriteNode, enemyNode:SKSpriteNode) {
        
        //Create explosion effect
        let explosion = SKEmitterNode(fileNamed: "Explosion")!
        explosion.position = enemyNode.position
        addChild(explosion)
        
        //Play explosion sound effect
        self.run(SKAction.playSoundFileNamed("explosion.wav", waitForCompletion: false))
        
        //remove sprites
        laserNode.removeFromParent()
        enemyNode.removeFromParent()
        
        //Remove explosion effect after a delay
        self.run(SKAction.wait(forDuration: 2)) {
            explosion.removeFromParent()
        }
        print("laser hit enemy")
        
        //Add score
        score += 10
    }
    
    //Function for when player and astroid collide
    func playerHitAstroid(playerNode:SKSpriteNode, astroidNode:SKSpriteNode) {
        
        let explosionA = SKEmitterNode(fileNamed: "Explosion")!
        explosionA.position = astroidNode.position
        explosionA.zPosition = 3
        addChild(explosionA)
        
        print("Player hit astroid")
        
//        let explosionB = SKEmitterNode(fileNamed: "Explosion")!
//        explosionB.position = playerNode.position
//        explosionB.zPosition = 3
//        addChild(explosionB)
        
        //Play explosion sound effect
        self.run(SKAction.playSoundFileNamed("explosion.wav", waitForCompletion: false))
        
        //remove sprites
        //playerNode.removeFromParent()
        astroidNode.removeFromParent()
        
        //Remove explosion effect after a delay
        self.run(SKAction.wait(forDuration: 2)) {
            explosionA.removeFromParent()
            //explosionB.removeFromParent()
        }
        
        //Removes a life when hit
        if livesArray.count > 0 {
            let lifeNode = livesArray.first
            lifeNode?.removeFromParent()
            livesArray.removeFirst()
        }
        
        //Remove player when all lives are gone
        if livesArray.count == 0 {
            playerNode.removeFromParent()
            let transition = SKTransition.flipHorizontal(withDuration: 0.5)
            let gameOver = GameOverScene(fileNamed: "GameOverScene")!
            gameOver.score = self.score
            gameOver.scaleMode = scaleMode
            self.view?.presentScene(gameOver, transition: transition)
        }
    }
    
    //Function for when player and enemy collide
    func playerHitEnemy(playerNode:SKSpriteNode, enemyNode:SKSpriteNode) {
        
        let explosionA = SKEmitterNode(fileNamed: "Explosion")!
        explosionA.position = enemyNode.position
        explosionA.zPosition = 3
        addChild(explosionA)
        
        print("Player hit enemy")
        
//        let explosionB = SKEmitterNode(fileNamed: "Explosion")!
//        explosionB.position = playerNode.position
//        explosionB.zPosition = 3
//        addChild(explosionB)
    
        //Play explosion sound effect
        self.run(SKAction.playSoundFileNamed("explosion.wav", waitForCompletion: false))
        
        //remove sprites
        //playerNode.removeFromParent()
        enemyNode.removeFromParent()
        
        //Remove explosion effect after a delay
        self.run(SKAction.wait(forDuration: 2)) {
            explosionA.removeFromParent()
            //explosionB.removeFromParent()
        }
        
        //Removes a life when hit
        if livesArray.count > 0 {
            let lifeNode = livesArray.first
            lifeNode?.removeFromParent()
            livesArray.removeFirst()
        }
        
        //Remove player when all lives are gone
        if livesArray.count == 0 {
            playerNode.removeFromParent()
            let transition = SKTransition.flipHorizontal(withDuration: 0.5)
            let gameOver = GameOverScene(fileNamed: "GameOverScene")!
            gameOver.score = self.score
            gameOver.scaleMode = scaleMode
            self.view?.presentScene(gameOver, transition: transition)
        }
        
    }
  
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        fireLaser()
    }
}

Solution

  • I wouldn't be so sure that the problem is solved. When I tried your code, it crashed cause one of the nodes of contact bodies was nil. I was able to produce crash when creating a physics body from texture, from rectangle, with a circle. It doesn't really matter... The problem is not that :)

    The cause of this, is that you are removing your nodes before physics simulations are done.

    Take a look at how one frame looks like:

    cycle

    So what was happening is that you remove your nodes before physics simulation is done, so Physics engine retain physics body cause its needed to finish calculations, but node is removed.

    And thus nil in didBegin. So the solution is to make a variable that will hold nodes for removal:

    private var trash:[SKNode] = []
    

    Then at every place you have node with physics body do this:

    (say your playerHitAsteroid method)

    trash.append(laserNode)
    trash.append(astroidNode)
        
     self.run(SKAction.wait(forDuration: 2)) {[weak self] in
            guard let `self` = self else {return}
            self.trash.append(explosion)
     }
    

    You have few more places to change this in application. Take a look a this part too:

    if livesArray.count == 0 {
       trash.append(playerNode)
               
       print("Game over")
    }
    

    and some more. But when you fix it at all places like this, you are ready to implement actual removal by overriding didSimulatePhysics

     override func didSimulatePhysics() {
            //first go through every node and remove it from parent
            trash.map { node in
                node.run(SKAction.sequence([SKAction.fadeOut(withDuration: 0.25), SKAction.removeFromParent()]))
            }
            trash.removeAll() // then empty thrash array before next frame
        }
    

    And finally you can change didBegin like this, just to catch this error immediately. Which won't happen if you follow this strategy which is:

    • Remove nodes with physics bodies in didSimulatePhysics
    • Set correctly bit masks (which as it seems, you have done correctly)

    Maybe its worth of mention to be careful with Timer too. Check this out. It was long time ago, maybe something has changed, I didn't test it recently, but still I would prefer update method or SKAction for time related actions in my game.

    So, change didBegin like this:

    func didBegin(_ contact: SKPhysicsContact) {
            guard let nodeA = contact.bodyA.node, let nodeB = contact.bodyB.node else {
                
                fatalError("Physics body without its node detected!")
            }
            let A = contact.bodyA
            let B = contact.bodyB
            
            //PlayerLaser is A and Astroid is B
            if (A.categoryBitMask & playerLaserCategory) != 0 && (B.categoryBitMask & astroidCategory) != 0 {
                playerLaserHitAstroid(laserNode: A.node as! SKSpriteNode, astroidNode: B.node as! SKSpriteNode)
            }
            //PlayerLaser is A and Enemy is B
            else if (A.categoryBitMask & playerLaserCategory) != 0 && (B.categoryBitMask & enemyCategory) != 0 {
                playerLaserHitEnemy(laserNode: A.node as! SKSpriteNode, enemyNode: B.node as! SKSpriteNode)
            }
            //Player is A and Astroid is B
            else if (A.categoryBitMask & playerCategory) != 0 && (B.categoryBitMask & astroidCategory) != 0 {
                playerHitAstroid(playerNode: A.node as! SKSpriteNode, astroidNode: B.node as! SKSpriteNode)
            }
            //Player is A and Enemy is B
            else if (A.categoryBitMask & playerCategory) != 0 && (B.categoryBitMask & enemyCategory) != 0 {
                playerHitEnemy(playerNode: A.node as! SKSpriteNode, enemyNode: B.node as! SKSpriteNode)
            }
        }
    

    Not needed, but I think this way of implementing contact detection (by switching mask) is a bit more readable, so if you want, take a peek.

    One suggestion unrelated to this node removals... Don't use that much forced unwrappings! :D