4
\$\begingroup\$

This is my first time implementing achievements in a game so I could definitely use some feedback on what I have done here. I have heard about singleton approaches to this problem, but I did not feel it was really necessary and I would rather avoid singletons since they have a bad reputation. Instead my approach is to store a custom object in NSUserDefaults and load it at the start of a game. One important aspect of achievements in my game is that having certain achievements unlocked will unlock various bonuses in the game, but these effects will only be applied at the start of a new game.

My first time working through trying to set this up, I tried to have a simple dictionary hold boolean values for whether the achievement was completed or not. Later I decided that it could be important to show the amount of progress completed on an achievement, so I implemented classes to handle them. With this way of doing it, the Game only needs to have an AchievementType to access the achievement and update its completion state.

DTAchievements.h

#import <Foundation/Foundation.h>
#import "DTAchievement.h"

@interface DTAchievements : NSObject <NSCoding>

-(void) completeAchievement:(AchievementType)type;

@property NSMutableDictionary *achievementsDictionary;

@end

DTAchievements.m

#import "DTAchievements.h"

NSString* const k100FloorsRevealed = @"100FloorsRevealed";
NSString* const k100DwarvesDead = @"100DwarvesDead";
NSString* const k5thTowerDiscovered = @"5thTowerDiscovered";
NSString* const kLeaderDiedOldAge = @"leaderDiedOldAge";
NSString* const kBottomFloorRevealed = @"bottomFloorRevealed";
NSString* const kTopFloorRevealed = @"topFloorRevealed";

NSDictionary* achievementsDict() {
    NSDictionary *achievements = @{ k100FloorsRevealed :
                                        [[DTAchievement alloc]initWithType:AchievementType100FloorsRevealed],
                                    k100DwarvesDead :
                                        [[DTAchievement alloc]initWithType:AchievementType100DwarvesDead],
                                    k5thTowerDiscovered :
                                        [[DTAchievement alloc]initWithType:AchievementType5thTowerDiscovered],
                                    kLeaderDiedOldAge :
                                        [[DTAchievement alloc]initWithType:AchievementTypeLeaderDiedOldAge],
                                    kBottomFloorRevealed :
                                        [[DTAchievement alloc]initWithType:AchievementTypeBottomFloorRevealed],
                                    kTopFloorRevealed :
                                        [[DTAchievement alloc]initWithType:AchievementTypeTopFloorRevealed]
                                    };
    return achievements;
}

@implementation DTAchievements

-(id) init {
    self = [super init];
    if (self) {
        _achievementsDictionary = [[NSMutableDictionary alloc]initWithDictionary:achievementsDict()];
    }
    return self;
}

-(void) completeAchievement:(AchievementType)type {
    DTAchievement *achievement = nil;

    switch (type) {

        case AchievementType100FloorsRevealed:
            achievement = [self.achievementsDictionary objectForKey:k100FloorsRevealed];
            break;

        case AchievementType100DwarvesDead:
            achievement = [self.achievementsDictionary objectForKey:k100DwarvesDead];
            break;

        case AchievementType5thTowerDiscovered:
            achievement = [self.achievementsDictionary objectForKey:k5thTowerDiscovered];
            break;

        case AchievementTypeLeaderDiedOldAge:
            achievement = [self.achievementsDictionary objectForKey:kLeaderDiedOldAge];
            break;

        case AchievementTypeBottomFloorRevealed:
            achievement = [self.achievementsDictionary objectForKey:kBottomFloorRevealed];
            break;

        case AchievementTypeTopFloorRevealed:
            achievement = [self.achievementsDictionary objectForKey:kTopFloorRevealed];
            break;

        default:
            break;
    }
    achievement.completed = YES;
}

#pragma mark - NSCoding methods
-(void) encodeWithCoder:(NSCoder *)aCoder {
    [aCoder encodeObject:_achievementsDictionary forKey:@"achievementsDictionary"];
}
-(id) initWithCoder:(NSCoder *)aDecoder {
    self = [super init];
    if (self) {
        _achievementsDictionary = [aDecoder decodeObjectForKey:@"achievementsDictionary"];
    }
    return self;
}

@end

DTAchievement.h

#import <Foundation/Foundation.h>
#import "DTAchievementType.h"

@interface DTAchievement : NSObject <NSCoding>

-(instancetype)initWithType:(AchievementType)type;

@property BOOL completed;
@property (readonly) CGFloat percentageCompleted;

@end

DTAchievement.m

#import "DTAchievement.h"

@implementation DTAchievement {
    int _currentProgress;
    int _totalNeeded;
    AchievementType _type;
}

-(instancetype) initWithType:(AchievementType)type {
    self = [super init];
    if (self) {
        _type = type;
        _currentProgress = 0;
        _totalNeeded = 100;
    }
    return self;
}

-(CGFloat) getPercentageCompleted {
    return _currentProgress / _totalNeeded;
}

