Controlling the Characters

Adventure supports up to four players. One player, referred to throughout the code as the default player, uses the keyboard (for OS X) and touch (on iOS) to control the hero character. The other players, if any, use external game controllers to control their hero characters.

We receive events directly through the responder chain or through the Game Controller framework’s callback handlers, and we manipulate hero characters as necessary during the next pass through the Sprite Kit update loop. We use APAPlayer objects to represent each player; whenever a player presses a key, touches the screen, or manipulates a connected game controller, we update a property on the APAPlayer instance. During the update loop, we walk through all APAPlayer instances and make the necessary adjustments to the position of each player’s hero character, as shown in Figure 5-1.

Figure 5-1  Bridging between the responder chain and the update loop

In addition to player-controlled characters, the goblins and level boss have artificial intelligence to control their movement and attack. We use a separate AI object, updated on each pass through the event loop, to look for nearby heroes; if one comes within a set range, we move the goblin or boss toward the hero, giving the appearance of the enemy chasing the hero through the maze. Goblin caves also have artificial intelligence that determines how frequently a cave should spawn goblins—the rate increases as heroes move closer.

Receiving Keyboard and Touch Events

Any Sprite Kit node can receive user input from the responder chain. In OS X, the SKNode class inherits from the NSResponder class, and in iOS, it inherits from UIResponder.

In Adventure, we implement both responder-chain and game-controller event handling in the APAMultiplayerLayeredCharacterScene class. We use conditional compiler directives to implement keyDown: and keyUp: when building for OS X, and touchesBegan:withEvent:, touchesMoved:withEvent:, and touchesEnded:withEvent: for iOS.

Handling OS X Keyboard Events

When the user presses a key, the APAMultiplayerLayeredCharacterScene class receives the keyDown: event. We set a property on the default APAPlayer object that corresponds to the key. For example, if the user presses the Up Arrow or ‘W’ key, we set the moveForward property to YES. We implement keyUp: to detect when the user releases the key so that we can unset the relevant property on APAPlayer.

To avoid duplicating code across keyDown: and keyUp:, both methods call through to a shared handleKeyEvent:keyDown: method to handle the event.

  1. Adventure: APAMultiplayerLayeredCharacterScene.m -handleKeyEvent:keyDown:
  2. - (void)handleKeyEvent:(NSEvent *)event keyDown:(BOOL)downOrUp {
  3. ... (Check whether the key is on the numeric pad)
  4. switch (keyChar) {
  5. case NSUpArrowFunctionKey:
  6. self.defaultPlayer.moveForward = downOrUp;
  7. break;
  8. ... (Handle the Down, Left, and Right arrow keys)
  9. }
  10. ... (Check non-numeric keys for W, A, S, D, or Space)
  11. }

