Wednesday, August 8, 2018

Objective-C Tutorials: Protocols in Gaming Applications

In this tutorial, we will look at some ways that Objective-C protocols can be helpful in designing iOS mini-games.   A link to the Github repo for this project is provided here if you would like to download or clone the original source code and follow along.  A video screen shot for the final result of our mini-game is shown below:


Basically, the user is shown a “target word” at the bottom of the screen, which must be spelled by tapping on letter sprites on screen.  The catch is that these letter sprites appear and disappear at regular intervals, sometimes appearing at random on-screen positions, other times hiding behind cloud sprites randomly scattered across the background.  Before we begin, we will do some preliminary setup in our GameScene class.  The preliminary header and implementation files are show below:
GameScene.h
@import SpriteKit;

@interface GameScene : SKScene

@end


GameScene.m
@import Foundation;
@import SpriteKit;

#import "GameScene.h"
#import "RandomPointGenerator.h"
#import "Constants.h"
#import "LetterManager.h"
#import "WordManager.h"
#import "TargetWordArray.h"
#import "StatTracker.h"
#import "HUDManager.h"
#import "Cloud.h"

@interface GameScene() <SKPhysicsContactDelegate,WordManagerDelegate>

@property NSArray<NSValue*>*randomSpawnPoints;
@property LetterManager* letterManager;
@property NSString* targetWord;

@property WordManager* wordManager;
@property TargetWordArray* debugTargetWordArray;
@property StatTracker* statTracker;
@property HUDManager* hudManager;

@end
 
The GameScene interface is pretty straightforward – we notice that SpriteKit is included via an import statement, and that our GameScene class inherits from SKScene.  In our implementation file, we notice several private properties defined in the class extension.  Among them are targetWord, of type NSString, which is basically a reference to the word that must be spelled by the user. There is also spawnPoints, of type NSArray<NSValue*>*,  which is basically an array of CGPoint values wrapped in NSValue objects where the CGPoint values represent random positions used to scatter the cloud sprites across the screen randomly.
Other important properties defined in the extension require corresponding import statements at the top of the implementation file: wordManagerstatTrackerhudManagerletterManager, and debugTargetWordArray.  All of these properties are custom data types defined in other files – hence, we need to import the appropriate header files where these classes are defined accordingly.
The hudManager is responsible for managing the accumulated score indicator at the top of the screen as well as the text displays for the target word and the user’s progress towards spelling the target word.  The letterManager object is responsible for randomizing the positions of the on-screen letter sprites as well as for adding letter sprites to the scene based on the current target word.  The statTracker object is responsible for collecting game statistics and data, such as the number of misspellings, the number of target words spelled correctly, the total points accumulated by the user, etc.
wordManager is basically responsible for keeping track of the word that is currently being spelled by the user, comparing each letter tapped by the user to a corresponding letter in the target word to determine whether or not the user is spelling the word correctly.  Since the user can theoretically spell more than a single target word correctly, the wordManager must have access to additional target words, which is provided by the debugTargetWordArray. This is basically a wrapper class for a simple NSArrayobject and conforms to the WordManagerDataSource protocol, which is a custom Objective-C protocol that stipulates the methods that must be provided by any object that will act as a data source (i.e. a source of target words) for the wordManager object, regardless of how that data source is implemented (i.e. the data source can be just as easily implemented as a stack, linked list, etc.).  The WordManagerDataSource protocol is fairly simple and shown below:
#import <Foundation/Foundation.h>

@protocol WordManagerDataSource 

-(NSString*)getInitialTargetWord;

-(NSString*)getNextTargetWord;

@optional

-(BOOL)hasAcquiredAllTargetWords;


@end