#pragma mark - NSCoding methods
-(void) encodeWithCoder:(NSCoder *)aCoder {
    [aCoder encodeInt:_totalNeeded forKey:@"totalNeeded"];
    [aCoder encodeInt:_currentProgress forKey:@"currentProgress"];
    [aCoder encodeBool:self.completed forKey:@"completed"];
    [aCoder encodeInteger:_type forKey:@"type"];
}
-(id) initWithCoder:(NSCoder *)aDecoder {
    self = [super init];
    if (self) {
        _totalNeeded = [aDecoder decodeIntForKey:@"totalNeeded"];
        _currentProgress = [aDecoder decodeIntForKey:@"currentProgress"];
        _completed = [aDecoder decodeBoolForKey:@"completed"];
        _type = [aDecoder decodeIntegerForKey:@"type"];
    }
    return self;
}

@end

When the scene for a new game is loaded, this method in the scene looks for existing achievement data, and if it is not present, the scene creates it.

-(void) startAchievements {
    NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
    if (![defaults objectForKey:@"achievements"]) {
        _userAchievements = [[DTAchievements alloc]init];
        NSData *encodedAchievements = [NSKeyedArchiver archivedDataWithRootObject:_userAchievements];
        [defaults setObject:encodedAchievements forKey:@"achievements"];
        [defaults synchronize];
    } else {
        NSData *encodedAchievements = [defaults objectForKey:@"achievements"];
        _userAchievements = [NSKeyedUnarchiver unarchiveObjectWithData:encodedAchievements];
    }
}

After this it is loaded into the game like so:

[_game configureForAchievements:_userAchievements];

If the game is being loaded instead of starting as a new game, this method is called from initWithCoder:

-(void) loadAchievements {
    NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
    NSData *encodedAchievements = [defaults objectForKey:@"achievements"];
    _achievements = [NSKeyedUnarchiver unarchiveObjectWithData:encodedAchievements];
}

When the game completes (or possibly whenever progress is made towards an achievement), a method will evaluate whether any achievements have been completed, and then they are saved like so:

-(void) saveAchievements {
    NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
    NSData *encodedAchievements = [NSKeyedArchiver archivedDataWithRootObject:_achievements];
    [defaults setObject:encodedAchievements forKey:@"achievements"];
    [defaults synchronize];
}

Is this a logical approach to doing achievements on iOS? Is using NSUserDefaults in this way safe, and is it good practice to do so?

\$\endgroup\$
3
\$\begingroup\$

As I commented, you really should look into using the GameCenter to handle all of your achievements. With that said, here's my thoughts on some of what you are doing.

It doesn't ever make sense to ever have more than one DTAchievements instantiated, does it? It also seems a little clunky that we have this DTAchievements class, and all it is is a glorified dictionary, right?

I mean, completeAchievement: adds some convenience, but if we want to reach in and check percentage complete for example, it's really inconvenient.

So, step one, let's make a singleton.

I usually recommend against singletons, but in this case, it never makes sense to instantiate more than one object of this class, though it does make sense that multiple objects might want to work with this object, and the data needs to be consistent throughout the app.

This is what the singleton pattern looks like in Objective-C:

+ (DTAchievements *)achievements {
    static DTAchievements *achievements;
    @synchronized (self) {
        if (!achievements) {
            achievements = [[self alloc] init];
        }
        return achievements;
    }
}

Now let's add a property for each achievement. This will make the class much easier to work with than a dictionary full of achievements.

@property (nonatomic,strong) DTAchievement *hundredFloorsRevealed;
// etc.

Of course, if we do this, we will either have to instantiate them all in init, or we can use lazy instantiation:

- (DTAchievement *)getHundredFloorsRevealed {
    if (!_hundredFloorsRevealed) {
        _hundredFloorsRevealed = [[DTAchievement alloc]initWithType:AchievementTypeTopFloorRevealed];
    }
    return _hundredFloorsRevealed;
}

This also eliminates the need for the enum, and that big switch statement.


It's weird to me that we can, through the DTAchievements, set an achievement to complete status, however there's seemingly no way of effecting an achievement's percentage complete status.

I also think that CGFloat isn't a useful return type for percentage complete. We're most likely calculating this for display to the user, right? Does a user really care that he's 66.66666666666666667% complete? Is displaying 67% fine enough?

Even if you want to use a floating point number, I'm very curious as to why you chose CGFloat here rather than float or double when you've consistently used int rather than NSInteger throughout all the rest of your code. If you were going to avoid the Objective-C typedef with one or the other, I think you've picked the wrong one.

\$\endgroup\$
  • \$\begingroup\$ Good point on the CGFloat. Do you recommend always using NSInteger when writing Objective-C? The reason for not having ways to access the completeness of the achievement was because I wanted to see if my approach was correct first. Do you think that having a property for each achievement (possibly hundreds) is better than having multiple switch statements and an enum? Thanks for your feedback! \$\endgroup\$ – bazola Aug 13 '14 at 16:18
  • 1
    \$\begingroup\$ The truly correct approach would be using GKAchievement and such. There's already a lot of existing stuff for achievements in the iOS library. \$\endgroup\$ – nhgrif Aug 13 '14 at 21:03

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.