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()
}
}
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:
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:
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