使用UIKit制作卡牌游戏(一)ios游戏篇

转自朋友Tommy 的翻译,自己只翻译了第三篇教程。

译者: Tommy | 原文作者: Matthijs Hollemans写于2012/06/29 
原文地址: http://www.raywenderlich.com/12735/how-to-make-a-simple-playing-card-game-with-multiplayer-and-bluetooth-part-1


image

这篇文章是由iOS教程团队成员Matthijs Hollemans发表的,一个经验丰富的开发工程师和设计师。你可以在Google+Twitter上找到他。

纸牌游戏在App Store上是非常流行的,超过2500个app了并且还在持续增长,所以是时候由raywenderlich.com来来教大家做纸牌游戏了。

另外,该系列教程总共7篇,用来展示如何做一个多人纸牌游戏,你可以和你的小伙伴们可以通过GameKit的peer-to-peer的特性使用蓝牙或Wi-Fi来玩。

虽然说你用这篇教程来做的是个游戏,但你并不需要使用OpenGL或像Cocos2D一样的游戏框架,使用的仅仅是一些标准的UIImageView和一些UIView的基本动画。

不使用OpenGL和Cocos2D的原因是我们不需要!对于做这个,UIKit足够了,并且UIKit擅长用于纸牌和棋盘游戏的制作,这类游戏的内容大都在屏幕內,并且只需要对一些view做一些简单的动画就可以了。

要一步一步地学习下面的教程,你需要使用4.3或更高版本的Xcode,如何你现在使用的是4.2,那么是时候升级了!

还有,要测试多用户的功能,你至少需要两台运行5.0或者更高版本系统的手机。如果你有Wi-Fi网络环境,你也可以用一个设备来玩,但最好还是用多个(我在写这边教程的时候用四个不同的设备)。

继续下面的教程吧,做你自己的多人纸牌游戏,用你那神乎其神的牌技,给你的小伙伴们留下深刻印象。

介绍: snap!


你将要做的这个纸牌游戏是一个叫做的snap的儿童游戏。这就是你最后完成游戏的画面。

image

什么,你还不清楚游戏的规则!好吧,玩这个游戏,需要2到4个人,使用52张牌,通过卡片配对的方式来赢牌,你的目标就是赢得所有牌。

在每轮开始前,都会重新洗牌,然后顺时针依次发牌,直到牌发完为止。这些牌都会正面朝下摆在玩家面前。

玩家顺时针依次翻牌。如果轮到你了,翻过你最上方的牌,如果你看到翻开的牌中有能和你的牌形成配对的时候,快速大喊一声"snap",则匹配成功。两个张牌具有相同的值,就能匹配,比如两张王,不管大王还是小王。

最快速喊出"snap!"并且两张牌确实匹配的那个玩家赢得这两张牌,然后将这两张牌放入自己正面朝下的那些牌中。直到一个玩家赢得了所有牌。如果玩家喊出了"snap!",但是并没有可匹配的牌,那么他要给其他每个玩家一张牌作为惩罚。

基本流程


这是一个通过蓝牙或Wi-Fi连接的多人游戏,你将使用GameKit框架来实现它。这里只用到了GameKit的peer-to-peer连接的特性,并没有使用Game Center相关知识,其实这个教程主要使用的只有一个类:GKsession。

在该教程的第一部分,你将学到如何连接玩家的设备,让这些设备可以使用Snap通过蓝牙或Wi-Fi传递信息。玩这个游戏呢,总要有个人先建立游戏,作为游戏的"服务器",其他玩家作为"客户端",加到这个已经建好的服务器中来。

这个游戏的流程大体是下面这个样子:

image

上面这张图就是游戏的主屏幕,打开游戏玩家首先看到的画面。如何你想玩一局,你可以建立游戏,让其他玩家加进来,或者加进别人建立的游戏,还可以一个人玩单机模式。

image

这个"Host Game"画面列出了已经加进来的玩家,点击start按钮开始游戏;从这一刻起,其他没有加进来的玩家就不能在进入到该局游戏了。在玩游戏前,通常玩家都约定好了谁来建立游戏,然后其他玩家加入。

image

"Join Game"画面很像Host Game画面,唯一的差别就是这个界面诶有开始按钮,这个tableview列出了可以加入的游戏(列表里很有可能列了多个游戏)。选择一个你想加入的,然后等待主机点击他画面上的开始按钮。

image

game screen画面展示的是玩家们都围着桌子坐下,桌子上拍着各自牌面朝上和朝下的牌。点击屏幕右下角的按钮可以发出"snap!"的响声(玩游戏时,你没必要让满屋子都是那响声)。当有玩家按下"snap"按钮时,该玩家昵称旁边会出现一个气泡。

项目开始


为了节省你时间,我已经建好了一个带有图片资源和一些nib文件的项目,在这里下载源代码,用Xcode打开Snap.xcodeproj。

如果你看了源代码,你会发现,项目里只有一个view controller,MainViewController。运行项目,你会发现界面非常简单。

image

这个界面有5个UIImageView对象,组成一个logo(S,N,A,P和大王),还有3个UIButton。你可以在这些imageView上做些简单动画,让其更加生动一些,哦,不过你最好先把这些按钮弄好看些。

你下载的文件还有一个叫做Action_Man.ttf的文件。这是你将在项目中用到的字体文件,他将代替系统的标准字体Helvetica或者你iPhone的内置字体。如果你在Mac下双击这个文件,这个文件将会在字体库中被打开:

image

如果你问我,问什么要用这种字体,因为我觉得这种字体看起来更能让人兴奋。但不幸的是这种字体不能装在Mac上,也就是意味着不能在Interface Builder中使用,你必须在代码里面进行控制。然而,首先,你需要告诉UIKit有这种字体,这样app才能加载它。

在Xcode中打开Snap-Info.plist文件,添加一行,key选中"Fonts provided by application",值为数组类型。把第一项设置成那个字体文件的名字Action_Man.ttf :

image