Although we set APAPlayer properties as a direct result of input through the responder chain, we perform character movement and attack actions during the update loop. Each time through the update loop, we check the properties on all APAPlayer objects and move the relevant hero or trigger the attack action as necessary.

  1. Adventure: APAMultiplayerLayeredCharacterScene.m -update:
  2. - (void)update:(NSTimeInterval)currentTime {
  3. ...
  4. for (APAPlayer *player in self.players) {
  5. ... (Continue if the player is NSNull)
  6. APAHeroCharacter *hero = player.hero;
  7. ... (Continue if there's no hero yet, or if the hero is dying)
  8. if (player.moveForward) {
  9. [hero move:APAMoveDirectionForward withTimeInterval:timeSinceLast];
  10. } else if (player.moveBack) {
  11. [hero move:APAMoveDirectionBack withTimeInterval:timeSinceLast];
  12. }
  13. ... (Handle other keys)
  14. }
  15. }
9

We supply a time interval to all movement-related methods so that the character moves the right distance for a user event, regardless of the frame rate.

Handling iOS Touch Events

When the user touches an iOS device screen, we receive a call to touchesBegan:withEvent: on APAMultiplayerLayeredCharacterScene. We implement this method to set the target location to which the hero should move; if the touch occurred on an enemy, such as a goblin or cave, we instead interpret the touch as a fire action against that enemy.

As with OS X event handling, we use the APAPlayer instance to bridge between the touch and the update loop, but this time we use the moveRequested and targetLocation properties.

  1. Adventure: APAMultiplayerLayeredCharacterScene.m -touchesBegan:withEvent:
  2. - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
  3. ... (Return early if we don't have any heroes in play, or if we're already tracking a touch)
  4. UITouch *touch = [touches anyObject];
  5. APAPlayer *defaultPlayer = self.defaultPlayer;
  6. defaultPlayer.targetLocation = [touch locationInNode:defaultPlayer.hero.parent];
  7. BOOL wantsAttack = NO;
  8. NSArray *nodes = [self nodesAtPoint:[touch locationInNode:self]];
  9. for (SKNode *node in nodes) {
  10. if (node.physicsBody.categoryBitMask & (APAColliderTypeCave | APAColliderTypeGoblinOrBoss) {
  11. wantsAttack = YES;
  12. }
  13. }
  14. defaultPlayer.fireAction = wantsAttack;
  15. defaultPlayer.moveRequested = !wantsAttack;
  16. defaultPlayer.movementTouch = touch;
12

We set wantsAttack to YES if the touch hits any goblin, the level boss, or a cave, and then use this value to determine whether the player is trying to move the character or perform the attack action.

16

If the hero is attacking an enemy, we don’t interpret the touch as movement.

When compiling for iOS, the update: method in APAMultiplayerLayeredCharacterScene contains this additional block of code to deal with character movement:

  1. Adventure: APAMultiplayerLayeredCharacterScene.m -update:
  2. - (void)update:(NSTimeInterval)currentTime {
  3. ...
  4. #if TARGET_OS_IPHONE
  5. APAPlayer *defaultPlayer = self.defaultPlayer;
  6. if ([self.heroes count] > 0) {
  7. APAHeroCharacter *hero = defaultPlayer.hero;
  8. if (defaultPlayer.fireAction) {
  9. [hero faceTo:defaultPlayer.targetLocation];
  10. }
  11. if (defaultPlayer.moveRequested) {
  12. if (!CGPointEqualToPoint(defaultPlayer.targetLocation, hero.position)) {
  13. [hero moveTowards:defaultPlayer.targetLocation withTimeInterval:timeSinceLast];
  14. } else {
  15. defaultPlayer.moveRequested = NO;
  16. }
  17. }
  18. }
  19. #endif
  20. ...
9

If the player is attacking, we turn their hero to face towards the enemy target.

12

We check to see whether the hero is already at the requested location; if not, we move the hero toward the location.

15

Otherwise, we cancel the requested move.

The cross-platform code that loops through the APAPlayer instances later in update: is responsible for triggering the actual fire action:

  1. for (APAPlayer *player in self.players) {
  2. ...
  3. APAHeroCharacter *hero = player.hero;
  4. ...
  5. if (player.fireAction) {
  6. [hero performAttackAction];
  7. }
  8. ...
  9. }
  10. }

Supporting External Game Controllers

Adventure uses the Game Controller framework to communicate with external game controllers; we support up to four different players. If you connect a new controller, a new APAPlayer instance is created and a new hero is added to the game.

You’ll remember from “Creating the Scene” that the app delegate (OS X) or view controller (iOS) creates an instance of the APAAdventure scene, adds it to the view, and then calls configureGameControllers on the scene. We implement this method in APAMultiplayerLayeredCharacterScene to register to receive notifications whenever a game controller connects or disconnects. We also configure controllers that are already connected at game launch, and then begin the search for any wireless controllers:

  1. Adventure: APAMultiplayerLayeredCharacterScene.m -configureGameControllers
  2. - (void)configureGameControllers
  3. [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(gameControllerDidConnect:)
  4. name:GCControllerDidConnectNotification object:nil];
  5. [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(gameControllerDidDisconnect:)
  6. name:GCControllerDidDisconnectNotification object:nil];
  7. [self configureConnectedGameControllers];
  8. [GCController startWirelessControllerDiscoveryWithCompletionHandler:^{
  9. NSLog(@"Finished finding controllers");
  10. }
  11. }

Assigning Controllers to Players

Whenever a controller is detected, we check the playerIndex property of the controller. If the controller has previously been used with Adventure, this property corresponds to the index of the player in the array of APAPlayer instances; if it’s a new controller, the property is set to GCControllerPlayerIndexUnset.

If the player index was already set, we check to see whether there’s already an existing controller associated with the relevant APAPlayer instance; if so, we treat the controller as if it had never been used before, to avoid having multiple controllers control a single hero. Otherwise, we create a new hero for the player and add it to the world:

  1. Adventure: APAMultiplayerLayeredCharacterScene.m -assignPresetController:toIndex:
  2. - (void)assignPresetController:(CGController *)controller toIndex:(NSInteger)playerIndex {
  3. APAPlayer *player = self.players[playerIndex];
  4. ... (Check if the player is NSNull and create a new APAPlayer if so)
  5. if (player.controller && player.controller != controller) {
  6. [self assignUnknownController:controller];
  7. return;
  8. }
  9. [self configureController:controller forPlayer:player];
  10. }
6

The assignUnknownController method enumerates through the APAPlayer instances, looking for players that don’t yet have an assigned controller.

Preparing for Controller Events

The configureController:forPlayer: method sets the valueChangedHandler block property on all controller inputs that we recognize. For example, if the player manipulates the thumbstick or presses one of the direction pad buttons, we set the heroMoveDirection property on the APAPlayer instance; if the player presses button A, we set the fireAction property.

  1. - (void)configureController:(GCController *)controller forPlayer:(APAPlayer *)player {
  2. player.controller = controller;
  3. GCControllerDirectionPadValueChangedHandler dpadMoveHandler = ^(GCControllerDirectionPad *dpad, float xValue, float yValue) {
  4. float length = hypotf(xValue, yValue);
  5. if (length > 0.0f) {
  6. float invLength = 1.0f / length;
  7. player.heroMoveDirection = CGPointMake(xValue * invLength, yValue * invLength);
  8. } else {
  9. player.heroMoveDirection = CGPointZero;
  10. }
  11. };
  12. controller.extendedGamepad.leftThumbstick.valueChangedHandler = dpadMoveHandler;
  13. controller.gamepad.dpad.valueChangedHandler = dpadMoveHandler;
  14. GCControllerButtonValueChangedHandler fireButtonHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) {
  15. player.fireAction = pressed;
  16. };
  17. controller.gamepad.buttonA.valueChangedHandler = fireButtonHandler;
  18. controller.gamepad.buttonB.valueChangedHandler = fireButtonHandler;
  19. ... (Add a hero for the player, if necessary)
  20. }

As with keyboard and touch input, we use the APAPlayer object as a bridge between the time that we receive controller input and the time that we manipulate the relevant hero during the update loop.

The update: method is responsible for checking these APAPlayer properties, and triggering any relevant action or movement on the player’s hero.

  1. Adventure: APAMultiplayerLayeredCharacterScene.m -update:
  2. - (void)update:(NSTimeInterval)currentTime {
  3. ...
  4. for (APAPlayer *player in self.players) {
  5. ...
  6. APAHeroCharacter *hero = player.hero;
  7. ...
  8. if (player.fireAction) {
  9. [hero performAttackAction];
  10. }
  11. CGPoint heroMoveDirection = player.heroMoveDirection;
  12. if (hypotf(heroMoveDirection.x, heroMoveDirection.y) > 0.0f) {
  13. [hero moveInDirection:heroMoveDirection withTimeInterval:timeSinceLast];
  14. }
  15. }
  16. }
12

We use hypotf() to calculate the length of the hypotenuse of a triangle with side lengths x and y. If this result is greater than zero, we need to move the hero.

Rather than walking forward a preset amount after a keypress, or walking toward a specific point from a touch, the heroMoveDirection indicates an x and y floating-point value between –1.0 and 1.0, depending on how far and in which direction the user moved the thumbstick.

The moveInDirection: method on APAHeroCharacter uses these values to generate a target location based on the current hero position.

  1. Adeventure: APACharacter.m -moveInDirection:withTimeInterval:
  2. - (void)moveInDirection:(CGPoint)direction withTimeInterval:(NSTimeInterval)timeInterval {
  3. CGPoint curPosition = self.position;
  4. CGFloat movementSpeed = self.movementSpeed;
  5. CGFloat dx = movementSpeed * direction.x;
  6. CGFloat dy = movementSpeed * direction.y;
  7. CGFloat dt = movementSpeed * kMinTimeInterval;
  8. CGPoint targetPosition = CGPointMake(curPosition.x + dx, curPosition.y + dy);
  9. CGFloat ang = APA_POLAR_ADJUST(APARadiansBetweenPoints(targetPosition, curPosition));
  10. self.zRotation = ang;
  11. CGFloat distRemaining = hypotf(dx, dy);
  12. if (distRemaining < dt) {
  13. self.position = targetPosition;
  14. } else {
  15. self.position = CGPointMake(curPosition.x - sinf(ang)*dt, curPosition.y + cosf(ang)*dt);
  16. }
  17. ... (Set up the walk animation)
  18. }
10

APA_POLAR_ADJUST is a preprocessor macro, defined in APAGraphicsUtilities.h that adjusts an angle by one-half pi radians, or 90°.

All the assets in Adventure are created with “forward” facing up, in other words, oriented along the y-axis. The coordinate system in the game treats forward as facing to the right, oriented along the x-axis, so we need to adjust all sprite texture orientations by rotating 90° to the right.

Controlling Enemies with Artificial Intelligence

The enemies in Adventure—goblins, the level boss, and goblin caves—are all controlled by separate artificial intelligence objects. Goblins and the level boss use an instance of the APAChaseAI to chase any hero that is within a predefined distance; the goblin caves use the APASpawnAI to dictate how frequently new goblins are generated.

All enemy characters in Adventure descend from the APAEnemyCharacter class, which overrides the updateWithTimeSinceLastUpdate: method provided by APACharacter to call updateWithTimeSinceLastUpdate: on the artificial intelligence:

  1. Adventure: APAEnemyCharacter.m -updateWithTimeSinceLastUpdate:
  2. - (void)updateWithTimeSinceLastUpdate:(CFTimeInterval)interval {
  3. [super updateWithTimeSinceLastUpdate:interval];
  4. [self.intelligence updateWithTimeSinceLastUpdate:interval];
  5. }

This means that the artificial intelligence objects are updated on each pass through the update loop.

Implementing the Chase AI

The APAChaseAI class overrides the abstract APAArtificialIntelligence class update: method to chase the closest hero, but only if it’s within kEnemyAlertRadius distance of the enemy. If the hero is close enough to attack, we rotate the enemy to face the hero and trigger the attack action.

  1. Adventure: APAChaseAI.m -updateWithTimeSinceLastUpdate:
  2. - (void)updateWithTimeSinceLastUpdate:(CFTimeInterval)interval {
  3. APACharacter *ourCharacter = self.character;
  4. ... (Return early if our enemy character is dying)
  5. CGPoint position = ourCharacter.position;
  6. APAMultiplayerLayeredCharacterScene *scene = [ourCharacter characterScene];
  7. CGFloat closestHeroDistance = MAXFLOAT;
  8. for (APAHeroCharacter *hero in scene.heroes) {
  9. CGPoint heroPosition = hero.position;
  10. CGFloat distance = APADistanceBetweenPoints(position, heroPosition);
  11. if (distance < kEnemyAlertRadius && distance < closestHeroDistance && !hero.dying) {
  12. closestHeroDistance = distance;
  13. self.target = hero;
  14. }
  15. }
  16. APACharacter *target = self.target;
  17. ... (Return early if there's no target)
  18. CGPoint heroPosition = target.position;
  19. CGFloat chaseRadius = self.chaseRadius;
  20. if (closestHeroDistance > self.maxAlertRadius) {
  21. self.target = nil;
  22. } else if (closestHeroDistance > chaseRadius) {
  23. [self.chararacter moveTowards:heroPosition];
  24. } else if (closestHeroDistance < chaseRadius) {
  25. [self.character faceTo:heroPosition];
  26. [self.character performAttackAction];
  27. }
  28. }

Implementing the Spawn AI

The APASpawnAI class overrides the updateWithTimeSinceLastUpdate: method to determine how frequently a goblin cave should spawn new goblins. As with the chase artificial intelligence, we look for the closest hero. We then adjust the time between goblin spawns relative to the distance between the cave and the closest hero so that the cave generates goblins more frequently as the hero gets closer.

  1. Adventure: APASpawnAI.m -updateWithTimeSinceLastUpdate:
  2. - (void)updateWithTimeSinceLastUpdate:(CFTimeInterval)interval {
  3. APACave *cave = (id)self.character;
  4. ... (Return early if our cave is destroyed)
  5. CGFloat closestHeroDistance = kMinimumHeroDistance;
  6. CGPoint closestHeroPosition = CGPointZero;
  7. ... (Calculate the distance to, and get the location of, the closest hero)
  8. CGFloat distScale = (closestHeroDistance / kMinimumHeroDistance);
  9. cave.timeUntilNextGenerate -= interval;
  10. NSUInteger goblinCount = [cave.activeGoblins count];
  11. if (goblinCount < 1 || cave.timeUntilNextGenerate <= 0.0f || (distScale < 0.35f && cave.timeUntilNextGenerate > 5.0f)) {
  12. if (goblinCount < 1 || (!CGPointEqualToPoint(closestHeroPosition, CGPointZero) && [scene closestHeroPosition from:cave.position])) {
  13. [cave generate];
  14. }
  15. cave.timeUntilNextGenerate = (4.0f * distScale);
  16. }
  17. }