Day 77 - 100 Days of Swift

10 minute read

Project 23 (part 1)

Day 77 is the first part of the twenty-third project. It is a Fruit Ninja style game where penguins fly through the screen and you attempt to swipe through them. Today you get the game set up, you add the code to display the user’s swipe and you add the code to generate enemies and toss them onto the screen.

First, you make a new SpriteKit game and do the normal clean up of the template file. Then you add the background and set up the physics in didMove(to:). The changes to the physics just let things float through the air a little more slowly than they would in real life:

1
2
3
4
5
6
7
8
9
10
11
12
let background = SKSpriteNode(imageNamed: "sliceBackground")
background.position = CGPoint(x: 512, y: 384)
background.blendMode = .replace
background.zPosition = -1
addChild(background)

physicsWorld.gravity = CGVector(dx: 0, dy: -6)
physicsWorld.speed = 0.85

createScore()
createLives()
createSlices()

createScore and createLives are just helper functions to set up those nodes in the scene:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var gameScore: SKLabelNode!
var score = 0 {
    didSet {
        gameScore.text = "Score: \(score)"
    }
}

var livesImages: [SKSpriteNode] = []
var lives = 3

private func createScore() {
    gameScore = SKLabelNode(fontNamed: "Chalkduster")
    gameScore.horizontalAlignmentMode = .left
    gameScore.fontSize = 48
    addChild(gameScore)

    gameScore.position = CGPoint(x: 8, y: 8)
    score = 0
}

private func createLives() {
    (0 ..< 3).forEach {
        let spriteNode = SKSpriteNode(imageNamed: "sliceLife")

        let x = CGFloat(834 + ($0 * 70))
        spriteNode.position = CGPoint(x: x, y: 720)
        addChild(spriteNode)

        livesImages.append(spriteNode)
    }
}

That leads to a layout that looks like this:

Screenshot of layout

The createSlices method sets up the SKShapeNodes that we’ll use to display the user’s swipe. They are two identical paths, one white and one yellow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private func createSlices() {
    activeSliceBG = SKShapeNode()
    activeSliceBG.zPosition = 2

    activeSliceFG = SKShapeNode()
    activeSliceFG.zPosition = 3

    activeSliceBG.strokeColor = UIColor(red: 1,
                                        green: 0.9,
                                        blue: 0,
                                        alpha: 1)
    activeSliceBG.lineWidth = 9
    activeSliceBG.lineCap = .round

    activeSliceFG.strokeColor = UIColor.white
    activeSliceFG.lineWidth = 5
    activeSliceFG.lineCap = .round

    addChild(activeSliceBG)
    addChild(activeSliceFG)
}

Next you add the logic for tracking and displaying the path of the user’s swipe. You need an array to keep track of the active points and then you redraw the path whenever the user moves their touch:

1
2
3
4
5
6
7
override func touchesMoved(_ touches: Set<UITouch>,
                           with event: UIEvent?) {
    guard let touch = touches.first else { return }
    let location = touch.location(in: self)
    activeSlicePoints.append(location)
    redrawActiveSlice()
}

Then, when the user lifts their finger, we need to fade out the path:

1
2
3
4
5
6
override func touchesEnded(_ touches: Set<UITouch>,
                           with event: UIEvent?) {
    let fadeOut = SKAction.fadeOut(withDuration: 0.25)
    activeSliceBG.run(fadeOut)
    activeSliceFG.run(fadeOut)
}

Then we also need to reset things, and kick off the path in touchesBegan. Here we need to make sure activeSlicePoints is empty, so we don’t have old points in the path, and we need to remove any actions from the nodes and make sure their alpha is 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
override func touchesBegan(_ touches: Set<UITouch>,
                           with event: UIEvent?) {
    guard let touch = touches.first else { return }

    activeSlicePoints.removeAll(keepingCapacity: true)

    let location = touch.location(in: self)
    activeSlicePoints.append(location)

    redrawActiveSlice()

    activeSliceBG.removeAllActions()
    activeSliceFG.removeAllActions()
    activeSliceBG.alpha = 1
    activeSliceFG.alpha = 1
}

As you may have noticed, we call redrawActiveSlice in a few places here, but haven’t written it yet. That is next:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private func redrawActiveSlice() {
    if activeSlicePoints.count < 2 {
        activeSliceBG.path = nil
        activeSliceFG.path = nil
        return
    }

    if activeSlicePoints.count > 12 {
        let toRemove = activeSlicePoints.count - 12
        activeSlicePoints.removeFirst(toRemove)
    }

    let path = UIBezierPath()
    path.move(to: activeSlicePoints[0])

    for i in 1 ..< activeSlicePoints.count {
        path.addLine(to: activeSlicePoints[i])
    }

    activeSliceBG.path = path.cgPath
    activeSliceFG.path = path.cgPath
}

This verifies that there are at least two points, because you can’t draw a path with only one point, and it makes sure that there are at most 12 points in the path so it doesn’t get too long. Then it draws a UIBezierPath and sets the path of the two shape nodes to it.

The last thing we do with the swipes is to add a sounds when the user swipes. We add a property to keep track of if the sound is already being played, so it doesn’t end up with a bunch of overlapping sound effects:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var isSwooshSoundActive = false