你还需要将那个TTF字体文件加到项目中去,将文件拖拽至Supporting Files下:

image

注意,你需要确保Add to Targets这一项是选中的,要不然,字体文件是不会被包含在项目中的。

image

现在你可以想下面这样给你的button和label设置字体了:

UIFont *font = [UIFont fontWithName:@"Action Man" size:16.0f];
someLabel.font = font;

为了避免代码重复,我们创建个类别。打开File菜单,选择New->File…选项,然后选择"Objcect-C category"模板。创建一个"UIFont"类的类别"SnapAdditions":

image

这样将会创建两个文件,UIFont+SnapAdditions.hUIFont+SnapAddtions.m。为了保持项目结构整洁,我将这两个文件加进了刚创建的Categories组中。

image

UIFont+SnapAdditions.h文件中的内容替换为:

@interface UIFont (SnapAdditions)

+ (id)rw_snapFontWithSize:(CGFloat)size;

@end

将.m中的内容替换为:

#import "UIFont+SnapAdditions.h"

@implementation UIFont (SnapAdditions)

+ (id)rw_snapFontWithSize:(CGFloat)size
{
    return [UIFont fontWithName:@"Action Man" size:size];
}

@end

这只是一个简单的类别,给UIFont类加了个方法:rw_snapFontWithSize:,这个方法可以用Action Man文件创建一个UIFont对象。

注意,这个字体文件的名字时Action_Man.ttf,是带有下划线的,但是这种字体的名字是没有下划线的,所以你应该用字体的名字,而不是文件名字。要找到字体的名字,双击它,该文件将会在字体库中打开。(注意,再打开的窗口上方显示的就是字体的名字Action Man而不是Action_Man。)

第二个要注意的就是,我在这个方法前加了"rw_"。在给标准库中的类添加类别时,在方法前加前缀(或者其它的唯一标示符)是个不错的注意。做这些是为了不和苹果内置的或者将来要添加的方法有冲突。虽然看起来苹果不会去内置一个snapFontWithSize:方法,但是为了安全考虑总是没错的。

这些准备工作之后,你就可以给你Main View Controller上的button设置字体了。在MainViewController.m的最上方,导入类别文件:

#import "UIFont+SnapAdditions.h"

viewDidLoad方法中实现如下内容:

- (void)viewDidLoad
{
    [super viewDidLoad];
    self.hostGameButton.titleLabel.font = [UIFont rw_snapFontWithSize:20.0f];
    self.joinGameButton.titleLabel.font = [UIFont rw_snapFontWithSize:20.0f];
    self.singlePlayerGameButton.titleLabel.font = [UIFont rw_snapFontWithSize:20.0f];
}

现在运行项目。按钮上应该显示了新的字体:

image

如果你看到的仍然是老的字体,那么在检查一下Action_Man.ttf是否放入了项目中。选中文件,确保在Target membership这一项是选中的(在Xcode窗口右边的Inspector面板中):

image

注意:在使用任何字体的时候都要仔细阅读它的的许可证书,字体文件是受版权保护的,如果你想把它作为你项目中的一部分一起发布,往往是要收取费用的, 但幸运的是Action Man字体是可以免费使用和发布的。

现在这些按钮看起来好多了,但是一个好的按钮还要有个边框,我们用一些拉伸的图片来给按钮加上边框。这些图片已经加在项目里了,叫做Button.pngButtonPressed.png

因为这几个不同的画面都需要同样样式的按钮,所以你最好把这个自定义样式的功能放到类别中去。

给项目添加一个新的类别,叫做"SnapAdditions",但是这次类别是加在UIButton类上。这样就又创建了两个文件,UIButton+SnapAddtions.hUIButton+SnapAdditions.m,然后把这两个文件放到Categories组中,用下面的代码替换.h中的内容:

@interface UIButton (SnapAdditions)

- (void)rw_applySnapStyle;

@end

用下面代码提换.m中的内容:

#import "UIButton+SnapAdditions.h"
#import "UIFont+SnapAdditions.h"

@implementation UIButton (SnapAdditions)

- (void)rw_applySnapStyle
{
    self.titleLabel.font = [UIFont rw_snapFontWithSize:20.0f];

    UIImage *buttonImage = [[UIImage imageNamed:@"Button"] stretchableImageWithLeftCapWidth:15 topCapHeight:0];
    [self setBackgroundImage:buttonImage forState:UIControlStateNormal];

    UIImage *pressedImage = [[UIImage imageNamed:@"ButtonPressed"] stretchableImageWithLeftCapWidth:15 topCapHeight:0];
    [self setBackgroundImage:pressedImage forState:UIControlStateHighlighted];
}

@end

看看,我们又一次创建了个带有"rw_"前缀的方法。当你调用这个方法作用到按钮上时,它将会给按钮一个新的背景图片和新的字体样式。

MainViewController.m中引入新的类别文件:

#import "UIButton+SnapAdditions.h"

现在你可以用下面的代码替换viewDidLoad中的内容了:

- (void)viewDidLoad
{
    [super viewDidLoad];
    [self.hostGameButton rw_applySnapStyle];
    [self.joinGameButton rw_applySnapStyle];
    [self.singlePlayerGameButton rw_applySnapStyle];
}

再次运行项目,现在按钮是这个样子的了:

image

动画介绍


现在,你应该让主画面活跃一些。在游戏启动的时候,让logo卡片飞进来如何?

我们将下面的代码加入MainViewControllerm中来实现这个效果:

- (void)prepareForIntroAnimation
{
    self.sImageView.hidden = YES;
    self.nImageView.hidden = YES;
    self.aImageView.hidden = YES;
    self.pImageView.hidden = YES;
    self.jokerImageView.hidden = YES;
}

