iOS Game Development Cookbook (2014)

Chapter 13. Game Controllers and External Screens

iOS devices can interact with a wide range of other devices. Some of these devices, such as external screens and game controllers, are particularly useful when you’re building games!

iOS has supported multiple screens for several versions now, but as of iOS 7 it also supports game controllers, which are handheld devices that provide physical buttons for your players to use. Game controllers have both advantages and disadvantages when compared with touchscreens. Because a game controller has physical buttons, the player’s hands can feel where the controls are, which makes it a lot easier to keep attention focused on the action in the game. Additionally, game controllers can have analog inputs: a controller can measure how hard a button is being held down, and the game can respond accordingly. However, game controllers have fixed buttons that can’t change their position, or look and feel, which means that you can’t change your controls on the fly.

Game controllers that work with iOS devices must obey a specific set of design constraints specified by Apple; these constraints mean that you can rely on game controllers built by different manufacturers to all behave in a consistent way and provide the same set of controls for your games. In a move that is both as empowering for gamers as it is infuriating for developers, Apple requires that all iOS games must be playable without a controller at all, even if they support a controller. A controller must never be required by an iOS game. This means that you’ll end up developing two user interfaces: one with touchscreen controls, and one with game-controller controls.

To make matters more complex, there are several different profiles of game controller. The simplest (and usually cheapest) is the standard game controller (Figure 13-1), which features two shoulder buttons, four face buttons, a pause button, and a d-pad. The next step up is the extendedgamepad(Figure 13-1), which includes everything in the standard profile, and adds two thumbsticks and two triggers. Your game doesn’t need to make use of every single button that’s available, but it helps.

The basic game controller.

Figure 13-1. The basic game controller

The extended game controller. Note the thumbsticks and additional shoulder buttons.

Figure 13-2. The extended game controller (note the thumbsticks and additional shoulder buttons)

In addition to game controllers, iOS games can make use of external screens. These can be directly connected to your device via a cable, or they can be wirelessly connected via AirPlay. Using external screens, you can do a number of things: for example, you can make your game appear on a larger screen than the one that’s built in, or even turn the iPhone into a game controller and put the game itself on a television screen (effectively turning the device into a portable games console).

Like controllers, external screens should never be required by your game. External screens are useful for displaying supplementary components of your game, or providing the main game view while using the iOS device itself as a controller and secondary view.

In this chapter, you’ll learn how to connect to and use game controllers, how to use multiple screens via cables and wireless AirPlay, and how to design and build games that play well on the iPhone/iPod touch and iPad, or both.

Detecting Controllers

Problem

You want to determine whether the user is using a game controller. You also want to know when the user connects and disconnects the controller.

Solution

Game controllers are represented by instances of the GCController class. Each GCController lets you get information about the controller itself and the state all of its buttons and controls.

To get a GCGameController, you ask the GCController class for the controllers property, which is the list of all currently connected controllers:

for (GCController* controller in [GCController controllers]) {

    NSLog(@"Controller made by %@", controller.vendorName);

}

The controllers array updates whenever controllers are connected or disconnected. If the user plugs in a controller or disconnects one, the system sends a GCControllerDidConnectNotification or a GCControllerDidDisconnectNotification, respectively. You can register to receive these notifications like this:

[[NSNotificationCenter defaultCenter] addObserver:self

    selector:@selector(controllerConnected:)

    name:GCControllerDidConnectNotification object:nil];

[[NSNotificationCenter defaultCenter] addObserver:self

    selector:@selector(controllerDisconnected:)

    name:GCControllerDidDisconnectNotification object:nil];

// Elsewhere:

- (void) controllerConnected:(NSNotification*)notification {

    GCController* newController = notification.object;

    // Do something with newController

}

- (void) controllerDisconnected:(NSNotification*)notification {

    GCController* controller = notification.object;

    // Controller just got disconnected, deal with it

}

When a controller is connected, you can find out whether it’s a standard gamepad or an extended gamepad by using the gamepad and extendedGamepad properties:

GCController* controller = ... // a GCController

