4
\$\begingroup\$

This is a simple class for a label with a score that animates counting up or down. When someone in the game scores points, the numbers count up or down to the new total.

Here is an example of what it looks like:

animated score count

I ran into a few problems when building this class. It was important to prevent the building of further animations while a current animation was playing, because otherwise the score would be incremented too many times. It was also important to to set the correct values at the end of the sequence of animations in order to make sure that it was always accurate when it completed.

BZAnimatedScoreLabel.h

#import <SpriteKit/SpriteKit.h>

@interface BZAnimatedScoreLabel : SKLabelNode

+(BZAnimatedScoreLabel *) labelWithText:(NSString *)text score:(int)score size:(int)fontSize;

-(void) updateForScore:(int)newScore;

@end

BZAnimatedScoreLabel.m

#import "BZAnimatedScoreLabel.h"

@implementation BZAnimatedScoreLabel {
    int _score;
    SKLabelNode *_scoreLabel;
    NSMutableArray *_actionQueue;
    BOOL _isAnimationPlaying;
}

#pragma mark - Initialization
+(BZAnimatedScoreLabel *) labelWithText:(NSString *)text score:(int)score size:(int)fontSize {
    return [[BZAnimatedScoreLabel alloc]initWithText:text score:score size:fontSize];
}
-(instancetype) initWithText:(NSString *)text score:(int)score size:(int)fontSize {
    self = [super initWithFontNamed:@"Arial"];
    if (self) {
        self.text = text;
        self.fontSize = fontSize;
        self.fontColor = [SKColor whiteColor];
        _score = score;

        _scoreLabel = [[SKLabelNode alloc]initWithFontNamed:@"Arial"];
        _scoreLabel.fontSize = fontSize;
        _scoreLabel.fontColor = [SKColor whiteColor];
        _scoreLabel.text = [NSString stringWithFormat:@"%i", _score];
        _scoreLabel.position = CGPointMake((fontSize * 4), 0);
        [self addChild:_scoreLabel];

        _isAnimationPlaying = NO;
        _actionQueue = [[NSMutableArray alloc]init];
    }
    return self;
}

#pragma mark - Animation
-(void) updateForScore:(int)newScore {
    if (!_isAnimationPlaying) {
        if (newScore > _score) {
            [self updateForHigherScore:newScore];
        } else {
            [self updateForLowerScore:newScore];
        }
    }
}
-(void) updateForHigherScore:(int)newScore {
    for (int i = _score; i <= newScore; i+=10) {
        [self addAnimationToQueueForAmount:i];
    }
    [self playQueuedAnimationsFinalScore:newScore];
}
-(void) updateForLowerScore:(int)newScore {
    for (int i = _score; i >= newScore; i-=10) {
        [self addAnimationToQueueForAmount:i];
    }
    [self playQueuedAnimationsFinalScore:newScore];
}
-(void) addAnimationToQueueForAmount:(int)amount {
    [_actionQueue addObject:[SKAction runBlock:^(void){
        _scoreLabel.text = [NSString stringWithFormat:@"%i", amount];
    }]];
}
-(void) playQueuedAnimationsFinalScore:(int)finalScore {
    _isAnimationPlaying = YES;
    [self runAction:[SKAction sequence:_actionQueue] completion:^(void) {
        _score = finalScore;
        _scoreLabel.text = [NSString stringWithFormat:@"%i", _score];
        _isAnimationPlaying = NO;
        [_actionQueue removeAllObjects];
    }];
}

@end

Here is an example usage in the SKScene:

//object creation
_pointsLabel = [BZAnimatedScoreLabel labelWithText:@"Points = " score:0 size:20];
_pointsLabel.position = CGPointMake(_initialScreenSize.width/1.8, _initialScreenSize.height/28);
[self addChild:_pointsLabel];

//usage
[_pointsLabel updateForScore:_game.currentScore];

I thought about expanding the initialization method of the class to include the size of the scene (for proper spacing of the points from the text) and also the position of the label, but the initialization method is already pretty long and adding two further arguments feels like too much, but I am not sure.

\$\endgroup\$
  • \$\begingroup\$ Can you use UIKit stuff in SpriteKit? Is there any reason not to do this as a UILabel subclass? \$\endgroup\$ – nhgrif Feb 2 '15 at 17:00
  • \$\begingroup\$ It might be possible, but this is for use in a Sprite-Kit scene so I naturally used the Sprite-Kit version. I haven't used UILabel enough to know all the differences. \$\endgroup\$ – bazola Feb 2 '15 at 17:25