- (void)performIntroAnimation
{
    self.sImageView.hidden = NO;
    self.nImageView.hidden = NO;
    self.aImageView.hidden = NO;
    self.pImageView.hidden = NO;
    self.jokerImageView.hidden = NO;

    CGPoint point = CGPointMake(self.view.bounds.size.width / 2.0f, self.view.bounds.size.height * 2.0f);

    self.sImageView.center = point;
    self.nImageView.center = point;
    self.aImageView.center = point;
    self.pImageView.center = point;
    self.jokerImageView.center = point;

    [UIView animateWithDuration:0.65f
                          delay:0.5f
                        options:UIViewAnimationOptionCurveEaseOut
                     animations:^
     {
         self.sImageView.center = CGPointMake(80.0f, 108.0f);
         self.sImageView.transform = CGAffineTransformMakeRotation(-0.22f);

         self.nImageView.center = CGPointMake(160.0f, 93.0f);
         self.nImageView.transform = CGAffineTransformMakeRotation(-0.1f);

         self.aImageView.center = CGPointMake(240.0f, 88.0f);

         self.pImageView.center = CGPointMake(320.0f, 93.0f);
         self.pImageView.transform = CGAffineTransformMakeRotation(0.1f);

         self.jokerImageView.center = CGPointMake(400.0f, 108.0f);
         self.jokerImageView.transform = CGAffineTransformMakeRotation(0.22f);
     }
    completion:nil];
}

第一个方法prepareForIntroAnimation,只是简单的隐藏带有logo的卡片。实际的动画在performIntroAnimation方法中。首先,将卡片放置在屏幕以外,水平居中且在屏幕下方。然后,通过实现动画的block让卡片移动到最终的位置。这样看起来就这些卡片就像是从中间散开一样。

分别在viewWillAppear:viewDidApper:中调用这两个方法:

- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];

    [self prepareForIntroAnimation];
}

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];

    [self performIntroAnimation];
}

现在,启动游戏,这些卡片会飞进屏幕并散开,是不是很cool,哈哈。

image

仅仅这些动画还不够完美。我想,当卡片飞向目标位置时,让按钮渐渐出来,这样效果会更好。将下面方法中的几行代码放在prepareForIntroAnimation:方法的最后:

- (void)prepareForIntroAnimation
{
    . . .

    self.hostGameButton.alpha = 0.0f;
    self.joinGameButton.alpha = 0.0f;
    self.singlePlayerGameButton.alpha = 0.0f;

    _buttonsEnabled = NO;
}

上面这些代码是为了让按钮先透明。将下面的block放到performIntroAnimation:方法的最后:

- (void)performIntroAnimation 
{
        . . .

        [UIView animateWithDuration:0.5f
                              delay:1.0f
                            options:UIViewAnimationOptionCurveEaseOut
                         animations:^
         {
             self.hostGameButton.alpha = 1.0f;
             self.joinGameButton.alpha = 1.0f;
             self.singlePlayerGameButton.alpha = 1.0f;
         }
                         completion:^(BOOL finished)
         {
             _buttonsEnabled = YES;
         }];
}

这样按钮就有了从透明到完全显现的动画。_buttonsEnabled这个变量又有什么用处呢?它的用途在于,确保在按钮完全显示之后才接收点击事件,你肯定不想在按钮做透明度变化的时候让玩家去点击它。

在按钮做动画的时候,用_buttonsEnabled这个变量来忽略玩家对按钮的点击。现在,将这个变量加到@implementation中:

@implementation MainViewController
{
    BOOL _buttonsEnabled;
}

运行项目,看下动画,是不是很流畅!

GameKit和多人游戏


GameKit是iOS SDK中一个标准的framework。它主要用于Game Center(在这篇教程中不会用到)和语音聊天,但是也有在多台设备之间peer-to-peer连接的通讯的特性。如果所有的设备都在同一个Wi-Fi网络环境,GameKit还可以用Wi-Fi来代替蓝牙。(这是通过网络实现peer-to-peer的方式,而且你必须花些时间自己实现大部分代码,这种方式更擅长被用于Game Center。)

GameKit的peer-to-peer特性,对于在同一个房间,玩家们各自使用自己的设备玩游戏是非常棒的。但是据说,玩家在使用蓝牙的时候,他们之间的距离不能超过10米(或30步)。

那么,到底什么是peer-to-peer连接呢?每个参与GameKit网络会话的设备称作一个"peer"。一个设备可以作为提供服务的"server",也可以作为寻找服务器的"client",或者即作为服务器又作为客户端。GameKit是使用Bonjour技术来实现这些的,但是你并不需要直接使用Bonjour,因为使用封装好的GameKit就可以了。

当你使用蓝牙时,设备并不一定要配对,就像用蓝牙鼠标或者键盘跟你的设备配对一样。GameKit很简单地就可以实现客户端和服务器的连接,一旦连接,设备之间就可以通过本地网络发送信息了。

你不能够选择是使用蓝牙还是Wi-Fi;GameKit会为你选择的。模拟器是不支持蓝牙的,但是支持Wi-Fi。

在开发和测试这篇教程时,我发现使用模拟器和一两个真机通过本地Wi-Fi进行连接,就可以很容易地实现多人玩游戏。如果你要想通过蓝牙来玩,那就需要至少两个带有蓝牙功能的真机了。

注意:其实不用GameKit框架,使用Bonjour和蓝牙也可以实现网络通讯,但是,如果你想创建一个多人游戏,使用GameKit是非常简单的。它隐藏了很多让你非常厌恶的网络开发的东西,并且给你封装了一个简单的类GKSession来使用。这个类也是在该教程中,涉及到GameKit的唯一的类(和它的委托,GKSessionDelegate)。

如果你只想做一个通过蓝牙或者Wi-Fi来玩的两人游戏,你可以使用GameKit的GKPeerPickerController来创建设备间的连接。就像下面这个样子。

image

GKPeerPickerController的使用很简单,但是只能两台设备连接。但是Snap!需要同时四个人一起玩,所以通过这篇教程来教你通过自己写代码实现多人连接。

"Host Game"界面


在这部分,你将添加一个"Host Game"界面。这个界面允许玩家建立一个房间,让其他玩家加进来。当你完成的时候,应该是这个样子:

image

