How To Make a Simple Playing Card Game with Multiplayer and Bluetooth, Part 5

This is a post by iOS Tutorial Team member Matthijs Hollemans, an experienced iOS developer and designer. You can find him on Google+ and Twitter. Welcome back to our monster 7-part tutorial series on creating a multiplayer card game over Bluetooth or Wi-Fi using UIKit! If you are new to this series, check out the […] By Matthijs Hollemans.

Leave a rating/review
Save for later
Share
You are currently viewing page 4 of 5 of this article. Click here to view the first page.

Activating the players

The basic flow of the game is like this: the active player taps his stack to turn over the top-most card. Then the player to his left (you're always going clockwise) becomes active and everyone waits until that player turns over his top-most card. Activate the next player, turn over card, activate player, and so on.

This repeats until there's a matching pair on the table and someone yells "Snap!". In this section you'll focus on activating the players and having them turn over their cards. There's more to this than you may think!

Start by adding a new method signature to Game.h:

- (void)beginRound;

This method will initialize the new round and activate the first player (the one from _activePlayerPosition). Modify the afterDealing method in GameViewController.m to call this:

- (void)afterDealing
{
	[_dealingCardsSound stop];
	self.snapButton.hidden = NO;
	[self.game beginRound];
}

The dealing animation has finished, so now you can start the round and give control to the first player. Add the implementation of beginRound to Game.m:

- (void)beginRound
{
	[self activatePlayerAtPosition:_activePlayerPosition];
}

There will be more here soon, but for now it just calls activatePlayerAtPosition:. Add this new method as well:

- (void)activatePlayerAtPosition:(PlayerPosition)playerPosition
{
	if (self.isServer)
	{
		NSString *peerID = [self activePlayer].peerID;
		Packet* packet = [PacketActivatePlayer packetWithPeerID:peerID];
		[self sendPacketToAllClients:packet];
	}

	[self.delegate game:self didActivatePlayer:[self activePlayer]];
}

If you're the server then it sends a PacketActivatePlayer packet to all the clients to let them know a new player is active. On both server and client, the GameDelegate is notified so that it can place a graphic next to the name of the newly activated player.

Let's do the delegate method first. Add its signature to the protocol in Game.h:

- (void)game:(Game *)game didActivatePlayer:(Player *)player;

And implement it in GameViewController.m:

- (void)game:(Game *)game didActivatePlayer:(Player *)player
{
	[self showIndicatorForActivePlayer];
	self.snapButton.enabled = YES;
}

- (void)showIndicatorForActivePlayer
{
	[self hideActivePlayerIndicator];

	PlayerPosition position = [self.game activePlayer].position;

	switch (position)
	{
		case PlayerPositionBottom: self.playerActiveBottomImageView.hidden = NO; break;
		case PlayerPositionLeft:   self.playerActiveLeftImageView.hidden   = NO; break;
		case PlayerPositionTop:    self.playerActiveTopImageView.hidden    = NO; break;
		case PlayerPositionRight:  self.playerActiveRightImageView.hidden  = NO; break;
	}

	if (position == PlayerPositionBottom)
		self.centerLabel.text = NSLocalizedString(@"Your turn. Tap the stack.", @"Status text: your turn");
	else
		self.centerLabel.text = [NSString stringWithFormat:NSLocalizedString(@"%@'s turn", @"Status text: other player's turn"), [self.game activePlayer].name];
}

This requires that Game exposes the activePlayer method, though, so add that to Game.h as well:

- (Player *)activePlayer;

These changes take care of showing the active player on the server, but you still need to send out the PacketActivatePlayer packets.

Add a new Objective-C class to the project, named PacketActivatePlayer, subclass of Packet. Then replace PacketActivatePlayer.h with the following:

#import "Packet.h"

@interface PacketActivatePlayer : Packet

@property (nonatomic, copy) NSString *peerID;

+ (id)packetWithPeerID:(NSString *)peerID;

@end

Note: The new packet just has a peer ID. It's almost identical to the PacketOtherClientQuit message, which also just has a peer ID. You could have re-used that same packet class -- but with a different "packetType" value -- but I find that having a class name that describes what the packet is for helps to make the code easier to read.

Also, you never know if you need to change one of these packet classes later, making your code reuse actually work against you. In fact, that's what you'll do later on so that PacketOtherClientQuit and PacketActivatePlayer will actually work differently even though they carry the same data.

Next replace PacketActivatePlayer.m with the following:

#import "PacketActivatePlayer.h"
#import "NSData+SnapAdditions.h"

@implementation PacketActivatePlayer

@synthesize peerID = _peerID;

+ (id)packetWithPeerID:(NSString *)peerID
{
	return [[[self class] alloc] initWithPeerID:peerID];
}

- (id)initWithPeerID:(NSString *)peerID
{
	if ((self = [super initWithType:PacketTypeActivatePlayer]))
	{
		self.peerID = peerID;
	}
	return self;
}

+ (id)packetWithData:(NSData *)data
{
	size_t count;
	NSString *peerID = [data rw_stringAtOffset:PACKET_HEADER_SIZE bytesRead:&count];
	return [[self class] packetWithPeerID:peerID];
}

- (void)addPayloadToData:(NSMutableData *)data
{
	[data rw_appendString:self.peerID];
}

@end

In Packet.m add an #import for this subclass:

#import "PacketActivatePlayer.h"

And add a case-statement to packetWithData:

		case PacketTypeActivatePlayer:
			packet = [PacketActivatePlayer packetWithData:data];
			break;

Also import the packet in Game.m:

#import "PacketActivatePlayer.h"

Now the code compiles, but you don't do anything with these packets on the receiving end yet. In clientReceivedPacket:, add the following case-statement:

		case PacketTypeActivatePlayer:
			if (_state == GameStatePlaying)
			{
				[self handleActivatePlayerPacket:(PacketActivatePlayer *)packet];
			}
			break;

Add the handleActivatePlayerPacket: method as well:

- (void)handleActivatePlayerPacket:(PacketActivatePlayer *)packet
{
	NSString *peerID = packet.peerID;

	Player* newPlayer = [self playerWithPeerID:peerID];
	if (newPlayer == nil)
		return;

	_activePlayerPosition = newPlayer.position;
	[self activatePlayerAtPosition:_activePlayerPosition];
}

Now build and run the app again for the clients and the server and you should see something like this:

The game now highlights the active player

The first player, which was randomly chosen in beginGame, is now highlighted. The screenshot was taken from the server, but on the clients it should also highlight the same player, even though it will be in a different position on the screen.

Turning over the cards

The GameViewController nib has an invisible UIButton that roughly covers the bottom area of the screen where the user's closed cards stack is:

When the player taps this button, you will turn over his top-most card and then activate the next player.

The button is hooked up to several IBAction methods in GameViewController.m, so implement these now to respond to taps on the button:

- (IBAction)turnOverPressed:(id)sender
{
	[self showTappedView];
}

- (IBAction)turnOverEnter:(id)sender
{
	[self showTappedView];
}

- (IBAction)turnOverExit:(id)sender
{
	[self hideTappedView];
}

- (IBAction)turnOverAction:(id)sender
{
	[self hideTappedView];
}

The showTappedView and hideTappedView methods will place a special image on top of the top-most card in other to highlight it. It's always a good idea to show to the user whether or not their taps have an effect on on-screen elements.

First add a new instance variable:

@implementation GameViewController
{
	. . .
	UIImageView *_tappedView;
}

And then these two new methods:

- (void)showTappedView
{
	Player *player = [self.game playerAtPosition:PlayerPositionBottom];
	Card *card = [player.closedCards topmostCard];
	if (card != nil)
	{
		CardView *cardView = [self cardViewForCard:card];

		if (_tappedView == nil)
		{
			_tappedView = [[UIImageView alloc] initWithFrame:cardView.bounds];
			_tappedView.backgroundColor = [UIColor clearColor];
			_tappedView.image = [UIImage imageNamed:@"Darken"];
			_tappedView.alpha = 0.6f;
			[self.view addSubview:_tappedView];
		}
		else
		{
			_tappedView.hidden = NO;
		}

		_tappedView.center = cardView.center;
		_tappedView.transform = cardView.transform;
	}
}

- (void)hideTappedView
{
	_tappedView.hidden = YES;
}

The _tappedView itself is a UIImageView that is lazily loaded. The Darken.png image is a rounded rectangle, basically the shape of a playing card, that is completely black. You set the opacity of the image view to 0.6, so that it darkens whatever is below it. That happens to be the CardView for the top-most card on the Player's closedCards stack. You set the position and transform of the _tappedView to those of the CardView so that it is placed and rotated in exactly the same way.

This needs two new methods, first cardViewForCard:. Add this to the view controller:

- (CardView *)cardViewForCard:(Card *)card
{
	for (CardView *cardView in self.cardContainerView.subviews)
	{
		if (cardView.card == card)
			return cardView;
	}
	return nil;
}

This method simply loops through all the subviews of the "card container" UIView to find the CardView that represents the specified Card object. This is why you added the CardViews to their own container, so that you don't have to look at all the UIViews in our screen, only those that are guaranteed to be CardViews.

The other method is topmostCard, which belongs in Stack. Add it to Stack.h and Stack.m:

- (Card *)topmostCard
{
	return [_cards lastObject];
}

This just lets you peek at the top-most Card, but doesn't actually remove it from the Stack. Run the app and tap on your player's closed stack. It should look something like this:

Example of a highlighted card

Beyond highlighting the card, tapping doesn't do anything yet, so let's make that happen now. Change GameViewController's turnOverAction: to:

- (IBAction)turnOverAction:(id)sender
{
	[self hideTappedView];
	[self.game turnCardForPlayerAtBottom];
}

This calls a new method on Game, so add the signature to Game.h:

- (void)turnCardForPlayerAtBottom;

And the method itself in Game.m:

- (void)turnCardForPlayerAtBottom
{
	if (_state == GameStatePlaying 
		&& _activePlayerPosition == PlayerPositionBottom
		&& [[self activePlayer].closedCards cardCount] > 0)
	{
		[self turnCardForPlayer:[self activePlayer]];
	}
}

- (void)turnCardForPlayer:(Player *)player
{
	NSAssert([player.closedCards cardCount] > 0, @"Player has no more cards");

	Card *card = [player turnOverTopCard];
	[self.delegate game:self player:player turnedOverCard:card];
}

The reason turnCardForPlayerAtBottom calls turnCardForPlayer:, is that you will also want to turn cards from elsewhere in the code later on. In turnCardForPlayerAtBottom you make sure that only the active player can turn a card, and that this can only happen while the game is in the GameStatePlaying state. And of course, the active player must actually have any cards to turn over.

The Player class gets a new method, turnOverTopCard, that transfers the Card from the Stack of closed cards to the open cards. Add the signature to Player.h and the method itself to Player.m:

- (Card *)turnOverTopCard
{
	NSAssert([self.closedCards cardCount] > 0, @"No more cards");

	Card *card = [self.closedCards topmostCard];
	card.isTurnedOver = YES;
	[self.openCards addCardToTop:card];
	[self.closedCards removeTopmostCard];

	return card;
}

This assumes Card has an isTurnedOver property, which it doesn't yet have. In Card.h add:

@property (nonatomic, assign) BOOL isTurnedOver;

And synthesize this new property in Card.m:

@synthesize isTurnedOver = _isTurnedOver;

You also need to add a removeTopmostCard method to Stack.m, and its declaration to Stack.h:

- (void)removeTopmostCard
{
	[_cards removeLastObject];
}

As you can see, Stack is a pretty basic class that just has wrappers around the methods from NSMutableArray. But it does read a lot more naturally for our problem domain: addCardToTop and removeTopmostCard actually sound like operations you would do on a pile of cards.

One more thing to do. The turnCardForPlayer: method in Game.m calls a new delegate method, game:player:turnedOverCard:. Add the signature for this method to the delegate protocol in Game.h:

- (void)game:(Game *)game player:(Player *)player turnedOverCard:(Card *)card;

And implement this method in GameViewController.m:

- (void)game:(Game *)game player:(Player *)player turnedOverCard:(Card *)card
{
	[_turnCardSound play];

	CardView *cardView = [self cardViewForCard:card];
	[cardView animateTurningOverForPlayer:player];
}

This plays a new sound and performs a new animation. Let's first add the "turning card sound". Add a new instance variable:

@implementation GameViewController
{
	. . .
	AVAudioPlayer *_turnCardSound;
}

The code to load this sound effect goes into loadSounds:

- (void)loadSounds
{
	. . .

	url = [[NSBundle mainBundle] URLForResource:@"TurnCard" withExtension:@"caf"];
	_turnCardSound = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:nil];
	[_turnCardSound prepareToPlay];
}