private func playSwooshSound() {
    isSwooshSoundActive = true

    let randomNumber = Int.random(in: 1...3)
    let soundName = "swoosh\(randomNumber).caf"

    let swooshSound = SKAction.playSoundFileNamed(soundName,
                                                  waitForCompletion: true)

    run(swooshSound) { [weak self] in
        self?.isSwooshSoundActive = false
    }
}

// At the end of touchesMoved
if !isSwooshSoundActive {
    playSwooshSound()
}

This leads to a pretty satisfying ability to swipe through the screen, even though there is nothing to actually cut through yet.

The last thing you do today is write the method that will generate the enemies. First, because at certain points in the game you either want to make sure there are no bombs or you want to make sure there are only bombs, you define an enum that you can pass into the function to communicate that:

1
2
3
enum ForceBomb {
    case never, always, random
}

Then you start writing the createEnemy method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var activeEnemies: [SKSpriteNode] = []

private func createEnemy(forceBomb: ForceBomb = .random) {
    let enemy: SKSpriteNode

    var enemyType = Int.random(in: 0...6)

    if forceBomb == .never {
        enemyType = 1
    } else if forceBomb == .always {
        enemyType = 0
    }

    if enemyType == 0 {
        // Bomb generation code will go here
    } else {
        enemy = SKSpriteNode(imageNamed: "penguin")
        run(SKAction.playSoundFileNamed("launch.caf",
                                        waitForCompletion: false))
        enemy.name = "enemy"
    }

    // Enemy position code will go here

    addChild(enemy)
    activeEnemies.append(enemy)
}

This picks a number to generate an enemy randomly, unless we’ve told it to force a bomb or not. If the number is 0, it will generate a bomb and if it is any other number, it will generate a penguin.

Then you add the position code where that comment is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
let randomX = Int.random(in: 64...960)
let randomPosition = CGPoint(x: randomX,
                             y: -128)
enemy.position = randomPosition

let randomAngularVelocity = CGFloat.random(in: -3...3 )
let randomXVelocity: Int

let outsideXVel = Int.random(in: 8...15)
let insideXVel = Int.random(in: 3...5)

if randomPosition.x < 256 {
    randomXVelocity = outsideXVel
} else if randomPosition.x < 512 {
    randomXVelocity = insideXVel
} else if randomPosition.x < 768 {
    randomXVelocity = -insideXVel
} else {
    randomXVelocity = -outsideXVel
}

let randomYVelocity = Int.random(in: 24...32)

enemy.physicsBody = SKPhysicsBody(circleOfRadius: 64)
enemy.physicsBody?.velocity = CGVector(dx: randomXVelocity * 40,
                                       dy: randomYVelocity * 40)
enemy.physicsBody?.angularVelocity = randomAngularVelocity
enemy.physicsBody?.collisionBitMask = 0

You set its starting position to be somewhere along the bottom of the screen and then calculate velocities that make sense based on where it’s position on screen is, so that it isn’t getting thrown to the right if it is at the far right of the screen to begin with. You also set the collisionBitMask to 0, so they don’t run into each other.

Then you add the bomb generation code. A lot of this is organized the way it is so that you can start and stop the sound effect for the fuse, and so the user doesn’t trigger the bomb by touching a random particle from the fuse emitter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import AVFoundation // Need AVFoundation to use AVAudioPlayer

var bombSoundEffect: AVAudioPlayer?

// Where the bomb code comment is
enemy = SKSpriteNode()
enemy.zPosition = 1
enemy.name = "bombContainer"

let bombImage = SKSpriteNode(imageNamed: "sliceBomb")
bombImage.name = "bomb"
enemy.addChild(bombImage)

if bombSoundEffect != nil {
    bombSoundEffect?.stop()
    bombSoundEffect = nil
}

if let path = Bundle.main.url(forResource: "sliceBombFuse",
                              withExtension: "caf") {
    if let sound = try? AVAudioPlayer(contentsOf: path) {
        bombSoundEffect = sound
        sound.play()
    }
}

if let emitter = SKEmitterNode(fileNamed: "sliceFuse") {
    emitter.position = CGPoint(x: 76, y: 64)
    enemy.addChild(emitter)
}

This will generate a bomb and start the sound effect running when it is on screen, but the sound effect never gets stopped until a new bomb is created so we also need to check to make sure there is still a bomb on screen in update. This also doesn’t work yet, because we are never removing the enemies from the activeEnemies array, but I’m assuming we’ll add that tomorrow:

1
2
3
4
5
6
7
8
9
10
11
override func update(_ currentTime: TimeInterval) {
    let bombCount = activeEnemies.reduce(0) {
        let modifier = $1.name == "bombContainer" ? 0 : 1
        return $0 + modifier
    }

    if bombCount == 0 {
        bombSoundEffect?.stop()
        bombSoundEffect = nil
    }
}

And that is it for today. You don’t actually call the createEnemy function anywhere today, but I threw it in a little timer in didMove(to:) just to see what it looks like:

1
2
3
4
Timer.scheduledTimer(withTimeInterval: 0.5,
                     repeats: true) { _ in
    self.createEnemy()
}

You can find my version of this project at the end of day 77 on Github here.