if (controller.extendedGamepad) {

    // It's an extended gamepad

} else if (controller.gamepad) {

    // It's a standard gamepad

} else {

    // It's something else entirely, and probably can't be used by your game

}

Discussion

The GCController class updates automatically when a controller is plugged in to the device. However, your user might have a wireless controller that uses Bluetooth to connect to the iPhone, and it might not be connected when your game launches.

Your player can leave the game and enter the Settings application to connect the device, but you might prefer to let the player connect the controller while still in your game. To do this, you use the startWirelessControllerDiscoveryWithCompletionHandler: method. When you call this, the system starts looking for nearby game controllers, and sends you a GCControllerDidConnectNotification for each one that it finds. Once the search process is complete, regardless of whether or not any controllers were found, the method calls a completion handler block:

[GCController startWirelessControllerDiscoveryWithCompletionHandler:^{

    // This code is called once searching is finished

}];

// Notifications will now be sent when controllers are discovered

You can also manually stop the searching process with the stopWirelessControllerDiscovery method:

[GCController stopWirelessControllerDiscovery];

It’s important to note that the system won’t show any built-in UI when you’re searching for wireless controllers. It’s up to you to show UI that indicates that you’re searching for controllers.

Once a wireless controller is connected, your game treats it just like a wired one—there’s no difference in the way you talk to it.

Once you have a GCController, you can set the playerIndex property. When you set this property, an LED on the controller lights up to let the player know which player he is. This property is actually remembered by the controller and is the same across all games, so that the player can move from game to game and not have to relearn which player number he is in multiplayer games.

Getting Input from a Game Controller

Problem

You would like people to be able to control your game using their external controllers.

Solution

Each controller provides access to its buttons through various properties:

GCController *controller = ... // a GCController

BOOL buttonAPressed = controller.gamePad.buttonA.pressed;

float buttonAPressAmount = controller.gamePad.buttonA.value;

You use the same technique to get information about the gamepad’s directional pads. The d-pad and the thumbsticks are both represented as GCControllerDirectionPad classes, which lets you treat them as a pair of axes (i.e., the x-axis and the y-axis), or as four separate buttons (up, down, left, and right):

// -1 = fully left, 1 = fully right

float xAxis = controller.gamePad.dpad.xAxis;

// Alternatively:

BOOL isLeftButtonPressed = controller.gamePad.dpad.left.pressed;

Discussion

There are two different types of inputs available in a game controller:.

§  A button input tells you whether a button is being pressed, as a Boolean YES or NO. Alternatively, you can find out how much a button is being pressed down by, as a floating-point value that goes from 0 (not pressed down at all) to 1 (completely pressed down).

§  An axis input provides two-dimensional information on how far left, right, up, and down the d-pad or thumbstick is being pressed by the user.

The face and shoulder buttons are all represented as GCControllerButtonInput objects, which let you get their value either as a simple BOOL or as a float. The d-pad and the thumbsticks are both represented as GCControllerAxisInput objects.

Both button inputs and axis inputs also let you provide value changed handlers, which are blocks that the system calls when an input changes value. You can use these to make your game run code when the user interacts with the controller, as opposed to continuously polling the controller to see its current state.

For example, if you want to get a notification every time the A button on the controller is interacted with, you can do this:

controller.gamePad.buttonA.valueChangedHandler = ^(GCControllerButtonInput

    *button, float value, BOOL pressed) {

        // The button has changed state, do something about it

};

This applies to both button inputs and axis inputs, so you can attach handlers to the thumbsticks and d-pad as well. Note that the value changed handler will be called multiple times while a button is pressed, because the value property will change continuously as the button is being pressed down and released.

In addition to adding handlers to the inputs, you can also add a handler block to the controller’s pause button:

controller.controllerPausedHandler = ^(GCController* controller) {

    // Toggle between being paused or not

}

NOTE

The controller itself doesn’t store any information about whether the game is paused or not—it’s up to your game to keep track of the pause state. All the controller will do is tell you when the button is pressed.

Showing Content via AirPlay

Problem

You would like to use AirPlay to wirelessly display elements of your game on a high-definition screen via an Apple TV.

Solution

Use an MPVolumeView to provide a picker, which lets the user select an AirPlay device.