这里有个列出了连接到当局游戏的玩家的列表,一个开始按钮,还有个可以输入玩家昵称的文本框(默认里面是你机器的名字)。

添加一个UIViewController的子类,命名为HostViewController。创建时不要选中"With XIB for user interface"选项。这个界面上的基本控件已经在一个xib文件中摆放好了,你可以在"Snap/en.lproj"文件夹中找到这个xib文件。把HostViewController.xib加到项目中。这个xib文件打开后是下面这个样子:

image

这些UI控件都有跟代码里的属性和方法相关联,因此,你应该把这些控件和HostViewController类关联起来,否则,项目运行起来去加载xib时会挂掉的。

HostViewController.m里面,将下面几行加到类扩展中(在文件的最上方):

@interface HostViewController ()
@property (nonatomic, weak) IBOutlet UILabel *headingLabel;
@property (nonatomic, weak) IBOutlet UILabel *nameLabel;
@property (nonatomic, weak) IBOutlet UITextField *nameTextField;
@property (nonatomic, weak) IBOutlet UILabel *statusLabel;
@property (nonatomic, weak) IBOutlet UITableView *tableView;
@property (nonatomic, weak) IBOutlet UIButton *startButton;
@end

注意,你是将IBoutlet属性加到了.m文件中,而不是.h文件中。这是新版Xcode(4.2或者更高)中LLVM编译器的新特性。这样可以使你的.h文件更加简洁明,可以把一些不想被别的类看到的属性隐藏起来。

当然,你还需要synthesize这些属性,将下面的代码添加到@implementation下面:

@synthesize headingLabel = _headingLabel;
@synthesize nameLabel = _nameLabel;
@synthesize nameTextField = _nameTextField;
@synthesize statusLabel = _statusLabel;
@synthesize tableView = _tableView;
@synthesize startButton = _startButton;

提示:据说在下个版本的Xcode(或许正是你在看这篇教程的时候),你就不需要写@synthesize这行代码了,但是如果你用的是Xcode4.3,仍然需要放进去。

用下面的代码替换shouldAutorotateToInterfaceOrientation:这个方法,限制设备只支持横屏:

- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation
{
    return UIInterfaceOrientationIsLandscape(interfaceOrientation);
}

然后,将下面的几个还未用到的方法放在文件的下面:

- (IBAction)startAction:(id)sender
{
}

- (IBAction)exitAction:(id)sender
{
}

#pragma mark - UITableViewDataSource

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return 0;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return nil;
}

最后,用下面这行代码替换HostViewController.h文件中的@interface这行:

@interface HostViewController : UIViewController <UITableViewDataSource, UITableViewDelegate, UITextFieldDelegate>

Host Game界面的基本工作已经做完,但是当用户点击主界面上的按钮时,还需要触发动作显示Host Game界面。将下面的代码添加到MainViewController.h中:

#import "HostViewController.h"

并且像下面这样实现MainViewContoller.m文件中的HostGameAction:方法:

- (IBAction)hostGameAction:(id)sender
{
    if (_buttonsEnabled)
    {
        HostViewController *controller = [[HostViewController alloc] initWithNibName:@"HostViewController" bundle:nil];

        [self presentViewController:controller animated:NO completion:nil];
    }
}

如果你现在启动app,点击Host Game按钮,屏幕上将会呈现Host Game界面,但是这还不够炫。你用modal的方式来显示这个新的viewController,但是没有把animated这个参数设置为YES,所以也就没有从下往上滑动的效果。

因为像这样的动画用在这里并不是太好,所以我们并没有使用让Host Game界面从主界面向上滑动出现的效果,我们在MainViewController.m中创建了一个新的方法,用来生成我们要使用的新动画:

- (void)performExitAnimationWithCompletionBlock:(void (^)(BOOL))block
{
    _buttonsEnabled = NO;

    [UIView animateWithDuration:0.3f
        delay:0.0f
        options:UIViewAnimationOptionCurveEaseOut
        animations:^
        {
            self.sImageView.center = self.aImageView.center;
            self.sImageView.transform = self.aImageView.transform;

            self.nImageView.center = self.aImageView.center;
            self.nImageView.transform = self.aImageView.transform;

            self.pImageView.center = self.aImageView.center;
            self.pImageView.transform = self.aImageView.transform;

            self.jokerImageView.center = self.aImageView.center;
            self.jokerImageView.transform = self.aImageView.transform;
        }
        completion:^(BOOL finished)
        {
            CGPoint point = CGPointMake(self.aImageView.center.x, self.view.frame.size.height * -2.0f);

            [UIView animateWithDuration:1.0f
                delay:0.0f
                options:UIViewAnimationOptionCurveEaseOut
                animations:^
                {
                    self.sImageView.center = point;
                    self.nImageView.center = point;
                    self.aImageView.center = point;
                    self.pImageView.center = point;
                    self.jokerImageView.center = point;
                }
                completion:block];

            [UIView animateWithDuration:0.3f
            delay:0.3f
                options:UIViewAnimationOptionCurveEaseOut
                animations:^
                {
                    self.hostGameButton.alpha = 0.0f;
                    self.joinGameButton.alpha = 0.0f;
                    self.singlePlayerGameButton.alpha = 0.0f;
                }
                completion:nil];
        }];
}

提示:你不必在意这个方法放在哪个位置(只要在@implementation和@end之间)。之前你必须在.h文件或者.m扩展中声明,或者将该方法放在使用的代码前面。现在不需要了,这一切都要感谢Xcode4.3中的LLVM编译器。不管你把代码放在哪里,甚至你在使用之前没有任何声明,这个编译器都可以很聪明地找到需要的方法。

performExitAnimationWithCompletionBlock:中的动画:logo卡片从屏幕外飞进来,同时,界面上的按钮渐渐出现。当动画结束之后,会执行作为参数传进来的block。

现在将hostGameAction:方法改为如下:

- (IBAction)hostGameAction:(id)sender
{
    if (_buttonsEnabled)
    {
        [self performExitAnimationWithCompletionBlock:^(BOOL finished)
        {   
            HostViewController *controller = [[HostViewController alloc] initWithNibName:@"HostViewController" bundle:nil];

            [self presentViewController:controller animated:NO completion:nil];
        }];
    }
}

代码跟以前差不多,但是现在的逻辑是:将创建和呈现Host Game界面的代码放在了一个动画执行之后会调用的block中。运行项目看看效果吧。你把动画的代码放在了一个单独的方法中,当这样用户点击其它按钮时,也可以用这个方法来做动画。

正如你所看到的,Host Game界面使用的还是默认的Helvetica字体,并且它的开始按钮也没有边框。其实很容易就可以改好。将下面两个头文件导入到HostViewController.m文件中:

#import "UIButton+SnapAdditions.h"
#import "UIFont+SnapAdditions.h"

然后用下面的代码替换viewDidLoad方法:

- (void)viewDidLoad
{
    [super viewDidLoad];

    self.headingLabel.font = [UIFont rw_snapFontWithSize:24.0f];;
    self.nameLabel.font = [UIFont rw_snapFontWithSize:16.0f];
    self.statusLabel.font = [UIFont rw_snapFontWithSize:16.0f];
    self.nameTextField.font = [UIFont rw_snapFontWithSize:20.0f];

    [self.startButton rw_applySnapStyle];
}

因为你创建了这些类别,所以可以很方便地添加字体和按钮样式,真是不费吹灰之力就让界面如此漂亮,哈哈(原句: it’s a snap (ha ha) to make the screen look good)。运行项目看看效果吧。

现在还有点东西要改进。这里有个让用户输入名字的文本框,当用户点击时,在界面的最上方会弹出个键盘。这个键盘盖住了半个屏幕,关键是现在还没有办法让它下去。

image

第一种让键盘消失的方法:就是用那个又大又蓝带有"Done"(中文状态下应该是"完成")的按钮。现在你点击之后是什么都不会发生的,不过将下面的方法加入HostViewController.m中就能解决轻松这个问题:

#pragma mark - UITextFieldDelegate

- (BOOL)textFieldShouldReturn:(UITextField *)textField
{
    [textField resignFirstResponder];
    return NO;
}

第二种让键盘消失的方法:就是viewDidLoad:方法的最后加入如下代码:

- (void)viewDidLoad
{
    . . .

    UITapGestureRecognizer *gestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self.nameTextField action:@selector(resignFirstResponder)];
    gestureRecognizer.cancelsTouchesInView = NO;
    [self.view addGestureRecognizer:gestureRecognizer];
}

你在主界面创建了个点击手势。现在,当你点击除文本框以外的地方的时候,这个手势操作会给文本框发送"resignFirstResponder"消息,这个消息可以使键盘消失。

注意,你需要将cancelsTouchesInView属性设置为NO,否则界面上的其它控件都不会有任何事件响应了,比如列表,按钮。:

退出Host Screen界面


除了开始游戏按钮,其它的还都没有事件。现在我们要做的就是点击Host Screen界面左下角的x按钮,让屏幕重新回到主界面。

这个按钮是跟exitAction:方法绑定的,现在里面还是空的。点击它应该能够关闭这个界面,你将用delegate来实现这些。在这个项目中有几个viewController, 你将用delegate来控制它们的切换。

将下面的代码添加到HostViewController.h,放在@interface这行之前:

@class HostViewController;

@protocol HostViewControllerDelegate <NSObject>

- (void)hostViewControllerDidCancel:(HostViewController *)controller;

@end

在@interface里加入这个新的属性:

@property (nonatomic, weak) id <HostViewControllerDelegate> delegate;

属性需要synthesize,因此将下面的代码放在HostViewController.m中:

@synthesize delegate = _delegate;

最后,用下面的方法替换exitAction:方法:

- (IBAction)exitAction:(id)sender
{
    [self.delegate hostViewControllerDidCancel:self];
}

想法很明确了:在HostViewController中声明一个delegate protocol。当用户点击了x按钮时,HostViewController会告诉delegate,Host Game界面已经不需要了。然后delegate会履行自己的职责,关掉界面。

在这里,MainViewController扮演着delegate的角色,当然,我们还需要在MainViewController.h中添加<HostViewControllerDelegate>

@interface MainViewController : UIViewController <HostViewControllerDelegate>

MainViewController.m中将hostGameAction:方法改为:

- (IBAction)hostGameAction:(id)sender
{
    if (_buttonsEnabled)
    {
        [self performExitAnimationWithCompletionBlock:^(BOOL finished)
        {   
            HostViewController *controller = [[HostViewController alloc] initWithNibName:@"HostViewController" bundle:nil];
            controller.delegate = self;

            [self presentViewController:controller animated:NO completion:nil];
        }];
    }
}

现在,你已经将MainViewController设置为HostViewController的delegate。最后,在MainViewController.m文件中实现delegate方法:

#pragma mark - HostViewControllerDelegate

- (void)hostViewControllerDidCancel:(HostViewController *)controller
{
    [self dismissViewControllerAnimated:NO completion:nil];
}

没有使用动画,简单地关掉了HostViewController界面。但是由于MainViewController的viewWillAppear方法被再次调用,卡片飞进来的动画就被执行了一次。运行项目试试看。

注意:在调试时,当一个界面消失的时候,我想确保viewController被销毁,所以我在viewController中加入dealloc方法,再次方法中输出信息到控制台。

- (void)dealloc
{
    #ifdef DEBUG
    NSLog(@"dealloc %@", self);
    #endif
}

即使你在项目中使用了ARC,项目仍然有可能有内存泄露的地方。虽然ARC是个非常好的内存管理工具,但是它无法解决循环引用的问题。比如你有两个对象,都有个强类型指针指向对方,这样它们将永远留在内存中。这就是我为什么在dealloc中输出log,确保对象对象被销毁的原因,只是为了擦亮自己的眼睛,把事情搞得更清楚。

