Quantcast
Channel: Kodeco | High quality programming tutorials: iOS, Android, Swift, Kotlin, Unity, and more
Viewing all articles
Browse latest Browse all 4370

How to Save your Game’s Data: Part 1/2

$
0
0
Space game

Learn how to save your game’s data!

Unbelievably, in the “old days” many games lacked the ability to save data. You would literally have to beat the game in one sitting! This often caused players to experience emotions ranging from passing frustration to explosive rage, as Angry Video Game Nerd can attest.

In the great days of modern iOS, your game has no excuse because there are plenty of ways to save data, from NSUserDefaults to Core Data to iCloud and more!

In this two-part tutorial, you’re going to learn the best ways to save data in your games. Specifically, you’ll learn how to:

  • Separate your data: You’ll build a class to hold your game data and update it properly during gameplay.
  • Save your data: You’ll use NSCoding to persist your game data to the device.
  • Use iCloud: You’ll also learn how to use iCloud to allow the user to continue their game on a different device if they so desire.

By the end of both parts, you’ll have an awesome iCloud connected Space Shooter made with Sprite Kit:

sgd_01_completed

And yes, that’s a cat in the cockpit. Surely you know cats are avid pilots?

Get ready to start saving!

Getting Started

For this tutorial, you’ll build upon the game created in Tony Dahbura‘s Sprite Kit Space Shooter Tutorial. These exercises will be most useful if you go through that tutorial first, but if you’d rather not, at least scan the code so you’re familiar with how it works.

If you decide to skip the Space Shooter tutorial, you can just download the completed project.

Open up Space Shooter in Xcode, then build and run on an iOS device. You’ll need to run the game on an actual device since the accelerometer is required to steer the spaceship around all those asteroids.
sgd_02_start

To win the game just stay alive for 30 seconds. All you have to do is blast the asteroids out of your way to avoid deadly collisions. It’s not as easy as it sounds!

Game Data Class

Did you notice that the game doesn’t track the score, or the distance traveled by the ship? You’ll start by adding tracking for those two metrics so that you have something interesting to save across gameplay sessions.

In this section, you’ll create a new class to hold the game data. It’s a best practice to isolate your game’s data from the rest of the game; this abstraction makes it much easier to save the user’s progress from game to game.

In Xcode, go to File\New\File…, choose the iOS\Cocoa Touch\Objective-C class template and click Next. Name your new class RWGameData, make it a subclass of NSObject, click Next and then Create.

RWGameData will hold your game’s data, specifically, the score and distance traveled for the current game, as well as the player’s highest score and total distance flown. Open RWGameData.h and replace the default contents of this file with the following code:

#import <Foundation/Foundation.h>
 
@interface RWGameData : NSObject
 
@property (assign, nonatomic) long score;
@property (assign, nonatomic) int distance;
 
@property (assign, nonatomic) long highScore;
@property (assign, nonatomic) long totalDistance;
 
+(instancetype)sharedGameData;
-(void)reset;
 
@end

Here’s what each of those class properties will do:

  • score: The current game’s score
  • distance: The current game’s distance traveled (in space miles)
  • highScore: The player’s high score across all games (later on in this tutorial you’ll persist this high score)
  • totalDistance: The player’s total distance travelled across all games

Keep in mind that the logic necessary to keep this data separate by player and device will be more complex than just keeping everything in memory as you’re doing for now.

There are also two method definitions in your class interface. sharedGameData will give you access to this class’ singleton instance. As you may have guessed, reset will reset the current game data.

Open RWGameData.m and add the following method:

+ (instancetype)sharedGameData {
    static id sharedInstance = nil;
 
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedInstance = [[self alloc] init];
    });
 
    return sharedInstance;
}

This is a boilerplate Objective-C implementation of the singleton pattern. Using sharedGameData throughout your app will ensure that you’re always accessing the same RWGameData instance. You can use this same code any time you want to provide singleton access to one of your own classes.

Next, take care of the reset method, which will reset the game’s score and distance whenever the user starts a new game. Add this method inside RWGameData.m:

-(void)reset
{
    self.score = 0;
    self.distance = 0;
}

In this method you just set the score and distance properties to 0, so the user can start fresh.

Pimping up the HUD

Now that you have a class to store the game’s data, now you’ll modify the game to keep score with the new class.

Open MyScene.m, where the game logic resides, and add the following line at the top of the file to import your new class:

#import "RWGameData.h"

You’ll need a few labels to display progress to the user. Add the following instance variables to the @implementation section in MyScene.m:

SKLabelNode* _score;
SKLabelNode* _highScore;
SKLabelNode* _distance;

Still in MyScene.m, add the following method to initialize those labels and add them to the scene:

-(void)setupHUD
{
    _score = [[SKLabelNode alloc] initWithFontNamed:@"Futura-CondensedMedium"];
    _score.fontSize = 12.0;
    _score.position = CGPointMake(50, 7);
    _score.fontColor = [SKColor greenColor];
    [self addChild:_score];
 
    _distance = [[SKLabelNode alloc] initWithFontNamed:@"Futura-CondensedMedium"];
    _distance.fontSize = 12.0;
    _distance.position = CGPointMake(115, 7);
    _distance.fontColor = [SKColor orangeColor];
    [self addChild:_distance];
 
    _highScore = [[SKLabelNode alloc] initWithFontNamed:@"Futura-CondensedMedium"];
    _highScore.fontSize = 12.0;
    _highScore.position = CGPointMake(200, 7);
    _highScore.fontColor = [SKColor redColor];
    [self addChild:_highScore];
}

This code sets up _score, _distance, and _highScore by setting font face, size and color, and finally positioning all three labels at the bottom of the scene, out of the way of the space ship.

Next, you’ll need to call setupHUD: somewhere in your code. Add the following line to initWithSize:, just before the line that calls startBackgroundMusic:

[self setupHUD];

This will add the HUD labels to the scene just before starting the gameplay.

One final touch — at the end of startTheGame:, add the code to initialize the label’s values:

_highScore.text = [NSString stringWithFormat:@"High: %li pt", [RWGameData sharedGameData].highScore];
_score.text = @"0 pt";
_distance.text = @"";

This will set the high score label to whatever you stored last in RWGameData‘s highScore property and will reset the values of _score and _distance. You’re all ready to go! Build and run the project right now.

sgd_03_initialHUD

The HUD is now visible, but the game doesn’t keep score yet – that’s the next thing to add to the app.

Keeping Score

There’s no sense in playing the game if you’re not scoring points, right? Every time the player shoots an asteroid he or she should earn a point and the score should update.

Stay in MyScene.m, scroll to update: and look for the if statement that checks for [shipLaser intersectsNode:asteroid]. This code branch executes whenever a ship laser contacts an asteroid – the code hides the laser and the asteroid, then spawns a new explosion on the position of the impact. Boom!

This is a good spot to add the score-keeping code. Add the following just above the continue; statement at the end of this if block:

[RWGameData sharedGameData].score += 10;
_score.text = [NSString stringWithFormat:@"%li pt", [RWGameData sharedGameData].score];

First, you increase the player’s score by 10 points, then you update the label’s text with the new amount. Build and run, then shoot a few asteroids to try out the scoring system:

sgd_04_HUD_score

If you play a couple of games you’ll notice the score does not reset between games. But why does this happen? You certainly remember writing a reset method in RWGameData, right?

Long story short, you need to add code to invoke reset. Doh!

You want to reset the game data both when the user fails and completes the game. In MyScene.m, just above the line that calls removeAllActions:, add the following line of code inside endTheScene:.

[[RWGameData sharedGameData] reset];

This line will take care of resetting the per-game data every time the game is over. Build and run to see this in action. The score will now reset every time you restart the game.

Next, you’re going to update the high score at the end of every game – but you’ll need to check if the current score is higher than the stored highest score, and update the high score as needed. To make the reward more complex, you’ll need to implement a more complex reward logic that updates the highest score only when the player survives for at least 30 seconds.

The code that detects a successful completion of the game is towards the end of update: in MyScene.m. Find this line [self endTheScene:kEndReasonWin]; and add the following code above it to update the high score:

[RWGameData sharedGameData].highScore = MAX([RWGameData sharedGameData].score,
                                            [RWGameData sharedGameData].highScore);

The MAX() function compares the current score to the high score and sends back the bigger number. You just set [RWGameData sharedGameData].highScore to that number and that’s it. Adding it is easy enough when you know where to put that line in the project’s code.

Next, you’re going to track the distance the space ship travels during a game.

Still inside update: in MyScene.m, add the following code a bit above where you just updated highScore and immediately before the if statement that checks whether _lives <= 0:

static NSTimeInterval _lastCurrentTime = 0;
if (currentTime-_lastCurrentTime>1) {
  [RWGameData sharedGameData].distance++;
  [RWGameData sharedGameData].totalDistance++;
  _distance.text = [NSString stringWithFormat:@"%li miles", [RWGameData sharedGameData].totalDistance];
  _lastCurrentTime = currentTime;
}

update: and currentTime parameters hold the time the method was called. The above code compares currentTime against _lastCurrentTime to see if at least one second passed since it last updated the distance.

To update the distance, you do a few things:

  • You increase the distance traveled this game and the total distance traversed across all games.
  • You update the distance label's text. Note you're displaying the total distance across all games, so it's easy to see if this saves properly when you add that later on.
  • Finally you update _lastCurrentTime, which ensures you won't update distance again until another second has passed.

That's about it. Build and run, and enjoy keeping score and tracking your progress.

sgd_05_HUD_distance

Persisting Data Between Launches

You're doing well so far, but did you notice that every time you launch the game, the high score and the total distance resets to zero? That's not really the result you're after, but it's helpful to take baby steps as you learn how to persist data between app launches.

Your next step will be to make the RWGameData class conform to the NSCoding protocol, which is one good way to persist game data on a device. The advantage of NSCoding over alternative persistence methods like Core Data is that it's nice and easy, and is ideal for a small amount of data like what you see with this game.

The NSCoding protocol declares two required methods:

  • encodeWithCoder:: This method converts your object into a buffer of data. You can think of it as "serializing" your class.
  • initWithCoder:: This method converts a buffer of data into your object. You can think of it as "deserializing" your class.

It's quite simple really - you have to implement one method for saving and one for loading; that's all there is to it. Now, you're going to see how precise data storage can be.

Open RWGameData.h and modify the @interface line so it looks like this:

@interface RWGameData : NSObject <NSCoding>

This declares that RWGameData conforms to the NSCoding protocol.

Switch back to RWGameData.m. You'll add encodeWithCoder: (just think of it as the method to "save" data) and two constants for the key names you'll use to store the data when encoding the class. Add the following code just below the @implementation line:

static NSString* const SSGameDataHighScoreKey = @"highScore";
static NSString* const SSGameDataTotalDistanceKey = @"totalDistance";
 
- (void)encodeWithCoder:(NSCoder *)encoder
{
  [encoder encodeDouble:self.highScore forKey: SSGameDataHighScoreKey];
  [encoder encodeDouble:self.totalDistance forKey: SSGameDataTotalDistanceKey];
}

encodeWithCoder: receives an NSCoder instance as a parameter. It's up to you to use this to store all the values you need persisted. Note that you'll persist only the high score and total distance. Since the other properties reset between games, there's no need to save them.

You probably already figured out how the encoding works. You call a method called encodeXxx:forKey: and provide a value and a key name, based on the type of your data. There are methods for encoding all the primitive types, like doubles, integers or booleans.

There's also a method to encode any object that supports NSCoding (encodeObject:). Many of the built-in classes like NSString, NSArray or NSDictionary implement NSCoding. You can always implement NSCoding on your own objects, much like how you're doing it here.

Note: If your class extends anything that conforms to the NSCoding protocol, you must call [super encodeWithCoder:encoder]; to ensure all of your object's data persists.

This is everything you need to do in this method, just supply values and keys to the encoder. Actually saving to the device is a separate task on your list, which you'll take on in a moment.

Now it's time to implement the opposite process - initializing a new instance with the data from a decoder. Add the following method to RWGameData.m:

- (instancetype)initWithCoder:(NSCoder *)decoder
{
  self = [self init];
  if (self) {
    _highScore = [decoder decodeDoubleForKey: SSGameDataHighScoreKey];
    _totalDistance = [decoder decodeDoubleForKey: SSGameDataTotalDistanceKey];
  }
  return self;
}

See how you start this method much as you would any other initializer, by calling some initializer on your parent class? If your class extends anything that conforms to the NSCoding protocol, you most likely need to call [super initWithCoder:decoder], but because this class extends NSObject, calling init here is fine.

Much the same way you used encodeDouble:forKey: to store a double value for a given key, you now use decodeDoubleForKey: to retrieve a double value from the NSCoder instance passed into the method.