As you can see, it’s relatively straightforward.  It just requires that the data source provide the initial target word as well as the next target word after the current target word.  In this way, the wordManager can continually iterate through a collection of strings that function as target words.  This collection of target words can be implemented  by means of a variety of data structures (e.g. a stack, linked list, array, etc.).  The optional protocol method hasAcquiredAllTargetWords can be implemented if we only wish to iterate through the collection of target words once.
The extension for our GameScene also indicates that the GameScene class conforms to two protocols:  SKPhysicsContactDelegate and WordManagerDelegate.  The former basically means that our GameSceneclass can act as an SKPhysicsContactDelegate for the physicsWorld property of the SKScene and that it will implement two required methods, didBeginContact and didEndContact, which are called when the physics bodies of different game objects collide with each other.  The latter, WordManagerDelegate,  is our own custom protocol, which requires that several methods be implemented:
WordManagerDelegate.h
#import <Foundation/Foundation.h>

/** The WordManager will inform its delegate when it has updated the word in progress;  it will also inform its delegate when it has cleared or completed its word in progress, as well as when it has updated the target word and when the user has earned points **/

@protocol WordManagerDelegate 


-(void)didUpdateWordInProgress:(NSString*)wordInProgress;


@optional

-(void)didExtendWordInProgress:(NSString*)extendedWordInProgress;

-(void)didMisspellWordInProgress:(NSString*)misspelledWordInProgress;

-(void)didClearWordInProgress:(NSString*)deletedWordInProgress;

-(void)didCompleteWordInProgress:(NSString*)completedWordInProgress;

-(void)didUpdateTargetWordTo:(NSString*)updatedTargetWord fromPreviousTargetWord:(NSString*)previousTargetWord;



@end


In order to understand this custom protocol, it should be understood that the WordManager stores a reference not only to the target word (i.e. the word that the user is trying to spell) but also to an NSString property wordInProgress, which is a temporary string that tracks the user’s progress towards spelling the target word.
Our custom protocol defines one required method didUpdateWordInProgress, which is  called every time the temporary string that tracks the user’s attempted spelling of the target word undergoes some kind of change – that is, this method is called if the user misspells the target word, resulting in the temporary word in progress being deleted, or if the user spells a letter correctly, resulting in the temporary word being lengthened or in the current target word being swapped for a new target word if the user has completed spelling the existing target word.  Additionally, several optional methods can be implemented: didUpdateTargetWordTo:fromPreviousTargetWord (which is called every time the user successfully completes the spelling of a target word), didCompleteWordInProgress (which is called after the user correctly spells the target word but before the current target word is swapped for an updated target word), didClearWordInProgress (which is called either when the user misspells the target word, requiring that the user start spelling the target word again  or when the the user successfully spells the target word, resulting in the word in progress being cleared, in which cases it is called after the current target word is swapped for an updated target word), didMisspellWordInProgress (which is called every time the user misspells a target word, resulting the word in progress being cleared), and didExtendWordInProgress (which is called every time the user correctly spells a letter in the target word, resulting in the temporary word in progress being extended).
Now that we have a rough understanding of all the private properties defined via this extension, let’s start to examine the meat of the the implementation file:
const static CGFloat kHUDXPosition = 0.0;
const static CGFloat kHUDYPosition = 0.0;


- (void)didMoveToView:(SKView *)view {
    // Setup your scene here
    
    [self configureScene];
    [self setupBackground];
    [self setupBGMusic];
    [self setupSpawnPoints];
    [self setupStatTracker];
    [self setupWordManager];
    [self acquireTargetWord];
    [self setupHUDManager];
    [self setupLetterManager];
    [self createClouds];
    [self addTargetWordLetters];
    
    
}

At the top of the implementation section,  we’ve defined static constants for the X and Y coordinates of the position of the main HUD display.  These constants can just as easily be provided via the Constants.h file.  In the didMoveToView method, we call a series of  helper methods responsible for setting up and configuring the scene for game play.  The order of these methods is important, since the methods are called on the assumption that certain properties have already been initialized (i.e.  a hudManager can only be initialized with a targetWord, which is why it is called after the method acquireTargetWord, which itself is called after setupWordManger, since the initial target word is provided by the wordManager).
Now that we have a holistic understanding of how these setup methods are called, let’s look at their specific implementations below:
GameScene.m
-(void)configureScene{
    self.physicsWorld.contactDelegate = self;
    self.anchorPoint = CGPointMake(0.5, 0.5);
}