现在,Host Game界面已经完成。在你建立想加入或者建立一局之前,你必须先进入游戏界面。否则,其它设备找不到你的设备!

"Join Game"界面


这个界面跟Host Game界面很相似,但是鉴于它们那些不同之处,我们足以有理由去创建一个新的类(不是继承hostViewController)。由于跟之前做的很类似,所以你可以很快地完成这些。

创建一个UIViewController的子类JoinViewController。创建时不要带有xib,因为我已经在你开始用的代码里提供了,将这个xib文件(在"Snap/en.lproj/"这里)添加到项目中。

用这些代替JoinViewController.h文件的内容:

@class JoinViewController;

@protocol JoinViewControllerDelegate <NSObject>

- (void)joinViewControllerDidCancel:(JoinViewController *)controller;

@end

@interface JoinViewController : UIViewController <UITableViewDataSource, UITableViewDelegate, UITextFieldDelegate>

@property (nonatomic, weak) id <JoinViewControllerDelegate> delegate;

@end

就像当初对Host Game界面所做的那样,用下面的代码替换JoinViewController.m文件的内容:

#import "JoinViewController.h"
#import "UIFont+SnapAdditions.h"

@interface JoinViewController ()
@property (nonatomic, weak) IBOutlet UILabel *headingLabel;
@property (nonatomic, weak) IBOutlet UILabel *nameLabel;
@property (nonatomic, weak) IBOutlet UITextField *nameTextField;
@property (nonatomic, weak) IBOutlet UILabel *statusLabel;
@property (nonatomic, weak) IBOutlet UITableView *tableView;

@property (nonatomic, strong) IBOutlet UIView *waitView;
@property (nonatomic, weak) IBOutlet UILabel *waitLabel;
@end

@implementation JoinViewController

@synthesize delegate = _delegate;

@synthesize headingLabel = _headingLabel;
@synthesize nameLabel = _nameLabel;
@synthesize nameTextField = _nameTextField;
@synthesize statusLabel = _statusLabel;
@synthesize tableView = _tableView;

@synthesize waitView = _waitView;
@synthesize waitLabel = _waitLabel;

- (void)dealloc
{
    #ifdef DEBUG
    NSLog(@"dealloc %@", self);
    #endif
}

- (void)viewDidLoad
{
    [super viewDidLoad];

    self.headingLabel.font = [UIFont rw_snapFontWithSize:24.0f];
    self.nameLabel.font = [UIFont rw_snapFontWithSize:16.0f];
    self.statusLabel.font = [UIFont rw_snapFontWithSize:16.0f];
    self.waitLabel.font = [UIFont rw_snapFontWithSize:18.0f];
    self.nameTextField.font = [UIFont rw_snapFontWithSize:20.0f];

    UITapGestureRecognizer *gestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self.nameTextField action:@selector(resignFirstResponder)];
    gestureRecognizer.cancelsTouchesInView = NO;
    [self.view addGestureRecognizer:gestureRecognizer];
}

- (void)viewDidUnload
{
    [super viewDidUnload];
    self.waitView = nil;
}

- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation
{
    return UIInterfaceOrientationIsLandscape(interfaceOrientation);
}

- (IBAction)exitAction:(id)sender
{
    [self.delegate joinViewControllerDidCancel:self];
}

#pragma mark - UITableViewDataSource

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return 0;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return nil;
}

#pragma mark - UITextFieldDelegate

- (BOOL)textFieldShouldReturn:(UITextField *)textField
{
    [textField resignFirstResponder];
    return NO;
}

@end

除了waitView的绑定之外,这里跟之前没什么特别的。注意,这个属性是被声明为"strong",而不是像其它属性一样"weak",因为它在这个xib文件中属于top-level视图:

image

将这个属性声明为strong是及其重要的,这样可以避免被销毁。你没必要对第一个视图做这样的操作,因为viewController内置的self.view属性已经retain了。

当用户点击了列表中别人建立的游戏房间名时,我么在主页面上方放置了另一个视图(就是画面上显示"Connecting..."的视图)。这本来可以用一个新的viewController来做,但是为了简单,我们先这样做了。

修改MainViewController.n文件:

#import "HostViewController.h"
#import "JoinViewController.h"

@interface MainViewController : UIViewController <HostViewControllerDelegate, JoinViewControllerDelegate>

@end

MainViewController.m中,用下面的方法替换JoinGameAction:

- (IBAction)joinGameAction:(id)sender
{
    if (_buttonsEnabled)
    {
        [self performExitAnimationWithCompletionBlock:^(BOOL finished)
        {
            JoinViewController *controller = [[JoinViewController alloc] initWithNibName:@"JoinViewController" bundle:nil];
            controller.delegate = self;

            [self presentViewController:controller animated:NO completion:nil];
        }];
    }
}

并且实现如下delegate方法:

#pragma mark - JoinViewControllerDelegate

- (void)joinViewControllerDidCancel:(JoinViewController *)controller
{
    [self dismissViewControllerAnimated:NO completion:nil];
}

现在我们已经完成了Join Game界面。是时候添加配对逻辑了。

注意:当你写多人游戏(或者其它基于网络通讯的软件)时,都有两种架构选择:client-server和peer-to-peer(这里的意思是点对点方式)。尽管我们可以使用GameKit的"peer-to-peer"方式,但是这次我们选择client-server方式。一个玩家作为服务器,其它玩家加进来。

image

在client-server这种模式下,server控制着所有事情并且决定卡片是否配对。client发送玩家的更新到server,server通知所有client更新,client之间并不进行直接数据交流。然而在真正让点对点模式下,所有的client都是平等的,做相同的工作,但是你一定要确保所有的玩家看到同样的事情,因为这种方式没有server来控制。 
再次说一次,在这篇教程中使用的是client-server模式,这种模式需要一个玩家来作为服务器。

牌型配对


现在你的Host Game和Join Game两个界面基本上都能用了,现在你可以添加卡片配对逻辑了。当一个玩家点击Host Game界面上的按钮时,他的设备会被别的玩家在Snap中搜索到。当其他玩家进入Join Game界面时,能够看到很多server。