UIView* view = ... // a UIView you want to show the picker in

MPVolumeView *volumeView = [[MPVolumeView alloc] init] ;

volumeView.showsVolumeSlider = NO; // don't show the volume slider,

                                   // just the AirPlay picker

[volumeView sizeToFit];

[view addSubview:volumeView];

This creates a button that, when tapped, lets the user select an AirPlay device to connect to the existing device. When the user selects a screen, a UIScreenDidConnectNotification is sent, and your game can use the AirPlay device using the UIScreen class (see Using External Screens.)

NOTE

The MPVolumeView will only show the AirPlay picker if there are AirPlay devices available. If no AirPlay device is nearby, nothing will appear.

Discussion

When the user has selected an AirPlay display, iOS treats it as if a screen is attached. You can then treat it as a UIScreen (there’s no distinction made between wireless screens and plugged-in screens).

Just like with a plugged-in screen, the contents of the primary screen will be mirrored onto the additional screen. If you give the screen to a UIWindow object, mirroring will be turned off and the screen will start showing the UIWindow. If you remove the UIScreen from the UIWindow, the screen will return to mirroring mode.

NOTE

If there are more than two screens attached, only one screen will mirror the main display. The other screens will be blank until you give them to a UIWindow.

Using External Screens

Problem

You would like to display elements of your game on a screen external to the iOS device.

Solution

To get the list of available screens, you use the UIScreen class:

for (UIScreen* connectedScreen in [UIScreen screens]) {

    NSLog(@"Connected screen: %@", NSStringFromCGSize(screen.currentMode.size));

}

On iPhones, iPod touches, and iPads, there’s always at least one UIScreen available—the built-in touchscreen. You can get access to it through the UIScreen’s mainScreen property:

UIScreen* mainScreen = [UIScreen mainScreen];

When you have a UIScreen, you can display content on it by creating a UIWindow and giving it to the UIScreen. UIWindows are the top-level containers for all views—in fact, they’re views themselves, which means you add views to a screen using the addSubview: method:

UIScreen* screen = ... // a UIScreen from the list

UIWindow* newWindow = [[UIWindow alloc] initWithFrame:screen.bounds];

UIView* aView = [[UIView alloc] initWithFrame:CGRect(10,10,100,100)];

aView.backgroundColor = [UIColor redColor];

[newWindow addSubview:aView];

newWindow.screen = screen;

Discussion

You can detect when a screen is connected by subscribing to the UIScreenDidConnectNotification and UIScreenDidDisconnectNotification notifications. These are sent when a new screen becomes available to the system—either because it’s been plugged in to the device, or because it’s become available over AirPlay—and when a screen becomes unavailable.

If you want to test external screens on the iOS simulator, you can select one by choosing Hardware→TV Out and choosing one of the available sizes of window (see Figure 13-3). Note that selecting an external display through this menu will restart the entire simulator, which will quit your game in the process. This means that while you can test having a screen connected, you can’t test the UIScreenDidConnectNotification and UIScreenDidDisconnectNotification notifications.

Choosing the size of the external screen in the iOS Simulator

Figure 13-3. Choosing the size of the external screen in the iOS Simulator

Designing Effective Graphics for Different Screens

Problem

You want your game to play well on different kinds of screens and devices, including iPhones, iPads, and large-scale televisions.

Solution

When you design your game’s interface, you need to consider several factors that differ between iPhones, iPads, and connected displays. Keep the following things in mind when considering how the player is going to interact with your game.

Designing for iPhones

An iPhone:

Is very portable

People can whip out an iPhone in two seconds, and start playing a game within five. Because they can launch games very quickly, they won’t want to wait around for your game to load.

Additionally, the iPhone is a very light device. Users can comfortably hold it in a single hand.

Has a very small screen

The amount of screen space available for you to put game content on is very small. Because the iPhone has a touchscreen, you can put controls on the screen. However, to use them, players will have to cover up the screen with their big, opaque fingers and thumbs. Keep the controls small—but not too small, because fingers are very imprecise.

Will be used in various locations, and with various degrees of attention