Now the animation. Add the method signature to CardView.h:

- (void)animateTurningOverForPlayer:(Player *)player;

And the method itself to CardView.m:

- (void)animateTurningOverForPlayer:(Player *)player
{
	[self loadFront];
	[self.superview bringSubviewToFront:self];

	UIImageView *darkenView = [[UIImageView alloc] initWithFrame:self.bounds];
	darkenView.backgroundColor = [UIColor clearColor];
	darkenView.image = [UIImage imageNamed:@"Darken"];
	darkenView.alpha = 0.0f;
	[self addSubview:darkenView];

	CGPoint startPoint = self.center;
	CGPoint endPoint = [self centerForPlayer:player];
	CGFloat afterAngle = [self angleForPlayer:player];

	CGPoint halfwayPoint = CGPointMake((startPoint.x + endPoint.x)/2.0f, (startPoint.y + endPoint.y)/2.0f);
	CGFloat halfwayAngle = (_angle + afterAngle)/2.0f;

	[UIView animateWithDuration:0.15f
		delay:0.0f
		options:UIViewAnimationOptionCurveEaseIn
		animations:^
		{
			CGRect rect = _backImageView.bounds;
			rect.size.width = 1.0f;
			_backImageView.bounds = rect;

			darkenView.bounds = rect;
			darkenView.alpha = 0.5f;

			self.center = halfwayPoint;
			self.transform = CGAffineTransformScale(CGAffineTransformMakeRotation(halfwayAngle), 1.2f, 1.2f);
		}
		completion:^(BOOL finished)
		{
			_frontImageView.bounds = _backImageView.bounds;
			_frontImageView.hidden = NO;

			[UIView animateWithDuration:0.15f
				delay:0
				options:UIViewAnimationOptionCurveEaseOut
				animations:^
				{
					CGRect rect = _frontImageView.bounds;
					rect.size.width = CardWidth;
					_frontImageView.bounds = rect;

					darkenView.bounds = rect;
					darkenView.alpha = 0.0f;

					self.center = endPoint;
					self.transform = CGAffineTransformMakeRotation(afterAngle);
				}
				completion:^(BOOL finished)
				{
					[darkenView removeFromSuperview];
					[self unloadBack];
				}];
		}];
}