虽然GameKit的GKSession类为这些做了很多,但是你仍然需要做很多工作。我们创建了两个类MatchmakingServer和MatchmakingCient来处理游戏的逻辑,而不是把所有的逻辑都放在viewController里。viewController承受东西太多的话,代码看起来会非常的凌乱。这就是我为什么还要创建两个新对象来管理设备间通讯的原因。

在创建这些新的类之前,你应该先把GameKit框架添加进项目中。在Target Summary界面,Linked Frameworks and Libraries里,点击+按钮,在弹出的列表中选择GameKit.framework加到项目中。

image

因为我们要在很多文件中用到GameKit这个框架,所以我们没有像平常那样在各个源代码文件上方导入GameKit,而是在预编译头文件中导入GameKit框架,也就是Snap-Prefix.pch文件(在Supporting Files中)中的#ifdef __OBJC__部分加入下面这行代码:

#import <GameKit/GameKit.h>

现在所有的文件中都可以用GameKit框架了。

你还有一件事要做,那就是在Info.plist文件中表示这个项目用了peer-to-peer功能,因为并不是所有的设备(尤其是第一代的iPhone和iPod Touch)都支持peer-to-peer功能。

打开Snap-Info.plist文件并在"Required device capabilities"下加入一个子项,并设置其值为"peer-peer":

image

MatchmakingServer


创建一个新的NSObject的子类,命名为MatchmakingServer。我建议把它放进一个新的组"Networking"里。用下面的内容替换MatchmakingServer.h

@interface MatchmakingServer : NSObject <GKSessionDelegate>

@property (nonatomic, assign) int maxClients;
@property (nonatomic, strong, readonly) NSArray *connectedClients;
@property (nonatomic, strong, readonly) GKSession *session;

- (void)startAcceptingConnectionsForSessionID:(NSString *)sessionID;

@end

MatchmakingServer有一个连接其它client的列表,并且要有一个变量来控制同一时间连接进来的client数量。就是该游戏每局有最多4个人玩家的限制,也就是只能有3个玩家连接进来(算上自己正好是4个)。

MatchmakingServer还有个GKSession对象,来控制各个设备之间的网络通讯,MatchmakingServer还要遵守GKSessionDelegate协议,因为这样GKSession可以告诉它一些重要的事件。

现在,MatchmakingServer还只有一个方法,用来进行广播服务和接收client的消息。很快,你就会往这个类里加很多东西。

用下面的代码替换MatchmakingServer.m文件中的内容:

#import "MatchmakingServer.h"

@implementation MatchmakingServer
{
    NSMutableArray *_connectedClients;
}

@synthesize maxClients = _maxClients;
@synthesize session = _session;

- (void)startAcceptingConnectionsForSessionID:(NSString *)sessionID
{
    _connectedClients = [NSMutableArray arrayWithCapacity:self.maxClients];

    _session = [[GKSession alloc] initWithSessionID:sessionID displayName:nil sessionMode:GKSessionModeServer];
    _session.delegate = self;
    _session.available = YES;
}

- (NSArray *)connectedClients
{
    return _connectedClients;
}

#pragma mark - GKSessionDelegate

- (void)session:(GKSession *)session peer:(NSString *)peerID didChangeState:(GKPeerConnectionState)state
{
    #ifdef DEBUG
    NSLog(@"MatchmakingServer: peer %@ changed state %d", peerID, state);
    #endif
}

- (void)session:(GKSession *)session didReceiveConnectionRequestFromPeer:(NSString *)peerID
{
    #ifdef DEBUG
    NSLog(@"MatchmakingServer: connection request from peer %@", peerID);
    #endif
}

- (void)session:(GKSession *)session connectionWithPeerFailed:(NSString *)peerID withError:(NSError *)error
{
    #ifdef DEBUG
    NSLog(@"MatchmakingServer: connection with peer %@ failed %@", peerID, error);
    #endif
}

- (void)session:(GKSession *)session didFailWithError:(NSError *)error
{
    #ifdef DEBUG
    NSLog(@"MatchmakingServer: session failed %@", error);
    #endif
}

@end

这基本上是公式一样的玩意儿,GKSessionDelegate方法除了在Xcode Debug面板上打出了一些log之外,没有做任何事情。不过在startAcceptingConnectionsForSessionID方法中倒是有些新鲜的东西:

_session = [[GKSession alloc] initWithSessionID:sessionID displayName:nil sessionMode:GKSessionModeServer];
_session.delegate = self;
_session.available = YES;

你在这里创建了GKSessin对象,并且设置好delegate在这里管理。说详细些就是只对有效的service(以sessionID参数命名的)进行广播服务,不会关心其它任何广播同样消息的设备。你告诉session,MatchmakingServer是它的delegate,然后设置了"availabel"属性的值为YES,这样就可以开启广播了。这就是你让GameKit session所做的事情。

现在你要把MatchMakingServer导入到HostViewController.h文件中:

#import "MatchmakingServer.h"

添加一个MatchmakingServer对象作为HostViewController的一个实例变量,如下:

@implementation HostViewController
{
    MatchmakingServer *_matchmakingServer;
}

添加如下方法到HostViewController.m文件中:

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];

    if (_matchmakingServer == nil)
    {
        _matchmakingServer = [[MatchmakingServer alloc] init];
        _matchmakingServer.maxClients = 3;
        [_matchmakingServer startAcceptingConnectionsForSessionID:SESSION_ID];

        self.nameTextField.placeholder = _matchmakingServer.session.displayName;
        [self.tableView reloadData];
    }
}

一旦Host Game界面出现,就会创建一个MatchmakingServer对象,并且告诉它开始接受连接。同时它会把你机器的名字作为placeholder填入"Your Name"文本框中,如果你不输入你的名字,那么就用这个来作为你在游戏中的标示。

