Day 77 - 100 Days of Swift
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:

The createSlices
method sets up the SKShapeNode
s 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.