By implementing these two methods, you added the ability to your class to save its current state and retrieve it with ease.

sgd_06_ohyeah

I'm sure you're eager to take your new NSCoding class for a test drive. However, you'll need to hold your horses just a bit longer. You still need to save the game data to a file.

First, you need to make sure the class will create a new empty instance whenever there's no persisted data, i.e. the very first time you run the game.

Inside RWGameData.m, add this simple helper method to construct the file path to store the game data:

+(NSString*)filePath
{
  static NSString* filePath = nil;
  if (!filePath) {
    filePath = 
      [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject]
       stringByAppendingPathComponent:@"gamedata"];
  }
  return filePath;
}

This method uses NSSearchPathForDirectoriesInDomains() to obtain the path to the app's document directory, then adds to it a file name of "gamedata", thus returning a fully qualified file path.

Next, you're going to use this method to check if there's a saved game data file already, and if so, load it and create a new class instance from it. Add this new method to RWGameData.m:

+(instancetype)loadInstance
{
  NSData* decodedData = [NSData dataWithContentsOfFile: [RWGameData filePath]];
  if (decodedData) {
    RWGameData* gameData = [NSKeyedUnarchiver unarchiveObjectWithData:decodedData];
    return gameData;
  }
 
  return [[RWGameData alloc] init];
}

First you get the file path where the stored game data file should be and then you try to create an NSData instance out of the file contents.

If decodedData is not nil, then that means the file content was read successfully and converted to an NSData instance. In that case (pay attention…here comes the magic) you create an RWGameData instance by calling NSKeyedUnarchiver's class method unarchiveObjectWithData:.

