How to Make a Game Like Stack
In this tutorial, you’ll learn how to make a game like Stack using SceneKit and Swift. By Brody Eller.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Handling Perfect Matches
To handle the "perfect matching" case, add the following method under addBrokenBlock(_:)
:
func checkPerfectMatch(_ currentBoxNode: SCNNode) {
if height % 2 == 0 && absoluteOffset.z <= 0.03 {
currentBoxNode.position.z = previousPosition.z
currentPosition.z = previousPosition.z
perfectMatches += 1
if perfectMatches >= 7 && currentSize.z < 1 {
newSize.z += 0.05
}
offset = previousPosition - currentPosition
absoluteOffset = offset.absoluteValue()
newSize = currentSize - absoluteOffset
} else if height % 2 != 0 && absoluteOffset.x <= 0.03 {
currentBoxNode.position.x = previousPosition.x
currentPosition.x = previousPosition.x
perfectMatches += 1
if perfectMatches >= 7 && currentSize.x < 1 {
newSize.x += 0.05
}
offset = previousPosition - currentPosition
absoluteOffset = offset.absoluteValue()
newSize = currentSize - absoluteOffset
} else {
perfectMatches = 0
}
}
If the player stops the block within 0.03 of the previous position, you’ll consider this a a perfect match. You set the position of the current block equal to the position of the previous block, since it’s not quite a perfect mathematical match, but it’s close enough.
By setting the current and previous positions equal, you make the perfect match mathematically correct and then recalculate the offset and new size. Call this method right after you calculate the offset and new size inside handleTap(_:)
:
checkPerfectMatch(currentBoxNode)
Handling Misses
Now you've covered the cases where the player matches perfectly and when they partially match, but you haven't covered the case when the player misses.
Add the following right above the call to checkPerfectMatch(_:)
inside handleTap(_:)
:
if height % 2 == 0 && newSize.z <= 0 {
height += 1
currentBoxNode.physicsBody = SCNPhysicsBody(type: .dynamic,
shape: SCNPhysicsShape(geometry: currentBoxNode.geometry!, options: nil))
return
} else if height % 2 != 0 && newSize.x <= 0 {
height += 1
currentBoxNode.physicsBody = SCNPhysicsBody(type: .dynamic,
shape: SCNPhysicsShape(geometry: currentBoxNode.geometry!, options: nil))
return
}
If the player misses the block, the new size calculation will be negative, so you can check for this to see if the player has missed. If the player has missed, you increment the height by one so that the movement code is no longer moving the current block. Then you add a dynamic physics body so the block will fall.
Finally, you return
so that the code after does not run, such as checkPerfectMatch(_:)
, and addBrokenBlock(_:)
.
Adding Sound Effects
Since the audio files are very short, it makes sense to pre-load the audio. Add a new dictionary property named sounds to the variable declarations:
var sounds = [String: SCNAudioSource]()
Next, add these two methods below viewDidLoad
:
func loadSound(name: String, path: String) {
if let sound = SCNAudioSource(fileNamed: path) {
sound.isPositional = false
sound.volume = 1
sound.load()
sounds[name] = sound
}
}
func playSound(sound: String, node: SCNNode) {
node.runAction(SCNAction.playAudio(sounds[sound]!, waitForCompletion: false))
}
The first method loads the audio file at the path specified and stores it inside the sounds
dictionary. The second method plays the audio file stored in the sounds
dictionary.
Add these inside the middle of viewDidLoad()
:
loadSound(name: "GameOver", path: "HighRise.scnassets/Audio/GameOver.wav")
loadSound(name: "PerfectFit", path: "HighRise.scnassets/Audio/PerfectFit.wav")
loadSound(name: "SliceBlock", path: "HighRise.scnassets/Audio/SliceBlock.wav")
There are a few places where you'll need to play the sound effects. Inside handleTap(_:)
, add this line inside each section of the if
statement that checks whether the player missed the block, but before the return
statement:
playSound(sound: "GameOver", node: currentBoxNode)
Add this line below the call to addNewBlock
:
playSound(sound: "SliceBlock", node: currentBoxNode)
Scroll down to checkPerfectMatch(_:)
and add this line inside both sections of the if statement:
playSound(sound: "PerfectFit", node: currentBoxNode)
Build and run — things feel much more fun with some sounds, don’t they?
Handling Win/Lose Condition
What good is a game that doesn't end? You're going to fix that right now! :]
Head into Main.storyboard and drag a new button onto the view. Change the text color's hex value to #FF0000 and its text to Play. Then change its font to Custom, Helvetica Neue, 66.
Next, align the button to the center and pin it to the bottom with a constant of 100.
Connect an outlet to the view controller titled playButton
. Then create an action titled playGame
and place this code inside:
playButton.isHidden = true
let gameScene = SCNScene(named: "HighRise.scnassets/Scenes/GameScene.scn")!
let transition = SKTransition.fade(withDuration: 1.0)
scnScene = gameScene
let mainCamera = scnScene.rootNode.childNode(withName: "Main Camera", recursively: false)!
scnView.present(scnScene, with: transition, incomingPointOfView: mainCamera, completionHandler: nil)
height = 0
scoreLabel.text = "\(height)"
direction = true
perfectMatches = 0
previousSize = SCNVector3(1, 0.2, 1)
previousPosition = SCNVector3(0, 0.1, 0)
currentSize = SCNVector3(1, 0.2, 1)
currentPosition = SCNVector3Zero
let boxNode = SCNNode(geometry: SCNBox(width: 1, height: 0.2, length: 1, chamferRadius: 0))
boxNode.position.z = -1.25
boxNode.position.y = 0.1
boxNode.name = "Block\(height)"
boxNode.geometry?.firstMaterial?.diffuse.contents = UIColor(colorLiteralRed: 0.01 * Float(height),
green: 0, blue: 1, alpha: 1)
scnScene.rootNode.addChildNode(boxNode)
You'll notice that you're resetting all the game's variables to their default value and adding the first block.
Since you are now adding the first block here, remove the following lines of code out the viewDidLoad(_:)
again, specifically from the declaration of blockNode
until you add it to the scene.
//1
let blockNode = SCNNode(geometry: SCNBox(width: 1, height: 0.2, length: 1, chamferRadius: 0))
blockNode.position.z = -1.25
blockNode.position.y = 0.1
blockNode.name = "Block\(height)"
//2
blockNode.geometry?.firstMaterial?.diffuse.contents =
UIColor(colorLiteralRed: 0.01 * Float(height), green: 0, blue: 1, alpha: 1)
scnScene.rootNode.addChildNode(blockNode)
Define a new method below the method you just created:
func gameOver() {
let mainCamera = scnScene.rootNode.childNode(
withName: "Main Camera", recursively: false)!
let fullAction = SCNAction.customAction(duration: 0.3) { _,_ in
let moveAction = SCNAction.move(to: SCNVector3Make(mainCamera.position.x,
mainCamera.position.y * (3/4), mainCamera.position.z), duration: 0.3)
mainCamera.runAction(moveAction)
if self.height <= 15 {
mainCamera.camera?.orthographicScale = 1
} else {
mainCamera.camera?.orthographicScale = Double(Float(self.height/2) /
mainCamera.position.y)
}
}
mainCamera.runAction(fullAction)
playButton.isHidden = false
}
Here, you zoom out the camera to reveal the entire tower. At the end, you set the play button to visible so the player can start a new game.
Place the following call to gameOver()
in the missed block if
statement inside handleTap(_:)
, above the return
statement and inside both parts of the if
statement:
gameOver()
Build and run. You should now be able to start a new game if — I mean when — you lose. :]