That's a big one! Several things are going on here. The new loadFront method will load the UIImageView with the card's front-facing picture. You also load Darken.png (the same one you used earlier in GameViewController to highlight the card when it was tapped) into a new UIImageView and add it as a subview, but its alpha is initially 0.0, so it is fully transparent.

Then you calculate the end position and angle for the card. To make this work you will have to change centerForPlayer: to recognize that the Card is now turned over, which you'll do in a sec. For a turned-over card, centerForPlayer: returns a slightly different position, so that it moves over to the open pile.

The animation itself happens in two steps, which is why you calculate the halfway point and the halfway angle. The first step of the animation reduces the width of the card to 1 point, while at the same time making the darkenView more opaque. Because the darkenView covers the entire surface of the card, the CardView now appears darker, which simulates the shadow that the light casts on the card. It's only a subtle effect, but subtle is your new middle name.

Step 1 of the turn-over animation

At this point the card is half flipped over. Now you swap the back image with the front image and resize the card view back to its full width, while simultaneously making the darken view fully transparent again. Once the animation is complete, you remove the darken view and the UIImageView for the back because you no longer need them, and the card is turned over:

Step 2 of the turn-over animation

Notice also that you slightly scale up the card view (by 120%) as it approaches the halfway point. That makes it seem like the card is actually lifted up by the player. Another subtle tweak that makes our animation more lifelike.

