Animating the Characters

Adventure is a game that’s full of life. Leaves fall from trees all around the level, torches burn outside every goblin cave, sparks fly when projectiles hit the walls, smoke billows from destroyed caves, and green goo spews everywhere when a goblin dies.

To match the dynamic nature of the environment, we built extensive animations for all the moving characters in the game—the warrior and archer heroes, the goblins, and the level boss. These characters are always animated, even when idle, and each has a unique personality. Warriors walk with a swagger, archers swing their bows from side to side, goblins swing their arms and roll their eyes as they move, and the level boss staggers around swinging his club.

As with much of the behavior in Adventure, we deal with animations during the update loop. Whenever a movement or action occurs that should trigger an animation, we set a flag on the relevant character to request a specific animation state. During the update loop, we check this flag, and run an action to animate through the relevant sequence of frames, as shown in Figure 6-1.

Figure 6-1  Resolving the animation during the update loop

Requesting an Animation

All animated characters in Adventure descend ultimately from APACharacter and have their animated property set to YES. The APACharacter class provides a requestedAnimation property, which we set whenever we need to change the current animation, such as when attacking, walking, getting hit, or dying.

For example, we set the requested animation to APAAnimationStateWalk whenever we move a character. The move: method, which we call based on APAPlayer properties set in response to keyboard input, requests the animation before applying an action for the given movement.

  1. Adventure: APACharacter.m -move:
  2. - (void)move:(APAMoveDirection)direction withTimeInterval:(NSTimeInterval)timeInterval {
  3. CGFloat rot = self.zRotation;
  4. SKAction *action = nil;
  5. switch (direction) {
  6. case APAMoveDirectionForward:
  7. action = [SKAction moveByX:-sinf(rot)*self.movementSpeed*timeInterval
  8. y:cosf(rot)*self.movementSpeed*timeInterval
  9. duration:timeInterval];
  10. break;
  11. ... (Handle the other movement directions)
  12. }
  13. if (action) {
  14. self.requestedAnimation = APAAnimationStateWalk;
  15. [self runAction:action];
  16. }
  17. }

Resolving the Animation

We resolve all requested animations during the update loop—the APACharacter class overrides update: to call resolveRequestedAnimation.

  1. Adventure: APACharacter.m -resolveRequestedAnimation
  2. - (void)resolveRequestedAnimation {
  3. NSString *animationKey = nil;
  4. NSArray *animationFrames = nil;
  5. APAAnimationState animationState = self.requestedAnimation;
  6. switch (animationState) {
  7. default:
  8. case APAAnimationStateIdle:
  9. animationKey = @"anim_idle";
  10. animationFrames = [self idleAnimationFrames];
  11. break;
  12. ... (Handle other requested animations)
  13. }
  14. if (animationKey) {
  15. [self fireAnimationForState:animationState usingTextures:animationFrames withKey:animationKey];
  16. }
  17. self.requestedAnimation = self.dying ? APAAnimationStateDeath : APAAnimationStateIdle;
  18. }
10

Each animated APACharacter subclass overrides the various animation frame methods (idleAnimationFrames, walkAnimationFrames, and so on) to provide an array of textures for the animation. These textures are preloaded, as described in “Loading Shared Character Assets.”

17

As soon as we’ve started the animation, we reset the requestedAnimation property.

This reset animation state decides the next animation, if nothing occurs to request a different state before the current animation completes.

The fireAnimationForState:usingTextures:withKey: method uses an SKAction sequence to play through the animation frames and call animationHasCompleted: when done.

  1. Adventure: APACharacter.m -fireAnimationForState:usingTextures:withKey:
  2. - (void)fireAnimationForState:(APAAnimationState)animationState usingTextures:(NSArray *)frames withKey:(NSString *)key {
  3. SKAction *action = [self actionForKey:key];
  4. if (action || [frames count] < 1) {
  5. return; // we already have a running animation or there aren't any frames to animate
  6. }
  7. self.activeAnimationKey = key;
  8. [self runAction:[SKAction sequence:@[
  9. [SKAction animateWithTextures:frames timePerFrame:self.animationSpeed resize:YES restore:NO],
  10. [SKAction runBlock:^{
  11. [self animationHasCompleted:animationState];
  12. }]]]
  13. withKey:key];
  14. }