People play games on their iPhones in a variety of places: in bed, waiting for a train, on the toilet, at the dinner table, and more. Each place varies in the amount of privacy the user has, the amount of ambient noise, and the amount of distraction. If you’re making a game for the iPhone, your players will thank you if the game doesn’t punish them for looking away from the screen for a moment.

Additionally, you should assume that the players can’t hear a single thing coming from the speaker. They could be sitting in a quiet room, but they could just as easily be in a crowded subway station. They could also be playing in bed and trying not to wake their partners, or they could be hard of hearing or deaf.

Your game’s audio should be designed so that it enhances the game but isn’t necessary for the game to work. (Obviously, this won’t be achievable for all games; if you’ve got a game based heavily on sound, that’s still a totally OK thing to make!)

Designing for iPads

An iPad:

Is portable, but less spontaneous

Nobody quickly pulls out an iPad to play a 30-second puzzle game, and then puts it back in their pocket. Generally, people use iPads less frequently than smartphones but for longer periods. This means that “bigger” games tend to do very well on the iPad, beacuse the user starts playing them with an intent to play for at least a few minutes rather than (potentially) a few seconds.

Has a comparatively large screen

There are two different types of iPad screens: the one present on the iPad mini, and the one present on larger-size iPads (such as the iPad 2 and the iPad Air). The mini’s screen is smaller, but still considerably larger than that on the iPhone. This gives you more room to place your controls, and gives the player a bigger view of the game’s action.

However, the flipside is that the iPad is heavier than the iPhone. iPads generally need to be held in both hands, or placed on some kind of support (like a table or the player’s lap). This contributes to the fact that iPads are used less often but for longer sessions: it takes a moment to get an iPad positioned just how the user wants it.

Will be used in calmer conditions

For the same reason, an iPad tends to be used when the user is sitting rather than walking around, and in less hectic and public environments. The user will also be more likely to give more of their attention to the device.

Designing for larger screens

When the player has connected a larger screen:

They’re not moving around

An external screen tends to be fixed in place, and doesn’t move around. If the screen is plugged directly into the iPad, this will also restrict movement. This means that the players are likely to play for a longer period of time—because they’ve invested the energy in setting up the device with their TV, they’ll be in for the (relatively) long haul.

The player has two screens to look at

A player who’s connected an external screen to his iOS device will still be holding the device in his hands, but he’s more likely to not be looking at it. This means that he’s not looking at where your controls are. If he’s not using a controller, which is likely, he won’t be able to feel where one button ends and another begins. This means that your device should show very large controls on the screen, so that your users can focus on their wonderfully huge televisions and not have to constantly look down at the device.

Having two devices can be a tremendous advantage for your game, for example, if you want to display secondary information to your user—Real Racing 2 does this very well, in that it shows the game itself on the external screen, and additional info like the current speed and the map on the device.

More than one person can comfortably look at the big screen

Large displays typically have a couch in front of them, and more than one person can sit on a couch. This means that you can have multiple people playing a game, though you need to keep in mind that only one device can actually send content to the screen.

Discussion

Generally, you’ll get more sales if your game works on both the iPhone and the iPad. Players have their own preferences, and many will probably have either an iPhone or an iPad—it’s rare to have both, because Apple products are expensive.

When it comes to supporting large screens, it’s generally a cool feature to have, but it’s not very commonplace to have access to one. You probably shouldn’t consider external screen support to be a critical feature of your game unless you’re deliberately designing a game to be played by multiple people in the same room.

Dragging and Dropping

Problem

You want to drag and drop objects into specific locations. If an object is dropped somewhere it can’t go, it should return to its origin. (This is particularly useful in card games.)

Solution

Use gesture recognizers to implement the dragging itself. When the gesture recognizer ends, check to see whether the drag is over a view that you consider to be a valid destination. If it is, position the view over the destination; if not, move back to its original location.

The following code provides an example of how you can do this. In this example, CardSlot objects create Cards when tapped; these Card objects can be dragged and dropped only onto other CardSlots, and only if those CardSlot objects don’t already have a card on them, as shown inFigure 13-4.

The Drag and Drop example in this recipe

Figure 13-4. The Drag and Drop example in this recipe