Here's the promised change to the centerForPlayer: method:

- (CGPoint)centerForPlayer:(Player *)player
{
	CGRect rect = self.superview.bounds;
	CGFloat midX = CGRectGetMidX(rect);
	CGFloat midY = CGRectGetMidY(rect);
	CGFloat maxX = CGRectGetMaxX(rect);
	CGFloat maxY = CGRectGetMaxY(rect);

	CGFloat x = -3.0f + RANDOM_INT(6) + CardWidth/2.0f;
	CGFloat y = -3.0f + RANDOM_INT(6) + CardHeight/2.0f;

	if (self.card.isTurnedOver)
	{
		if (player.position == PlayerPositionBottom)
		{
			x += midX + 7.0f;
			y += maxY - CardHeight - 30.0f;
		}
		else if (player.position == PlayerPositionLeft)
		{
			x += 31.0f;
			y += midY - 30.0f;
		}
		else if (player.position == PlayerPositionTop)
		{
			x += midX - CardWidth - 7.0f;
			y += 29.0f;
		}
		else
		{
			x += maxX - CardHeight + 1.0f;
			y += midY - CardWidth - 45.0f;
		}	
	}
	else
	{
		if (player.position == PlayerPositionBottom)
		{
			x += midX - CardWidth - 7.0f;
			y += maxY - CardHeight - 30.0f;
		}
		else if (player.position == PlayerPositionLeft)
		{
			x += 31.0f;
			y += midY - CardWidth - 45.0f;
		}
		else if (player.position == PlayerPositionTop)
		{
			x += midX + 7.0f;
			y += 29.0f;
		}
		else
		{
			x += maxX - CardHeight + 1.0f;
			y += midY - 30.0f;
		}
	}

	return CGPointMake(x, y);
}