在你定义SESSION_ID之前,新的代码是不会起作用的。不用太关心SESSION_ID的内容是什么,只要server和client保持同样地值就可以了。GameKit将用这个值作为唯一Bonjour标示。因为MatchmakingServer和MatchmakingClient都会用到这个SESSION_ID,所以最好还是把它定义在prefix文件中吧。打开Snap-Prefix.pch并且把下面这行代码放在文件的最后:

// The name of the GameKit session.
#define SESSION_ID @"Snap!"

运行项目,点击Host Game界面上的按钮。如果你是运行在模拟器中,你将会看到下面这个界面:

image

GKSession中displayName属性的值是像这样"com.hollance.Sanp355561232..."的一串字符串,如果你在你自己的设备上运行,上面显示的将是你设备的名字,比如:"Joe's iPhone"或者你一开始给你设备设置的名字。

你现在已经有一个运行良好,可以广播"Snap!"服务的server了,但是还没有client连接进来。现在我们就创建一个MatchmakingClient类来实现这些。

MatchingmakingClient


添加一个新的类MatchmakingClient,继承NSObject,并把它放到Networking组。用下面的代码替换MatchmakingClient.h文件的内容:

@interface MatchmakingClient : NSObject <GKSessionDelegate>

@property (nonatomic, strong, readonly) NSArray *availableServers;
@property (nonatomic, strong, readonly) GKSession *session;

- (void)startSearchingForServersWithSessionID:(NSString *)sessionID;

@end

就像是从MatchmakingServer这面镜子映出来的一样,但是这个列表里不是client,而是server。用下面的代码替换MatchmakingClient.m文件的内容:

#import "MatchmakingClient.h"

@implementation MatchmakingClient
{
    NSMutableArray *_availableServers;
}

@synthesize session = _session;

- (void)startSearchingForServersWithSessionID:(NSString *)sessionID
{
    _availableServers = [NSMutableArray arrayWithCapacity:10];

    _session = [[GKSession alloc] initWithSessionID:sessionID displayName:nil sessionMode:GKSessionModeClient];
    _session.delegate = self;
    _session.available = YES;
}

- (NSArray *)availableServers
{
    return _availableServers;
}

#pragma mark - GKSessionDelegate

- (void)session:(GKSession *)session peer:(NSString *)peerID didChangeState:(GKPeerConnectionState)state
{
    #ifdef DEBUG
    NSLog(@"MatchmakingClient: peer %@ changed state %d", peerID, state);
    #endif
}

- (void)session:(GKSession *)session didReceiveConnectionRequestFromPeer:(NSString *)peerID
{
    #ifdef DEBUG
    NSLog(@"MatchmakingClient: connection request from peer %@", peerID);
    #endif
}

- (void)session:(GKSession *)session connectionWithPeerFailed:(NSString *)peerID withError:(NSError *)error
{
    #ifdef DEBUG
    NSLog(@"MatchmakingClient: connection with peer %@ failed %@", peerID, error);
    #endif
}

- (void)session:(GKSession *)session didFailWithError:(NSError *)error
{
    #ifdef DEBUG
    NSLog(@"MatchmakingClient: session failed %@", error);
    #endif
}

@end

又一次,我们给类搭了个框架,然后去填好这些方法。注意,你创建了个GKSessionModeClient模式的GKSession对象,因此它会寻找有效的server(不是自己广播的服务)。

现在将新创建的类整合到JoinViewController中。这样server和client才能连接。首先导入头文件到JoinViewController.h中:

#import "MatchmakingClient.h"

然后添加一个实例变量到JoinViewController.m中:

@implementation JoinViewController
{
    MatchmakingClient *_matchmakingClient;
}

用下面的代码实现viewDidAppear:方法:

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];

    if (_matchmakingClient == nil)
    {
        _matchmakingClient = [[MatchmakingClient alloc] init];
        [_matchmakingClient startSearchingForServersWithSessionID:SESSION_ID];

        self.nameTextField.placeholder = _matchmakingClient.session.displayName;
        [self.tableView reloadData];
    }
}

这时候,你可以进行测试了。确保有两台以上带有蓝牙功能的设备,或者用本地Wi-Fi网络,用你的模拟器和真机连接。一个设备点击进入Host Game界面,另一个点击设备进入Join Game界面。

界面上什么都没有发生,但是debug窗口输出了很多log:

server输出内容:

Snap[3810:707] BTM: attaching to BTServer
Snap[3810:707] BTM: posting notification BluetoothAvailabilityChangedNotification
Snap[3810:707] BTM: received BT_LOCAL_DEVICE_CONNECTABILITY_CHANGED event
Snap[3810:707] BTM: posting notification BluetoothConnectabilityChangedNotification

这些都是GameKit发出的消息。client也可以从GameKit发出消息,但是client输出的是:

Snap[94530:1bb03] MatchmakingClient: peer 663723729 changed state 0

这个消息来自GKSessionDelegate的session:peer:didChangeState:方法,在你的MatchmakingClient类里面。他告诉我们ID为"663723729"的小伙伴已经准备好了,也就是说,client侦测到了有效的server。

注意:peer ID是GameKit生成用来在一次会话中区别不同设备的标示。每次启动游戏,这个ID都会变的。很快你就会用到这些peer ID。

如果你有多台设备,你就可以创建多个server。但对于这篇教程,一个client只需要连接一个server就可以了,但是他们可以相互侦测到对方。试试吧!

到了这里,该何去何从?


到现在为止,所有的范例代码都在这里

恭喜,你现在有了一个漂亮的画面,app中的按钮动画也很流畅,Host Game和Join Game界面也都基本实现。另外,也可以用GameKit和Bonjour来广播和侦测server了!

非常棒,但是很明显,你要在屏幕上显示搜索到的server,这样用户才能选择你的server加入游戏。这就是该系列教程在第二部分内容。

如何你对于本篇文件有任何问题或者评论,请在下面加入我们的讨论!

posted on 2013-09-20 14:09  一梦浮生2012  阅读(1657)  评论(0编辑  收藏  举报