Additionally, card slots can be configured so they delete any cards that are dropped on them.

Create a new Objective-C class called CardSlot, which is a subclass of UIImageView. Put the following code in CardSlot.h:

@class Card;

@interface CardSlot : UIImageView

// The card that's currently living in this card slot.

@property (nonatomic, weak) Card* currentCard;

// Whether cards should be deleted if they are dropped on this card

@property (assign) BOOL deleteOnDrop;

@end

Then, put the following code in CardSlot.m:

#import "CardSlot.h"

#import "Card.h"

@interface CardSlot ()

// The tap gesture recognizer; when the view is tapped, a new Card is created

@property (strong) UITapGestureRecognizer* tap;

@end

@implementation CardSlot

// Called when the view wakes up in the Storyboard.

- (void) awakeFromNib {

    // Create and configure the tap recognizer.

    self.tap = [[UITapGestureRecognizer alloc] initWithTarget:self

                action:@selector(tapped:)];

    [self addGestureRecognizer:self.tap];

    // UIImageViews default to userInteractionEnabled being set to NO,

    // so change that.

    self.userInteractionEnabled = YES;

}

// Called when the tap recognizer changes state.

- (void) tapped:(UITapGestureRecognizer*)tap {

    // If a tap has been recognized, create a new card

    if (tap.state == UIGestureRecognizerStateRecognized) {

        // Only card slots that aren't 'delete on drop' can create cards

        if (self.deleteOnDrop == NO) {

            Card* card = [[Card alloc] initWithCardSlot:self];

            [self.superview addSubview:card];

            self.currentCard = card;

        }

    }

}

// Called by the Card class to transfer ownership of the card.

- (void)setCurrentCard:(Card *)currentCard {

    // If we're marked as "delete on drop" then delete the card

    // and set our current card variable to nil

    if (self.deleteOnDrop) {

        [currentCard delete];

        _currentCard = nil;

        return;

    }

    // Otherwise, our current card becomes the new card

    _currentCard = currentCard;

}

@end

Then, create another UIImageView subclass called Card. Put the following code in Card.h:

@class CardSlot;

@interface Card : UIImageView

// Creates a new card, given a card slot for it to exist in.

- (id) initWithCardSlot:(CardSlot*)cardSlot;

// Deletes the card with an animation.

- (void) delete;

// The card slot that we're currently in.

@property (weak) CardSlot* currentSlot;

@end

And the following code in Card.m:

#import "Card.h"

#import "CardSlot.h"

@interface Card ()

@property (strong) UIPanGestureRecognizer* dragGesture;

@end

@implementation Card

// Creates a card, and

- (id)initWithCardSlot:(CardSlot *)cardSlot {

    if (cardSlot.currentCard != nil) {

        // This card slot already has a card and can't have another.

        return nil;

    }

    // All cards use the same image.

    self = [self initWithImage:[UIImage imageNamed:@"Card"]];

    if (self) {

        // Cards appear at the same position as the card slot.

        self.center = cardSlot.center;

        // We're using this slot as our current card slot

        self.currentSlot = cardSlot;

        // Create and set up the drag gesture

        self.dragGesture = [[UIPanGestureRecognizer alloc]

                            initWithTarget:self action:@selector(dragged:)];

        [self addGestureRecognizer:self.dragGesture];

        // UIImageViews default to userInteractionEnabled to NO; turn it on.

        self.userInteractionEnabled = YES;

    }

    return self;

}

// Called when the drag gesture recognizer changes state.