3

Before starting a new animation, we check to make sure we haven’t already set an animation with the given key.

We don’t need to do anything to keep an animation playing—Sprite Kit runs the action and takes care of running through the frames automatically during the update loop.

Animation Completion

When an animation completes, the APACharacter class receives the animationHasCompleted: callback. We use this callback to perform general cleanup work, such as fading out the shadow blob if the character just died and resetting various properties.

The animationHasCompleted: method in turn calls animationDidComplete:, which is overridden by subclasses to perform character-specific work. The APAHeroCharacter class, for example, uses this method to perform the tasks necessary when a hero dies, as well as to fire the projectile.

  1. Adventure: APAHeroCharacter.m -animationComplete
  2. - (void)animationDidComplete:(APAAnimationState)animationState {
  3. switch (animationState) {
  4. case APAAnimationStateDeath:{
  5. APAMultiplayerLayeredCharacterScene * __weak scene = [self characterScene];
  6. SKEmitterNode *emitter = ... (load, configure, and run the death emitter)
  7. APACharacter * __weak weakSelf = self;
  8. [self runAction:[SKAction sequence:@[
  9. [SKAction waitForDuration:4.0f],
  10. [SKAction runBlock:^{
  11. [scene heroWasKilled:weakSelf];
  12. }],
  13. [SKAction removeFromParent],
  14. ]]];
  15. break;
  16. case APAAnimationStateAttack:
  17. [self fireProjectile];
  18. break;
  19. default:
  20. break;
  21. }
  22. }
5

We mark the variables as __weak to avoid any possibility of a strong reference cycle when capturing scene and self in the action block.

18

We wait until after the attack animation completes before firing the projectile, to give the illusion of a warrior throwing a hammer, or an archer firing a bow.

If we were to fire the projectile at the same time as we trigger the attack animation, it would look very strange.

Firing Projectiles

The APAHeroCharacter class is responsible for firing the projectile when the attack animation completes, and APAWarrior and APAArcher supply a suitable projectile along with an attached emitter node to accentuate the movement. Each hero projectile has a limited life—it starts to fade out after 0.6 seconds, and disappears completely 1 second after firing. We run a variety of actions on each projectile to handle movement, fade out, removal, and also to play a firing sound.

  1. Adventure: APAHeroCharacter.m -fireProjectile
  2. - (void)fireProjectile {
  3. // ... (Copy and configure the hero-specific projectile)
  4. // ... (Create an emitter for the projectile)
  5. CGFloat rot = self.zRotation;
  6. [projectile runAction:[SKAction moveByX:-sinf(rot)*kHeroProjectileSpeed*kHeroProjectileLifetime
  7. y:cosf(rot)*kHeroProjectileSpeed*kHeroProjectileLifetime
  8. duration:kHeroProjectileLifetime]];
  9. [projectile runAction:[SKAction sequence:@[[SKAction waitForDuration:kHeroProjectileFadeOutTime],
  10. [SKAction fadeOutWithDuration:kHeroProjectileLifetime - kHeroProjectileFadeOutTime],
  11. [SKAction removeFromParent]]]];
  12. [projectile runAction:[self projectileSoundAction]];
  13. projectile.userData = [NSMutableDictionary dictionaryWithObject:self.player forKey:kPlayer];
  14. }
13

We create the shared projectileSoundAction during scene loading, so that the sound itself is preloaded from disk. If we didn’t preload the sound, we’d see dropped frames while the file was loaded the first time the projectile was fired.

15

We use the node’s userData dictionary to keep track of the player responsible for firing the projectile.

We use this information to increase that player’s score if the projectile hits an enemy—the different enemy class’s collidedWith: methods call addToScore:afterEnemyKillWithProjectile: on the scene, which is responsible for increasing the score for the relevant player.