UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Tilt to move: CMMotionManager

We're going to control this game using the accelerometer that comes as standard on all iPads, but it has a problem: it doesn't come as standard on any Macs, which means we either resign ourselves to testing only on devices or we put in a little hack. This course isn't calling Giving Up with Swift, so we're going to add a hack – in the simulator you'll be able to use touch, and on devices you'll have to use tilting.

To get started, add this property so we can reference the player throughout the game:

var player: SKSpriteNode!

We're going to add a dedicated createPlayer() method that loads the sprite, gives it circle physics, and adds it to the scene, but it's going to do three other things that are important.

First, it's going to set the physics body's allowsRotation property to be false. We haven't changed that so far, but it does what you might expect – when false, the body no longer rotates. This is useful here because the ball looks like a marble: it's shiny, and those reflections wouldn't rotate in real life.

Second, we're going to give the ball a linearDamping value of 0.5, which applies a lot of friction to its movement. The game will still be hard, but this does help a little by slowing the ball down naturally.

Finally, we'll be combining three values together to get the ball's contactTestBitMask: the star, the vortex and the finish.

Here's the code for createPlayer():

func createPlayer() {
    player = SKSpriteNode(imageNamed: "player")
    player.position = CGPoint(x: 96, y: 672)
    player.zPosition = 1
    player.physicsBody = SKPhysicsBody(circleOfRadius: player.size.width / 2)
    player.physicsBody?.allowsRotation = false
    player.physicsBody?.linearDamping = 0.5

    player.physicsBody?.categoryBitMask = CollisionTypes.player.rawValue
    player.physicsBody?.contactTestBitMask = CollisionTypes.star.rawValue | CollisionTypes.vortex.rawValue | CollisionTypes.finish.rawValue
    player.physicsBody?.collisionBitMask = CollisionTypes.wall.rawValue
    addChild(player)
}

You can go ahead and add a call to createPlayer() directly after the call to loadLevel() inside didMove(to:). Note: you must create the player after the level, otherwise it will appear below vortexes and other level objects.

If you try running the game now, you'll see the ball drop straight down until it hits a wall, then it bounces briefly and stops. This game has players looking down on their iPad, so by default there ought to be no movement – it's only if the player tilts their iPad down that the ball should move downwards.

The ball is moving because the scene's physics world has a default gravity roughly equivalent to Earth's. We don't want that, so in didMove(to:) add this somewhere:

physicsWorld.gravity = .zero

Playing the game now hasn't really solved much: sure, the ball isn't moving now, but… the ball isn't moving now! This would make for a pretty terrible game on the App Store.

Before we get onto how to work with the accelerometer, we're going to put together a hack that lets you simulate the experience of moving the ball using touch. What we're going to do is catch touchesBegan(), touchesMoved(), and touchesEnded(), and use them to set or unset a new property called lastTouchPosition. Then in the update() method we'll subtract that touch position from the player's position, and use it set the world's gravity.

It's a hack. And if you're happy to test on a device, you don't really need it. But if you're stuck with the iOS Simulator or are just curious, let's put in the hack. First, declare the new property:

var lastTouchPosition: CGPoint?

Now use touchesBegan() and touchesMoved() to set the value of that property using the same three lines of code, like this:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch = touches.first else { return }
    let location = touch.location(in: self)
    lastTouchPosition = location
}

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch = touches.first else { return }
    let location = touch.location(in: self)
    lastTouchPosition = location
}

When touchesEnded() is called, we need to set the property to be nil – it is optional, after all:

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    lastTouchPosition = nil
}

Easy, I know, but it gets (only a little!) trickier in the update() method. This needs to unwrap our optional property, calculate the difference between the current touch and the player's position, then use that to change the gravity value of the physics world. Here it is:

override func update(_ currentTime: TimeInterval) {
    if let currentTouch = lastTouchPosition {
        let diff = CGPoint(x: currentTouch.x - player.position.x, y: currentTouch.y - player.position.y)
        physicsWorld.gravity = CGVector(dx: diff.x / 100, dy: diff.y / 100)
    }
}

This is clearly not a permanent solution, but it's good enough that you can run the app now and test it out.

Now for the new bit: working with the accelerometer. This is easy to do, which is remarkable when you think how much is happening behind the scenes.

All motion detection is done with an Apple framework called Core Motion, and most of the work is done by a class called CMMotionManager. Using it here won't require any special user permissions, so all we need to do is create an instance of the class and ask it to start collecting information. We can then read from that information whenever and wherever we need to, and in this project the best place is update().

Add import CoreMotion just above the import SpriteKit line at the top of your game scene, then add this property:

var motionManager: CMMotionManager!

Now it's just a matter of creating the object and asking it start collecting accelerometer data. This is done using the startAccelerometerUpdates() method, which instructs Core Motion to start collecting accelerometer information we can read later. Put this this into didMove(to:):

motionManager = CMMotionManager()
motionManager.startAccelerometerUpdates()

The last thing to do is to poll the motion manager inside our update() method, checking to see what the current tilt data is. But there's a complication: we already have a hack in there that lets us test in the simulator, so we want one set of code for the simulator and one set of code for devices.

Swift solves this problem by adding special compiler instructions. If the instruction evaluates to true it will compile one set of code, otherwise it will compile the other. This is particularly helpful once you realize that any code wrapped in compiler instructions that evaluate to false never get seen – it's like they never existed. So, this is a great way to include debug information or activity in the simulator that never sees the light on devices.

The compiler directives we care about are: #if targetEnvironment(simulator), #else and #endif. As you can see, this is mostly the same as a standard Swift if/else block, although here you don't need braces because everything until the #else or #endif will execute.

The code to read from the accelerometer and apply its tilt data to the world gravity look like this:

if let accelerometerData = motionManager.accelerometerData {
    physicsWorld.gravity = CGVector(dx: accelerometerData.acceleration.y * -50, dy: accelerometerData.acceleration.x * 50)
}

The first line safely unwraps the optional accelerometer data, because there might not be any available. The second line changes the gravity of our game world so that it reflects the accelerometer data. You're welcome to adjust the speed multipliers as you please; I found a value of 50 worked well.

Note that I passed accelerometer Y to CGVector's X and accelerometer X to CGVector's Y. This is not a typo! Remember, your device is rotated to landscape right now, which means you also need to flip your coordinates around.

We need to put that code inside the current update() method, wrapped inside the new compiler directives. Here's how the method should look now:

override func update(_ currentTime: TimeInterval) {
#if targetEnvironment(simulator)
    if let currentTouch = lastTouchPosition {
        let diff = CGPoint(x: currentTouch.x - player.position.x, y: currentTouch.y - player.position.y)
        physicsWorld.gravity = CGVector(dx: diff.x / 100, dy: diff.y / 100)
    }
#else
    if let accelerometerData = motionManager.accelerometerData {
        physicsWorld.gravity = CGVector(dx: accelerometerData.acceleration.y * -50, dy: accelerometerData.acceleration.x * 50)
    }
#endif
}

If you can test on a device, please do. It took only a few lines of code, but the game is now adapting beautifully to device tilting!

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until April 28th.

Click to save your free spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.5/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.