- (void) dragged:(UIPanGestureRecognizer*)dragGestureRecognizer {

    // If we've started dragging...

    if (dragGestureRecognizer.state == UIGestureRecognizerStateBegan) {

        // The drag has moved enough such that it's decided that a pan

        // is happening. We need to animate to the right location.

        CGPoint translation = [dragGestureRecognizer

                               translationInView:self.superview];

        translation.x += self.center.x;

        translation.y += self.center.y;

        // Animate to where the drag is at right now, and rotate

        // by a few degrees

        [UIView animateWithDuration:0.1 animations:^{

            self.center = translation;

            // Rotate by about 5 degrees

            self.transform = CGAffineTransformMakeRotation(M_PI_4 / 8.0);

        }];

        // Reset the drag

        [dragGestureRecognizer setTranslation:CGPointZero inView:self.superview];

        // Bring the card up to the front so that it appears over everything

        [self.superview bringSubviewToFront:self];

    } else if (dragGestureRecognizer.state == UIGestureRecognizerStateChanged) {

        // The drag location has changed. Update the card's position.

        CGPoint translation = [dragGestureRecognizer

                               translationInView:self.superview];

        translation.x += self.center.x;

        translation.y += self.center.y;

        self.center = translation;

        [dragGestureRecognizer setTranslation:CGPointZero inView:self.superview];

    } else if (dragGestureRecognizer.state == UIGestureRecognizerStateEnded) {

        // The drag has finished.

        // If the touch is over a CardSlot, and that card slot doesn't

        // already have a card, then we're now in that slot, and we should

        // move it; otherwise, return to the previous slot.

        CardSlot* destinationSlot = nil;

        // Loop over every view

        for (UIView* view in self.superview.subviews) {

            // First, check to see if the drag is inside the view;

            // if not, move on.

            if ([view pointInside:[dragGestureRecognizer locationInView:view]

                 withEvent:nil] == NO)

                continue;

            // If the view the drag is inside the view, check to see if

            // the view is a CardSlot. If it is, and it's got no card,

            // then it's our destination.

            if ([view isKindOfClass:[CardSlot class]]) {

                if ([(CardSlot*)view currentCard] == nil)

                    destinationSlot = (CardSlot*)view;

                break;

            }

        }

        // If we have a new destination, update the properties.

        if (destinationSlot) {

            self.currentSlot.currentCard = nil;

            self.currentSlot = destinationSlot;

            self.currentSlot.currentCard = self;

        }

        // Animate to our new destination

        [UIView animateWithDuration:0.1 animations:^{

            self.center = self.currentSlot.center;

        }];

    } else if (dragGestureRecognizer.state == UIGestureRecognizerStateCancelled) {

        // The gesture was interrupted (for example, because a phone call

        // came in). Move back to our original slot.

        [UIView animateWithDuration:0.1 animations:^{

            self.center = self.currentSlot.center;

        }];

    }

    // If the gesture ended or was cancelled, we need to return to

    // normal orientation.

    if (dragGestureRecognizer.state == UIGestureRecognizerStateEnded ||

    dragGestureRecognizer.state == UIGestureRecognizerStateCancelled) {

        // Rotate back to normal orientation.

        [UIView animateWithDuration:0.25 animations:^{

            self.transform = CGAffineTransformIdentity;

        }];

    }

}

// Removes the card from the view after fading out.

- (void) delete {

    [UIView animateWithDuration:0.1 animations:^{

        self.alpha = 0.0;

    } completion:^(BOOL finished) {

        [self removeFromSuperview];

    }];

}

@end

Add two images to your project: one called CardSlot.png, and another called Card.png.

Then, open your app’s storyboard and drag in a UIImageView. Make it use the CardSlot.png image, and set its class to CardSlot. Repeat this process a couple of times, until you have several card slots. When you run your app, you can tap on the card slots to make cards appear. Cards can be dragged and dropped between card slots; if you try to drop a card onto a card slot that already has a card, or try to drop it outside of a card slot, it will return to its original location.

You can also make a card slot delete any card that is dropped on it. To do this, select a card slot in the interface builder, go to the Identity inspector, and click the + button under the User Defined Runtime Attributes list. Change the newly added entry’s key path to deleteOnDrop, and make the Type “Boolean” and the Value “true”. When you re-run the app, any card that you drop on that card slot will disappear.

Discussion

Limiting where an object can be dragged and dropped provides constraints to your game’s interface, which can improve the user experience of your game. If anything can be dropped anywhere, the game feels loose and without direction. If the game takes control and keeps objects tidy, the whole thing feels a lot snappier.

In this example, the dragging effect is enhanced by the fact that when dragging begins, the card is rotated slightly; when the drag ends or is cancelled, the card rotates back. Adding small touches like this can dramatically improve how your game feels.