And of course you have to supply the missing loadFont and unloadBack methods:

- (void)unloadBack
{
	[_backImageView removeFromSuperview];
	_backImageView = nil;
}

- (void)loadFront
{
	if (_frontImageView == nil)
	{
		_frontImageView = [[UIImageView alloc] initWithFrame:self.bounds];
		_frontImageView.contentMode = UIViewContentModeScaleToFill;
		_frontImageView.hidden = YES;
		[self addSubview:_frontImageView];

		NSString *suitString;
		switch (self.card.suit)
		{
			case SuitClubs:    suitString = @"Clubs"; break;
			case SuitDiamonds: suitString = @"Diamonds"; break;
			case SuitHearts:   suitString = @"Hearts"; break;
			case SuitSpades:   suitString = @"Spades"; break;
		}

		NSString *valueString;
		switch (self.card.value)
		{
			case CardAce:   valueString = @"Ace"; break;
			case CardJack:  valueString = @"Jack"; break;
			case CardQueen: valueString = @"Queen"; break;
			case CardKing:  valueString = @"King"; break;
			default:        valueString = [NSString stringWithFormat:@"%d", self.card.value];
		}

		NSString *filename = [NSString stringWithFormat:@"%@ %@", suitString, valueString];
		_frontImageView.image = [UIImage imageNamed:filename];
	}
}

A keen eye will have noticed that you set the scale mode to UIViewContentModeScaleToFill. That's necessary to make the card-flip illusion work, because you are resizing the image view (by reducing its width) and you do want the image itself to resize accordingly.

And that's it for the animation. The player who is now first active can keep flipping over cards until he runs out.

Tip: To debug animations you can choose the Toggle Slow Animations option from the Simulator's Debug menu, or tap the Shift key three times in succession while the Simulator window is active. That will show you the animations in slow motion, which is a great help.

If you're a fan of Core Animation, then you may find it easier to flip the card with a 3D animation around the Y axis. For Snap!, reducing the width looks good enough, even though it is not 100% perspective-correct. If you were thinking about using the built-in "TransitionFlipFromLeft" UIView-transition, then know that won't work very well because our card views are rotated already around the center of the table and then an additional rotation makes it look weird.

Contributors

Over 300 content creators. Join our team.