-(void)setupBackground{
    
    SKEmitterNode* starEmitter = [SKEmitterNode nodeWithFileNamed:@"stars"];
    [self addChild:starEmitter];
    starEmitter.position = CGPointZero;
}


-(void)setupBGMusic{
    SKAudioNode* bgNode = [SKAudioNode nodeWithFileNamed:@"Mishief-Stroll.mp3"];
    
    if(bgNode){
        [self addChild:bgNode];
        
    }
}


-(void)setupSpawnPoints{
    
    RandomPointGenerator* pointGen = [[RandomPointGenerator alloc] init];
    int numPoints = kNumberOfOnScreenDebugPoints;
    self.randomSpawnPoints = [pointGen getArrayOfOnScreenPoints: numPoints];

    
}

-(void)setupStatTracker{
    
    self.statTracker = [[StatTracker alloc] init];
}

-(void)setupWordManager{
    
    self.debugTargetWordArray = [[TargetWordArray alloc] initDebugArray];
    
    self.wordManager = [[WordManager alloc] initWith:self.debugTargetWordArray];
    
    self.wordManager.delegate = self;
    
    
}

-(void)acquireTargetWord{
    
    self.targetWord = [self.wordManager targetWord];
}


-(void)setupHUDManager{
    
    self.hudManager = [[HUDManager alloc] initWithTargetWord:self.targetWord];
    
    [self.hudManager addHUDNodeTo:self atPosition:CGPointMake(kHUDXPosition, kHUDYPosition)];
    
}



-(void)setupLetterManager{
    
    self.letterManager = [[LetterManager alloc] initWithSpawnPoints:self.randomSpawnPoints andWithTargetWord:self.targetWord];
    
}


-(void)createClouds{
    
    
    
    for (NSValue*pointVal in self.randomSpawnPoints) {
        
        Cloud* randomCloud = [Cloud getRandomCloud];
        [randomCloud addSpriteTo:self atPosition:pointVal.CGPointValue];
    }
}

-(void)addTargetWordLetters{
    
    if([self.letterManager targetWord]){
        [self.letterManager addLettersTo:self];
    }
    
}

Again, the order in which these methods is called is important.  For example, we first call the method setupSpawnPoints to initialize the array of random spawn points (each of which is wrapped in an NSValue object), which we in turn use to provide the positions for each of the cloud sprites that are added to the scene in the createClouds method. Finally, letterManager adds the letter sprites to the scene based on the targetWord obtained from the wordManager in the method acquireTargetWord.
In the course of gameplay, the letter sprites on-screen are constantly appearing and disappearing at regular cycles, alternately scattering to random on-screen positions where they can be tapped by the user and then hiding behind on-screen cloud sprites, respectively.  In order to achieve this effect, we need to call the update method on the letterManager, which we do in the GameScene’s update function:
-(void)update:(CFTimeInterval)currentTime {
    
    if(self.letterManager){
        
        [self.letterManager update:currentTime];
        
    }
}

In addition, during the course of gameplay, the user can tap on different on-screen letter sprites in order to spell the target word displayed by the HUD display at the bottom of the screen.  Therefore, in our touchesBeganfunction, we need to call the evaluateNextLetter method on the wordManager object in order to determine whether the user tapped on the correct letter, in which case the word in progress will be extended, or the wrong letter, in which case the user has misspelled the target word, requiring that the target word be cleared:
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
    
    for (UITouch *t in touches) {
        
        
        CGPoint touchPoint = [t locationInNode:self];
        
        SKSpriteNode* node = (SKSpriteNode*)[self nodeAtPoint:touchPoint];
        
        if(node && [node.name containsString:@"letter"]){
            NSString* nodeName = node.name;
            
            char lastChar = [nodeName characterAtIndex:nodeName.length-1];
            
            NSLog(@"You touched the letter %c", lastChar);
            
            [self.wordManager evaluateNextLetter:lastChar];
            
        }
        
    }

}