4
\$\begingroup\$
  • If the score label is updated while the animation is still running, then the update is simply ignored. For example, with

    [_pointsLabel updateForScore:100];
    [_pointsLabel updateForScore:200];
    

    the label will animate to 100 and stay there, instead of animating to 200.

    Instead of pre-computing all actions from the current score to the final value, I would start only a single action that will display the next intermediate score, e.g. from 100 to 110. When that action has completed, start a new action.

    This approach solves the problem of simultaneously running actions, and makes both _isAnimationPlaying and the _actionQueue obsolete. Each time an action is created, it can check whether the counter has to be incremented or decremented.

  • The animation is always in steps of 10, e.g. an update from 13 to 51 will display 13, 23, 33, 43, 51. It would look nicer if multiples of 10 are displayed where possible, in this case 13, 20, 30, 40, 50, 51.

  • The animation is too fast. At last on my Simulator it was running so fast that not all intermediate steps can be recognized. I would add an small delay between the actions.

  • The updateForScore: method is not really necessary. I would make score a (public) property and override the setter method, so that

    _pointsLabel.score = newValue;
    

    updates the score and starts the animation.

Then your implementation could look like this:

BZAnimatedScoreLabel.h

@interface BZAnimatedScoreLabel : SKLabelNode
+(BZAnimatedScoreLabel *) labelWithText:(NSString *)text score:(int)score size:(int)fontSize;
@property (nonatomic) int score;
@end

BZAnimatedScoreLabel.m

#import "BZAnimatedScoreLabel.h"

@implementation BZAnimatedScoreLabel {
    SKLabelNode *_scoreLabel;
    int _currentScore; // The currently displayed score
}

#pragma mark - Constants

static NSString *kAnimationKey = @"BZLabelAnimationKey";
static const NSTimeInterval kAnimationDelay = 0.02;


#pragma mark - Initialization

+(BZAnimatedScoreLabel *) labelWithText:(NSString *)text score:(int)score size:(int)fontSize {
    return [[BZAnimatedScoreLabel alloc]initWithText:text score:score size:fontSize];
}

-(instancetype) initWithText:(NSString *)text score:(int)score size:(int)fontSize {
    self = [super initWithFontNamed:@"Arial"];
    if (self) {
        self.text = text;
        self.fontSize = fontSize;
        self.fontColor = [SKColor whiteColor];

        _currentScore = _score = score;
        _scoreLabel = [[SKLabelNode alloc] initWithFontNamed:@"Arial"];
        _scoreLabel.fontSize = fontSize;
        _scoreLabel.fontColor = [SKColor whiteColor];
        _scoreLabel.position = CGPointMake(fontSize * 4, 0);
        _scoreLabel.text = [NSString stringWithFormat:@"%i", score];
        [self addChild:_scoreLabel];
    }
    return self;
}

#pragma mark - Animation

-(void)setScore:(int)score {
    _score = score;
    [self updateDisplay];
}

// Compute next multiple of 10 from _currentScore in the direction of _score:
-(int)computeNextScore {
    int next;
    if (_score > _currentScore) {
        if (_currentScore >= 0) {
            next = ((_currentScore + 10)/ 10) * 10;
        } else {
            next = ((_currentScore + 1)/ 10) * 10;
        }
        if (next > _score) {
            next = _score;
        }
    } else if (_score < _currentScore) {
        if (_currentScore <= 0) {
            next = ((_currentScore - 10) / 10) * 10;
        } else {
            next = ((_currentScore - 1) / 10) * 10;
        }
        if (next < _score) {
            next = _score;
        }
    } else {
        next = _score;
    }
    return next;
}

-(void)updateDisplay {
    if (_score != _currentScore) {
        SKAction *wait = [SKAction waitForDuration:kAnimationDelay];
        SKAction *update = [SKAction runBlock:^() {
            _currentScore = [self computeNextScore];
            _scoreLabel.text = [NSString stringWithFormat:@"%i", _currentScore];
        }];
        SKAction *checkAgain = [SKAction performSelector:@selector(updateDisplay) onTarget:self];
        [self runAction:[SKAction sequence:@[wait, update, checkAgain]] withKey:kAnimationKey];
    } else {
        [self removeActionForKey:kAnimationKey];
    }
}

@end

_currentScore is the currently displayed score, and the computeNextScore method computes the next value to be displayed. It looks a bit complicated, but it works correctly for both positive and negative scores.

updateDisplay starts a sequence of three actions (if necessary): wait is for the delay, update computes the next score to be displayed and updates the label, and checkAgain causes the updateDisplay method to be called again.

The sequence action is created with a key. This has two advantages: The action can be removed, and starting a new action with the same key will automatically stop the previous action.

\$\endgroup\$
  • \$\begingroup\$ A very elegant solution, thanks for sharing it! \$\endgroup\$ – bazola Feb 4 '15 at 21:00

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.