What unarchiveObjectWithData: does is to try to initialize a new RWGameData by invoking its initWithCoder: initializer with an NSCoder loaded with decodedData. (Try saying that aloud, three times, fast; it's a mouthful!)

In case decodedData was nil you just construct a new instance of the class by calling init.

One final touch for RWGameData.m - in sharedGameData, replace this line:

sharedInstance = [[self alloc] init];

with this one:

sharedInstance = [self loadInstance];

This will ensure that when you create the instance of the game data class it loads the contents of the previously stored file, provided one exists at the target file path.

sgd_07_sir

I don't always write a load method, but when I do I write a save method too.

That's a good piece of advice just there. Add a save method to RWGameData.m, as well:

-(void)save
{
  NSData* encodedData = [NSKeyedArchiver archivedDataWithRootObject: self];
  [encodedData writeToFile:[RWGameData filePath] atomically:YES];
}

This code is the exact reverse of what you implemented in loadInstance. First, you call [NSKeyedArchiver archivedDataWithRootObject:] to get encoded data out of your class instance; this calls encodeWithCoder: on your instance behind the scenes. Then writing the data to file is simply a matter of calling writeToFile:atomically:.

Remember to switch to RWGameData.h, and also to add the method signature inside the class interface:

-(void)save;

And that's all there is to archiving and unarchiving data using the device. Now you only need to call save every now and then, and you're good to go.

Open MyScene.m and add the following line to endTheScene:, just before the line that calls reset on the RWGameData singleton:

[[RWGameData sharedGameData] save];

This wraps up this section, so now it's time to give your game a try! Build and run, then play a few to see your high score persist between launches:

sgd_08_endlevel

Then stop the game from Xcode and launch the project again. When you start the game you'll see the high score you achieved during the previous launch. Success!

sgd_09_highscoresaved

More about NSCoding

Are you mumbling something under your breath? Did you say something to the effect of, "Yeah, when you need to save two double numbers it's all too easy"? Well, you're on the right track. There is more you can do with NSCoding, but the concept is remarkably similar to what you just did.

As I mentioned earlier, NSCoding includes helper methods to encode various types of primitives:

  • encodeBool:forKey:
  • encodeInt:forKey:
  • encodeInt32:forKey:
  • encodeInt64:forKey:
  • encodeFloat:forKey:

You can encode any object that implements NSCoding with this:

  • encodeObject:forKey:

If you can convert something to either NSData or a series of bytes, you can encode it with these methods:

  • encodeBytes:length:forKey:
  • encodeDataObject:forKey:

And finally, there are a few utility methods to ease the pain whenever you need to encode certain common structures:

  • encodeCGPoint:forKey:
  • encodeCGRect:forKey:
  • encodeCGSize:forKey:
Note:If you're curious to see more, check the headers for NSCoder and NSCoding. Also, check out our tutorial: NSCoding Tutorial for iOS: How To Save Your App Data

Say "Space Cheese"!

Are you ready to try something more interesting than storing numbers? Good! Next, you'll add the player's photo to RWGameData and save it to the device.

To work with images you'll need some extra assets unrelated to this tutorial, so download them now.

Unzip it and drag the ImageMasking folder into your Xcode project. Be sure you select both of the following: Copy items into destination group's folder (if needed) and Create groups for any added folders. You should see three new files in your project navigator:

NewFiles

The files are as follows:

  • 25_mask.png: This is a black and white mask matching the shape of the front window of the spaceship.
  • UIImage+Mask.h: This is a category on UIImage that provides a method to resize and mask a UIImage instance.
  • UIImage+Mask.m: This is the implementation of the method mentioned above.

What you're about to do is let the player take a photo, scale it, mask it and display it in the space ship's window.

Now, add the code for taking a photo. Open MyScene.m and in the file contents, close to the @implementation line, add a new instance variable under the rest of the ones you added earlier:

SKLabelNode* _takePhoto;

This instance variable will hold your new HUD button for making photos. Now scroll down to setupHUD: and add this code at the end of the method body:

_takePhoto = [[SKLabelNode alloc] initWithFontNamed:@"Futura-CondensedMedium"];
_takePhoto.name = @"TakePhotoButton";
_takePhoto.fontSize = 15.0;
_takePhoto.text = @"Pilot Photo";
_takePhoto.position = CGPointMake(self.size.width-40, self.size.height-20);
_takePhoto.fontColor = [SKColor yellowColor];
[self addChild:_takePhoto];

This will create a new label along the rest of the HUD, but this label will act like a button - just like the label that acts like a button to restart the game. You also need to add the code to handle touches.

Scroll to touchesBegan:withEvent:, and add this new chunk of code to handle touches on the Pilot photo button. Check the comments inside the code below to make sure you're adding the right code:

for (UITouch *touch in touches) {
    SKNode *n = [self nodeAtPoint:[touch locationInNode:self]];
    if (n != self && [n.name isEqual: @"restartLabel"]) {
        //[self.theParentView restartScene];
        [[self childNodeWithName:@"restartLabel"] removeFromParent];
        [[self childNodeWithName:@"winLoseLabel"] removeFromParent];
        [self startTheGame];
        return;
    }
 
    // here new code begins
    if (n != self && [n.name isEqualToString:@"TakePhotoButton"]) {
      [self takePhoto];
      return;
    }
    // new code ends
}

What this new code does is to check whether the name of the node being tapped equals to "TakePhotoButton", and if so invokes the takePhoto: of your scene, which you'll add to the class implementation now:

-(void)takePhoto
{
  //1
  self.paused = YES;
 
  //2
  UIImagePickerController* imagePickerController = [[UIImagePickerController alloc] init];
 
  //3
  [imagePickerController setSourceType:UIImagePickerControllerSourceTypeCamera];
  imagePickerController.delegate = self;
 
  //4  
  UIWindow* appWindow = [UIApplication sharedApplication].windows.firstObject;
  [appWindow.rootViewController presentViewController:imagePickerController animated:YES completion:nil];
}

This code does a number of things:

  1. First you pause the game to avoid killing the player while he or she takes a selfie.
  2. Then you create a new UIImagePickerController instance - this is a view controller that allows the user to take a photo.
  3. You set the source type for the image picker to be the camera and the delegate to be the scene class.
  4. Finally, you hold the app's window and present the image picker view controller from the window's top view controller. [TODO: Please check this last point for clarity and accuracy]

Finally, to remove those compiler warnings, you need to conform to a couple of protocols, required by UIImagePickerController.

Open MyScene.m and replace the @interface directive with the following:

@interface MyScene : SKScene<UIImagePickerControllerDelegate, UINavigationControllerDelegate>

The warnings will now disappear with the next project build.

This should be enough to see the camera view controller, so give it a try! Build and run, then tap the Pilot photo button on the top of the scene.

Note: Unfortunately, you can only run this portion of the code on a device. If you try to run it in the simulator, it will crash with a NSInvalidArgumentException since the camera is not available.

Oooops! You should see an error in the console like this:

Terminating app due to uncaught exception 'UIApplicationInvalidInterfaceOrientation', 
reason: 'Supported orientations has no common orientation with the application, 
and shouldAutorotate is returning YES'

This was (almost) unexpected. So, to find the trouble-maker, click on the project file and check the allowed screen orientations:

sgd_12_orientation

Bingo! The image picker view controller only supports portrait orientation, but the app doesn't support portrait - so this seems easy enough to correct, right? Just check the portrait checkbox and build and run.

sgd_13_orientationPort

Hmm…now when you tilt the device in a certain position the whole scene rotates and the display looks off-kilter. As it turns out, your game can't support both portrait and landscape orientations.

Now you're going to implement a little trickery to support only landscape while the player plays the game, and only portrait while they are taking a photo.

Open ViewController.h and add a new static variable above the @interface line:

static BOOL lockToPortraitOrientation = NO;

This is a static variable, which you'll set to YES when you present the image picker and set back to NO when the player wants to continue playing.

Switch to MyScene.m and add along the rest of the imports at the top of the file:

#import "ViewController.h"

Add the following as the first line of code in the takePhoto:

lockToPortraitOrientation = YES;

Most of your master plan is now completed, the only thing remaining is to make ViewController respect the value of lockToPortraitOrientation. Open ViewController.m and find the method called supportedInterfaceOrientations. Replace the contents of the method with the following:

if (lockToPortraitOrientation) {
  return UIInterfaceOrientationMaskPortrait;
} else {
  return UIInterfaceOrientationMaskLandscapeRight;
}

The code is straight forward; if lockToPortraitOrientation is YES the view controller allows only portrait orientation; otherwise it allows landscape only. Could something so simple actually work? Give it a try!

sgd_14_cameraview

The camera view shows up and you can snap a picture, nice! But did you notice there's another problem? update: doesn't pause while you take a photo. There's a quick fix for that too, just open MyScene.m and as first line of code in update: add:

if (self.paused) {
  return;
}

This stops all node actions while you're taking your mug shot. That's more like it!

Immortalizing Your Photo

Now you need to make sure the photo is useful to the player. In this section, you'll add the delegate methods to the image picker view controller to make sure your handsome face saves to the disc, along your high score and total distance flown.

First open RWGameData.h and add a new property:

@property (strong, nonatomic) UIImage* pilotPhoto;

Switch to RWGameData.m and along with the other constants near the top of the file, add this:

static NSString* const SSGameDataPilotPhotoKey = @"pilotPhoto";

This will be the key you use to send the photo to the encoder/decoder.[TODO:I replaced 'encode' with 'send' to reduce redundancy. Please confirm the word choice is suitable] Speaking of which, scroll to encodeWithCoder: and add the following to the end to encode the photo property:

if (self.pilotPhoto) {
  NSData* imageData = UIImagePNGRepresentation(self.pilotPhoto);
  [encoder encodeObject:imageData forKey: SSGameDataPilotPhotoKey];
}

This code is a tiny bit different from how you've encoded the rest of the properties.

Note how you check whether self.pilotPhoto is set, and encode that property only if it is. After all, there's no need to write anything if the user is camera-shy!

Encoding the photo is a bit different when you compare it to the rest of the properties. UIImage does not implement NSCoding, so you have to convert it to a buffer of bytes first.

To do this, you use UIImagePNGRepresentation to convert the image to NSData (which does implement NSCoding), and then you can use encodeObject:forKey: as usual.

Now scroll to initWithCoder:. Look inside the if statement, which is after the code that initializes the other properties; add this code to initialize the photo:

NSData* imageData = [decoder decodeObjectForKey: SSGameDataPilotPhotoKey];
if (imageData) {
  _pilotPhoto = [[UIImage alloc] initWithData:imageData];
}

You're reversing what you did earlier. First, you try to decode the object for the photo key. If it exists, you just create a new UIImage instance out of the data.

Implementing the image picker delegate methods in your scene is the last step to immortalizing the player's photo. Open MyScene.m and at the top, alongside the rest of the imports, add:

#import "UIImage+Mask.h"

First, create a method stub for adding the pilot. You will be adding to it later in the tutorial. Please this code at the bottom of MyScene.m, just before the @end directive.

-(void) setupPilot
{
    // Code to go here.
}

Next, create a new method underneath the previous one to handle a successful photo capture.

-(void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info
{
  //1
  lockToPortraitOrientation = NO;
 
  //2
  [picker dismissViewControllerAnimated:YES
    completion:^{
 
    //3
    UIImage* photoTaken = info[UIImagePickerControllerOriginalImage];
    UIImage* pilotImage = [photoTaken imageWithSize: CGSizeMake(25, 25) andMask:[UIImage imageNamed:@"25_mask.png"]];
 
    //4
    [RWGameData sharedGameData].pilotPhoto = pilotImage;
    [[RWGameData sharedGameData] save];
 
    //5
    [self setupPilot];
 
    //6                            
    self.paused = NO;
  }];
}

This delegate method received the captured photo, so you're good to process and use it. You do this in several steps:

  1. First you reset the screen orientation to landscape only.
  2. Then you dismiss the image picker view controller.
  3. When the view controller is dismissed, you grab the captured photo (located in info[UIImagePickerControllerOriginalImage]), then store a resized and masked version of it in pilotImage.
  4. In the next couple lines you store the photo in RWGameData and then invoke save to make sure the photo saves to the disc immediately.
  5. Then you call setupPilot, which displays the photo on screen.
  6. Finally, you un-pause the scene withsetupPilot, which you'll add in few moments

Now you'll wrap up the image picker part of the code. Add the delegate method, the one called when the user cancels the image capture:

-(void)imagePickerControllerDidCancel:(UIImagePickerController *)picker
{
    lockToPortraitOrientation = NO;
 
    [picker dismissViewControllerAnimated:YES
                               completion:^{
                                   self.paused = NO;
                               }];
}

This method just resets the orientation back to landscape, dismisses and image picker view controller, and un-pauses the scene.

You're at the last step before wrapping up! Now you just need to display the photo on the screen. Make sure setupPilot: looks like the following:

-(void)setupPilot
{
  //1
  if ([RWGameData sharedGameData].pilotPhoto) {
    //2
    UIImage* pilotImage = [[RWGameData sharedGameData].pilotPhoto imageWithSize: CGSizeMake(25, 25) andMask:[UIImage imageNamed:@"25_mask.png"]];
 
    //3
    [[_ship childNodeWithName:@"Pilot"] removeFromParent];
 
    //4
    SKTexture* pilotTexture = [SKTexture textureWithImage:pilotImage];
    SKSpriteNode* pilotSprite = [SKSpriteNode spriteNodeWithTexture: pilotTexture];
    pilotSprite.name = @"Pilot";
    pilotSprite.position = CGPointMake(28, 5);
    [_ship addChild: pilotSprite];
  }
}
  1. First, you check whether there's a pilot's photo. You're going to call this method when the game starts, so you need to check.
  2. Then you make sure the photo is properly sized and masked, because once you persist it via the encoder the masking will actually disappear.
  3. Check for an existing pilot photo node with name of "Pilot" and if found, remove it from the scene.
  4. Create a new texture from the pilot photo and create a sprite node out of the resulting texture. Finally, position the sprite node and add it as a child to the space ship.

Your app calls this method when you capture a new photo, and also when you build the scene and load the game data from a file. If the player sets his or her photo in a previous app launch, you'll need to load the photo when the game starts.

Do that in initWithSize:. Just find the line where you add the ship to the scene: [self addChild:_ship]; and add this above:

[self setupPilot];

Perfect! Now when you start the game the app will check for persisted photo, and if found, it'll display on-screen. If the player takes a new photo - it overwrites and saves the new one to the game data file.

Build and run. You should be able to take your photo and see yourself piloting that space ship! Yeah!

sgd_15_pilot_spaceship

Where To Go From Here?

Here is the example project up to this point.

Here's a recap of what you learned in the part I of this tutorial series:

  • How to encode/decode data to the disc
  • How to encode/decode more complex data (i.e. beyond primitives)
  • How to have optional game data (i.e. can be set or undefined)
  • Bonus: using dynamic photos in your game scene and advanced screen orientation handling

In part two, you'll learn how to prevent malicious users from tinkering with your game's data file and how to share the game data between devices via iCloud.

If you have any questions or comments, please share them with us in the comments below!

How to Save your Game’s Data: Part 1/2 is a post from: Ray Wenderlich

The post How to Save your Game’s Data: Part 1/2 appeared first on Ray Wenderlich.


Viewing all articles
Browse latest Browse all 4370

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>