So far, we understood how the initial setup and configuration takes place.  We also  have a way of randomizing the letter sprites on-screen via the update function, which basically calls the corresponding update instance method defined for the WordManager class.   We also have a way of handling the user’s interaction with letters on the screen.  However, there’s still no communication between the wordManager object and the hudManagerstatTracker, and the letterManager (which must be notified whenever a new target word has been set so that it can add new letter sprites to the screen appropriately).  In order to accomplish this, we make use of the WordManagerDelegate protocol we defined earlier and to which our GameScene class is in conformance.  To this end, we implement all of the WordManagerDelegate protocol methods in our GameScene class, as shown below:
GameScene.m


/** Word Manager delegate methods **/

-(void)didUpdateWordInProgress:(NSString *)wordInProgress{
    NSLog(@"The word in progress has been updated - the current word in progress is now: %@", wordInProgress);
    
    [self.hudManager updateWordInProgressNode:wordInProgress];
}

-(void)didClearWordInProgress:(NSString *)deletedWordInProgress{
    
    NSLog(@"The word in progress %@ has been cleared.  You must start over in order to spell the target word",deletedWordInProgress);
    
    [self.hudManager updateWordInProgressNode:@""];
}

-(void)didMisspellWordInProgress:(NSString*)misspelledWordInProgress{
    
    self.statTracker.numberOfMisspellings += 1;
    
}


-(void)didCompleteWordInProgress:(NSString *)completedWordInProgress{
    
    NSLog(@"The user has completed the word in progress: %@",completedWordInProgress);
    
    
}

-(void)didUpdateTargetWordTo:(NSString*)updatedTargetWord fromPreviousTargetWord:(NSString*)previousTargetWord{
    
    NSLog(@"The target word has been updated.  The new target word is: %@",updatedTargetWord);
    
    self.statTracker.numberOfTargetWordsCompleted += 1;
    [self.statTracker addPointsForTargetWord:previousTargetWord];
    
    [self.hudManager updateTargetWordNode:updatedTargetWord];
    [self.hudManager updateScoreNode:self.statTracker.totalPointsAccumulated];
    
    
    /** Acquire the next target word form the WordManager **/
    [self acquireTargetWord];
    
    
    /** Remove letter nodes from the scene **/
    [self removeLetterNodes];
    
    /** Clear all the letters currently managed by the LetterManager **/
    [self.letterManager clearLetters];
    
    /** Reset the target word for the letter manager **/
    [self.letterManager setTargetWord:self.targetWord];
    
    /** Add the new letters from the letter manager to the scene **/
    [self.letterManager addLettersTo:self];
}

-(void)didExtendWordInProgress:(NSString*)extendedWordInProgress{
    
    self.statTracker.numberOfLettersSpelledCorrectly += 1;
    
}

//MARK: ******* Helper Function for Removing Excess Letters

-(void)removeLetterNodes{
    for (SKSpriteNode* node in self.children) {
        if([node.name containsString:@"letter"]){
            [node removeFromParent];
        }
    }
}



As you can see, these delegate methods allow us to update the HUD display, keep track of game statistics, and update the target word in a logical order, all the while maintaining the modularity of our game design and avoiding tight coupling among the different objects responsible for handling different game functionalities (i.e. tracking game statistics, update the HUD display, etc.).
This section of the tutorial is intended to provide a holistic overview of how we use protocol to achieve modular game design.  To continue with some of the finer points of how these different classes are implemented, you can continue with the tutorial here. Otherwise, please leave any comments or feedbacks below.

No comments:

Post a Comment