Welcome back to the third and final section of our Facts Game with Sprite Kit tutorial series. This tutorial will teach you how to use the Sprite Kit framework to create a question-based facts game. It is designed for both novice and advanced users. Along the way, you will apply the Sprite Kit core.
Introduction
In this tutorial, you will program the entire game logic including the player's life, the question, and the player's answer. This tutorial series is broken up into three sections: Project Setup, Facts Interface, and Game Logic. If you haven't yet completed the second section, you can download the project and pickup exactly where we left off. Each part produces a practical result, and the sum of all parts will produce the final game. Despite the fact that each part can be read independently, for a better understanding we suggest to follow the tutorial step by step. We also included the source code for each part separately, thus providing a way to start the tutorial in any part of the series.
This is what our game will look like upon completion:
1. Custom Facts Class
In the last tutorial, you defined a plist file for the questions. Each question has four properties. In order to manage them, you need to create a custom class to afford that task properly. Therefore, you need to form another Objective-C
class. Name it factObject
and define the NSObject
superclass.
Now, let's edit the header file and add the four plist properties. Each plist property has its own features:
- The statement Id is an
int
. - The statement is a
NSString
. - The isCorrect statement is an
int
. - The additional information is a
NSString
.
The end result should be similar to this:
@property(nonatomic,readwrite) int factID; @property(nonatomic,readwrite,retain) NSString *statement; @property(nonatomic,readwrite) NSInteger isCorrect; @property(nonatomic,readwrite,retain) NSString *additionalInfo;
You don't need to use the implementation file (.m). We will parse the plist file to this custom class and use the values directly from the memory.
2. Facts Interface: Initialization
In the last tutorial, you defined the basic structure of the facts interface. It is now time to complete it with the logic steps. To complete this game, we need to create a question label, a customized background statement, a button that asks for another question, and a true and false interface. Those four statements translate into five properties defined in the FactsScene.h
file. Once again, you can name them as you please. Our implementation is:
@property (nonatomic, retain) UILabel *questionLabel; @property (nonatomic, retain) SKSpriteNode *backgroundStatement; @property (nonatomic, retain) UIButton *nextQuestion; @property (nonatomic, retain) SKSpriteNode* wrong; @property (nonatomic, retain) SKSpriteNode* correct;
Now move your attention to the FactsScene.m
. You need to define several objects that are used internally in the class:
- A
NSMutableArray
to store the questions - A random value that represents a random question
- The question identifier
- The minimum threshold for the right questions; this threshold indicates the minimum required correct answers for the user to advance to another level. In this tutorial, you will use value seven.
Your implementation file should look like this:
NSMutableArray *statements; int randomQuestion; int questionNumber; int totalRightQuestions; // need 7 out of 10 to pass to the next level
It is now time to allocate a few values and begin with the logic. In the -(id) initWithSize:(CGSize)size inLevel:(NSInteger)level withPlayerLives:(int)lives
method initiate the questionNumber
and the totalRightQuestions
. Since it is the first time you use it, the initiation is easy and can be done like:
questionNumber = 1; totalRightQuestions=0;
Now it is time to use the custom class defined in the aforementioned step. Parse the plist file and use the information store in the plist to allocate and populate new factObject
objects. Note that we will store each factObject
object in a custom NSMutableArray
already defined (statements
). The complete snippet is below.
statements = [[NSMutableArray alloc] init]; NSString* plistPath = [[NSBundle mainBundle] pathForResource:@"LevelDescription" ofType:@"plist"]; NSMutableDictionary* dictionary = [[NSMutableDictionary alloc] initWithContentsOfFile:plistPath]; if ([dictionary objectForKey:@"Questions" ] != nil ){ NSMutableArray *array = [dictionary objectForKey:@"Questions"]; for(int i = 0; i < [array count]; i++){ NSMutableDictionary *questions = [array objectAtIndex:i]; factObject *stat = [factObject new]; stat.factID = [[questions objectForKey:@"id"] intValue]; stat.statement = [questions objectForKey:@"statement"]; stat.isCorrect = [[questions objectForKey:@"isCorrect"] integerValue]; stat.additionalInfo = [questions objectForKey:@"additionalInfo"]; [statements addObject:stat]; } }
This step removes the older parsing code from the -(void) didMoveToView:(SKView *)view
method. You can remove it, since you will not use it anymore.
3. Facts Interface: Logic
It is now time to focus on the logic code itself. We need to show the question to the user. However, the question is always a random choice. Start to define a rectangle to afford the question and then allocate the necessary resources for the question's text. The following snippet will help you:
CGRect labelFrame = CGRectMake(120,300, 530, 100); _questionLabel = [[UILabel alloc] initWithFrame:labelFrame]; randomQuestion = [self getRandomNumberBetween:0 to:([statements count]-1)]; NSString *labelText = [[statements objectAtIndex:randomQuestion] statement]; [_questionLabel setText:labelText]; [_questionLabel setTextColor:[UIColor whiteColor]]; [_questionLabel setFont:[UIFont fontWithName:NULL size:23]]; [_questionLabel setTextAlignment:NSTextAlignmentCenter]; // The label will use an unlimited number of lines [_questionLabel setNumberOfLines:0];
Note that you will not use the SKLabelNode
over the simple NSString
because of a SKLabelNode
limitation; it is for single-line text only. A warning will appear regarding the getRandomNumberBetween:0 to:X
method. You need to declare and code it; its objective is to return a random value between two values. The next snippet will help you:
-(int)getRandomNumberBetween:(int)from to:(int)to { return (int)from + arc4random() % (to-from+1); }
Now that you can see the question, we need to add some functionalities to the right and wrong button. Change both selectors and call a new method called: presentCorrectWrongMenu
.
[_falseButton addTarget:self action:@selector(presentCorrectWrongMenu:) forControlEvents:UIControlEventTouchUpInside]; [_trueButton addTarget:self action:@selector(presentCorrectWrongMenu:) forControlEvents:UIControlEventTouchUpInside];
Additionally, define a tag for each button. The true button will be tag = 1 and the false tag = 0. These tags help you when you call the -(void)presentCorrectWrongMenu:(UIButton*)sender
method to determine which button was tapped to call that same method.
[_trueButton setTag:1]; [_falseButton setTag:0];
The next step is to add the -(void)presentCorrectWrongMenu:(UIButton*)sender
method. This method is complex and recognizes which button is tapped, adds a custom answer interface, and adds a button that calls the next question. Use the following snippet to achieve the above-mentioned topics:
-(void)presentCorrectWrongMenu:(UIButton*)sender{ int userData = sender.tag; // background _backgroundStatement = [SKSpriteNode spriteNodeWithImageNamed:@"background.png"]; _backgroundStatement.position = CGPointMake(CGRectGetMidX(self.frame),CGRectGetMidY(self.frame)); _backgroundStatement.size = CGSizeMake(768, 1024); _backgroundStatement.zPosition = 10; _backgroundStatement.alpha = 0.0; [self addChild:_backgroundStatement]; _nextQuestion = [UIButton buttonWithType:UIButtonTypeRoundedRect]; _nextQuestion.frame = CGRectMake(CGRectGetMidX(self.frame)-100, CGRectGetMidY(self.frame)+90, 200, 70.0); _nextQuestion.backgroundColor = [UIColor clearColor]; [_nextQuestion setTitleColor:[UIColor blackColor] forState:UIControlStateNormal ]; [_nextQuestion setTitle:@"Tap Here to Continue" forState:UIControlStateNormal]; [_nextQuestion addTarget:self action:@selector(nextQuestion) forControlEvents:UIControlEventTouchUpInside]; _nextQuestion.alpha = 1.0; [self.view addSubview:_nextQuestion]; [_backgroundStatement runAction:[SKAction fadeAlphaTo:1.0f duration:0.2f]]; _trueButton.alpha = 0.0; _falseButton.alpha = 0.0;
A warning will appear, however do not fix it right away. First, end the method declaration. Now that you have a custom interface for the answer, you need to test the player's answer. To achieve that, you need to know which button the player tapped and the inherent question's answer. You already know this, so you only need to create a simple logic test condition. To do this, you need to test if the answer is correct or incorrect, play a sound accordingly, and proceed to the properties update. The next snippet will help you. Note that you must place it where the last snippet of code ended.
if( ([[statements objectAtIndex:randomQuestion] isCorrect] == 0 && userData == 0) || ([[statements objectAtIndex:randomQuestion] isCorrect] == 1 && userData == 1) ){ if ([[statements objectAtIndex:randomQuestion] isCorrect] == 0) _questionLabel.text = [[statements objectAtIndex:randomQuestion] additionalInfo]; _correct = [SKSpriteNode spriteNodeWithImageNamed:@"correct.png"]; _correct.scale = .6; _correct.zPosition = 10; _correct.position = CGPointMake(CGRectGetMidX(self.frame),800); _correct.alpha = 1.0; totalRightQuestions++; [self touchWillProduceASound:@"True"]; [self addChild:_correct]; } else{ if ([[statements objectAtIndex:randomQuestion] isCorrect] == 0) _questionLabel.text = [[statements objectAtIndex:randomQuestion] additionalInfo]; _wrong = [SKSpriteNode spriteNodeWithImageNamed:@"wrong.png"]; _wrong.scale = .6; _wrong.zPosition = 10; _wrong.position = CGPointMake(CGRectGetMidX(self.frame),800); _wrong.alpha = 1.0; [self removePlayerLife]; [self touchWillProduceASound:@"False"]; [self addChild:_wrong]; } }
Do you remember the last warning? Now you should see several warnings. Don't worry, they're warning you that several methods are missing. We can correct that. The first method to define is the -(void)nextQuestion
. As the name suggests, it calls the next question. Besides presenting a new question, it resets the timer, increments the question number, updates the current question label, removes the presented question from the array, and tests the logic needed to move to another level. The complete source code of -(void)nextQuestion
is:
-(void)nextQuestion{ [self resetTimer]; questionNumber++; _currentLevelLabel.text = [[NSString alloc] initWithFormat:@"Level: %ld of 10", (long)questionNumber]; _wrong.alpha = 0.0; _correct.alpha = 0.0; _backgroundStatement.alpha = 0.0; _nextQuestion.alpha = 0.0; [statements removeObject:[statements objectAtIndex:randomQuestion]]; //random question randomQuestion = [self getRandomNumberBetween:0 to:([statements count]-1)]; [_questionLabel setText:[[statements objectAtIndex:randomQuestion] statement]]; _trueButton.alpha = 1.0; _falseButton.alpha = 1.0; if (questionNumber == 10 && totalRightQuestions > 7){ int nexLevel = playerLevel+2; [defaults setInteger:nexLevel forKey:@"actualPlayerLevel"]; [self removeUIViews]; SKTransition* transition = [SKTransition doorwayWithDuration:2]; LevelSelect* levelSelect = [[LevelSelect alloc] initWithSize:CGSizeMake(CGRectGetMaxX(self.frame), CGRectGetMaxY(self.frame))]; [self.scene.view presentScene:levelSelect transition:transition]; } }
Note that you hard-coded the maximum questions (10) for this level and the threshold for the next level (7). Once again, a new warning will appear. No resetTimer
method exists; this method only resets the maximumTime
property to 60 and updates the label accordingly:
-(void)resetTimer{ maximumTime = 60; [_timerLevel setText:@"60"]; }
In the last tutorial, you defined the touchWillProduceASound
method. However in this tutorial, you need to modify it further. The objective is to pass it a object that represents the correct or incorrect answer. Then the corresponding sound will play. The complete method is:
-(void) touchWillProduceASound:(NSString*)answer{ long soundFlag = [defaults integerForKey:@"sound"]; if (soundFlag == 1){ SKAction* sound; if ([answer isEqualToString:@"False"]) { sound = [SKAction playSoundFileNamed:@"wrong.mp3" waitForCompletion:YES]; } else { sound = [SKAction playSoundFileNamed:@"right.mp3" waitForCompletion:YES]; } [self runAction:sound]; } }
You still need to define the -(void)removePlayerLife
method. As the name states, it tests the life of a player and acts accordingly. If the player has more than one life, a life is reduced and the inherent asset is updated or is moved to the home screen. The complete method is below.
-(void)removePlayerLife{ if (playerLives > 1){ for(NSInteger i = 0; i < playerLives; i++){ SKSpriteNode* node = [heartArray objectAtIndex:i]; if (i == (playerLives-1)){ node.alpha = .1; } } playerLives--; } else { [self moveToHome]; } }
At this point, we're nearly finished. It is now time to update the - (void)updateTimer
defined in the last tutorial. This new method is responsible for updating the timer value and testing the player's life. It automatically reacts when the timer hits zero. At that time, it tests the player's life and acts accordingly. It goes to the main menu if the player's life is less than one or calls another question otherwise (decreasing the player's life). The complete snippet is below.
- (void)updateTimer{ maximumTime--; if (maximumTime == 0){ if (playerLives < 1){ [self touchWillProduceASound:@"False"]; [self moveToHome]; } else{ [self presentCorrectWrongMenu:_trueButton]; [self touchWillProduceASound:@"False"]; [self removePlayerLife]; } } [_timerLevel setText:[[NSNumber numberWithInt:maximumTime] stringValue]]; }
4. Additional Methods
Two additional methods were created: -(void)moveToHome
and -(void)removeUIViews
. We need to define them because we'll use them more than once. It is good practice to reuse code instead of typing it all again. The -(void)moveToHome
is just a call to a SKTransition
and MyScene
class. The code is:
-(void)moveToHome{ SKTransition* transition = [SKTransition fadeWithDuration:2]; MyScene* myscene = [[MyScene alloc] initWithSize:CGSizeMake(CGRectGetMaxX(self.frame), CGRectGetMaxY(self.frame))]; [self.scene.view presentScene:myscene transition:transition]; }
The -(void)removeUIViews
removes the UIKit
views from the superview. Here is what the code looks like:
-(void)removeUIViews{ [_trueButton removeFromSuperview]; [_falseButton removeFromSuperview]; [_questionLabel removeFromSuperview]; }
Now that everything is correct, Run
the project. Every time you correctly answer a question, you will see an interface similar to the next image:
On the other hand, when you incorrectly answer a question, you will see an interface that looks like this:
5. Final Improvements
There's just one more step before we're done. We need to correctly initialize the actualPlayerLevel
at the level selection. Switch your attention to the MyScene.m
class (the first one created by the Xcode) and let's add some lines of code. Initially, add an object of the type NSUserDefaults
to the @implementation
section. The following snippet will help you:
@implementation MyScene { // code NSUserDefaults* defaults; }
Now inside the -(void) didMoveToView:(SKView *)view
, add the inherent NSUserDefaults
initialization. The default value is one, so that a new player always starts a new game at Level 1. Moreover, if the player didn't achieve the minimum requisites to pass the level, it starts again at that same level. The result is below.
defaults = [NSUserDefaults standardUserDefaults]; [defaults setInteger:1 forKey:@"actualPlayerLevel"]; //more code..
Several other adjustments can be made to this game. You can customize the correct answer rates for questions, animations, and transitions, or alter the landscape and portrait mode. We think that you are ready for such a task. Try to implement them on your own.
Conclusion
At the end of this Facts tutorial, you should be able to create a SpriteKit game, create and configure several SpriteKit views and actions, configure several UIKit views, use them in parallel with SpriteKit, and parse Property lists. Don't hesitate to use the comment section below for suggestions or comments. Thanks for reading!
Comments