使用UIKit制作卡牌游戏(三)ios游戏篇
译者: Lao Jiang | 原文作者: Matthijs Hollemans写于2012/07/13
转自朋友Tommy 的翻译,自己只翻译了这第三篇教程。
这篇文章为iOS教程团队成员 Matthijs Hollemans,他是一位经验丰富iOS开发者和设计者。你可以在 Google+和 Twitter找到他。
欢迎回到我们的Monster(怪物)系列教程的第七部分--创建一个多人纸牌游戏,通过蓝牙或Wi-Fi使用UIKit!
如果你刚接触这个系列教程,先点击介绍.在那里,你可以看到一个游戏的视频,我们会邀请您为我们的特殊的挑战者!
在系列教程第一篇,你创建了主菜单和主机的基础内容,并添加了游戏场景。
在系列教程第二篇,你实现了连接/主机的游戏逻辑,优雅的完成断线处理。
在系列教程的第三篇,我们将要实现客户端和服务器相互通信的功能。此外,我们将创建我们的游戏的 model 类,这个游戏的设置代码,或更多。让我们回到游戏创建中。
游戏入门
当玩家点击游戏界面上的“开始按钮”承载回话时,这个游戏开始。然后这个服务器会向客户端发送消息--这些数据包通过蓝牙或WiFi网络传输-指导客户端做好准备。
这些网络数据包被“data receive handler”(数据接收处理)接收。你必须给GKSession一个处理任何传入的包的对象。这有点像一个delegate,但是它不具有自己的@protocol(协议)。
小诀窍: 在比赛之前,这个服务器需要发送给客户端一堆信息,但你并不真的想要让JoinViewController继承GKSession方法处理数据接收。游戏的主要逻辑将被"Data Model"文件夹下的Game类处理,你想让这个Game类继承GKSession处理数据包的协议。因此,在客户端上,只要客户端连接到服务器,游戏就开始。(如果对你来说没有任何意义,它会很快显现)。
添加以下方法到MatchmakingClient类的delegate protocol中:
- (void)matchmakingClient:(MatchmakingClient *)client didConnectToServer:(NSString *)peerID;
只要客户端连接到服务器,你会调用此方法。添加如下代码到MatchmakingClient.m类中的- (void)session:(GKSession )session peer:(NSString )peerID didChangeState:(GKPeerConnectionState)state{}中
// We're now connected to the server.
case GKPeerStateConnected:
if (_clientState == ClientStateConnecting)
{
_clientState = ClientStateConnected;
[self.delegate matchmakingClient:self didConnectToServer:peerID];
}
break;
该delegate(委托)方法应该由JoinViewController实施,所以它添加:
- (void)matchmakingClient:(MatchmakingClient *)client didConnectToServer:(NSString *)peerID
{
NSString *name = [self.nameTextField.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if ([name length] == 0)
name = _matchmakingClient.session.displayName;
[self.delegate joinViewController:self startGameWithSession:_matchmakingClient.session playerName:name server:peerID];
}
这里你第一次从文本框中(剔出任何空格)得到玩家名称。然后你调用一个新的委托方法让MainViewController知道它不得不为这个客户端开始游戏。
添加新的委托方法声明 到JoinViewControllerDelegate
- (void)joinViewController:(JoinViewController *)controller startGameWithSession:(GKSession *)session playerName:(NSString *)name server:(NSString *)peerID;
请注意,你已经为MainViewController添加了3个重要的数据块,GKSession :新建游戏类与服务器通信时使用的;玩家名字;服务器的peerID(你可以从GKSession对象中获取这个服务器的peer ID,这个很容易)。
这个方法在 MainViewController.m中主要实现如下:
- (void)joinViewController:(JoinViewController *)controller startGameWithSession:(GKSession *)session playerName:(NSString *)name server:(NSString *)peerID
{
_performAnimations = NO;
[self dismissViewControllerAnimated:NO completion:^
{
_performAnimations = YES;
[self startGameWithBlock:^(Game *game)
{
[game startClientGameWithSession:session playerName:name server:peerID];
}];
}];
}
这些是什么意思?让我们从这个 _performAnimations 变量讲起。这是一个新的实例变量,需要将其添加到源文件的顶部(MainViewController.h):
@implementation MainViewController
{
. . .
BOOL _performAnimations;
}
记得主屏幕上的很酷的动画效果吗?logo卡牌飞入屏幕和按钮淡入效果。那个动画在主屏幕上任何时间可见出现,包括一个modally-presented(模态呈现)view controller(视图控制器)关闭。
不过,当开始一个新的游戏时,你不想在主场景做任何动画。你要立即从Join Game screen.切换到实际游戏画面,所有动作发生。这个_performAnimations变量简单控制着卡片飞动动画是否发生。
设置_performAnimations默认值为YES,在MainViewController.m文件中的initWithNibName设置:
- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil
{
if ((self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]))
{
_performAnimations = YES;
}
return self;
}
在viewWillAppear: 和 viewDidAppear: 方法中添加如下判断:
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
if (_performAnimations)
[self prepareForIntroAnimation];
}
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
if (_performAnimations)
[self performIntroAnimation];
}
返回解释先前添加的代码。当startClientGameWithSession…方法被调用时候,你通过设置performAnimations为NO来禁止这个动画。然后你离开这个Join Game screen.,重设置performAnimations为YES,并做下面操作:
[self startGameWithBlock:^(Game *game)
{
[game startClientGameWithSession:session playerName:name server:peerID];
}];
这可能需要更多的解释。你要做一个新的游戏类,作为本场比赛的数据模型(data model),和一个GameViewController类来管理游戏画面。
开始一个新游戏有三种方式:连服务器,一对一,或者在单人游戏模式下。这三种类型的游戏的主要设置是一样的,除了这个游戏对象如何初始化上面。startGameWithBlock: 处理着所有的共享细节,并且你传给这个block的东西是特定类型的游戏。
在这个例子中,因为你是客户端,你在这个游戏对象上调用startClientGameWithSession:playerName:server:让它开始。但是在此之前,你必须先写一些新的游戏对象和GameViewController。
在startGameWithBlock:中加入
- (void)startGameWithBlock:(void (^)(Game *))block
{
GameViewController *gameViewController = [[GameViewController alloc] initWithNibName:@"GameViewController" bundle:nil];
gameViewController.delegate = self;
[self presentViewController:gameViewController animated:NO completion:^
{
Game *game = [[Game alloc] init];
gameViewController.game = game;
game.delegate = gameViewController;
block(game);
}];
}
还未完成,但是你可以看到什么是应该做的:分配GameViewController,实现它,让后分配游戏对象。最后调用你的block做游戏类型特定的初始化。
虽然这些文件不存在,先在MainViewController.h中导入它们。
#import "GameViewController.h"
在MainViewController.m:中导入:
#import "Game.h"
这之后,你要添加这个仍然是虚拟的 GameViewControllerDelegate到MainViewController’s @interface:
@interface MainViewController : UIViewController <HostViewControllerDelegate, JoinViewControllerDelegate, GameViewControllerDelegate>
现在添加一个新的Objective-C类到项目中,并继承UIViewController,命名为GameViewController。没有Nib是必要的-它已经被第一部分的下载启动代码提供。从“Snap/en.lproj/” 文件夹下拖动 GameViewController.xib 到这个项目中中。用下面的内容替换GameViewController.h下的:
#import "Game.h"
@class GameViewController;
@protocol GameViewControllerDelegate <NSObject>
- (void)gameViewController:(GameViewController *)controller didQuitWithReason:(QuitReason)reason;
@end
@interface GameViewController : UIViewController <UIAlertViewDelegate, GameDelegate>
@property (nonatomic, weak) id <GameViewControllerDelegate> delegate;
@property (nonatomic, strong) Game *game;
@end
非常简单。你声明了一个新的委托协议GameViewControllerDelegate,其中只有一个方法用来让MainViewController知道游戏应该结束。这个GameViewController自身是这个委托的游戏对象。许多委托随处可见。
第一版本的 GameViewController.xib,你用起来非常简单。它在屏幕中只有一个exit按钮和一个单独的Label。
替代如下内容到 GameViewController.m中:
#import "GameViewController.h"
#import "UIFont+SnapAdditions.h"
@interface GameViewController ()
@property (nonatomic, weak) IBOutlet UILabel *centerLabel;
@end
@implementation GameViewController
@synthesize delegate = _delegate;
@synthesize game = _game;
@synthesize centerLabel = _centerLabel;
- (void)dealloc
{
#ifdef DEBUG
NSLog(@"dealloc %@", self);
#endif
}
- (void)viewDidLoad
{
[super viewDidLoad];
self.centerLabel.font = [UIFont rw_snapFontWithSize:18.0f];
}
- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation
{
return UIInterfaceOrientationIsLandscape(interfaceOrientation);
}
#pragma mark - Actions
- (IBAction)exitAction:(id)sender
{
[self.game quitGameWithReason:QuitReasonUserQuit];
}
#pragma mark - GameDelegate
- (void)game:(Game *)game didQuitWithReason:(QuitReason)reason
{
[self.delegate gameViewController:self didQuitWithReason:reason];
}
@end
这里没有什么太令人兴奋的。exitAction:告诉退出游戏对象,和游戏对象通过调用game:didQuitWithReason:而响应。MainViewController为GameViewController的委托,所以你应该实现gameViewController:didQuitGameWithReason::
pragma mark - GameViewControllerDelegate
- (void)gameViewController:(GameViewController *)controller didQuitWithReason:(QuitReason)reason
{
[self dismissViewControllerAnimated:NO completion:^
{
if (reason == QuitReasonConnectionDropped)
{
[self showDisconnectedAlert];
}
}];
}
这看起来也非常熟悉。你关闭游戏画面,如果有必要显示一个警告弹出框。
这些措施,只是让游戏对象被实施。
Game 类
正如我之前提到的,这个Game类是游戏中主要数据模型对象。它也处理从GKSession传入的网络数据包。你要为这个类建立一个基本的版本来让这个游戏开始。纵观本系列的其余部分,你会扩大Game类,和GameViewController直到Snap全面完成。
添加到一个新的Objective-C类到这个项目中,继承NSObject,命名为Game。我建议你把Game.h和Game.m文件移动到一个新建组叫做“Data Model”.替换Game.h文件内容如下:
@class Game;
@protocol GameDelegate <NSObject>
- (void)game:(Game *)game didQuitWithReason:(QuitReason)reason;
@end
@interface Game : NSObject <GKSessionDelegate>
@property (nonatomic, weak) id <GameDelegate> delegate;
@property (nonatomic, assign) BOOL isServer;
- (void)startClientGameWithSession:(GKSession *)session playerName:(NSString *)name server:(NSString *)peerID;
- (void)quitGameWithReason:(QuitReason)reason;
@end
这里你声明GameDelegate protocol,你已经在GameViewController中看到这个角色,和Game类。迄今为止,你只为这个类添加了startClientGameWithSession… 方法和一个委托和isServer属性。
替换Game.m内容如下:
#import "Game.h"
typedef enum
{
GameStateWaitingForSignIn,
GameStateWaitingForReady,
GameStateDealing,
GameStatePlaying,
GameStateGameOver,
GameStateQuitting,
}
GameState;
@implementation Game
{
GameState _state;
GKSession *_session;
NSString *_serverPeerID;
NSString *_localPlayerName;
}
@synthesize delegate = _delegate;
@synthesize isServer = _isServer;
- (void)dealloc
{
#ifdef DEBUG
NSLog(@"dealloc %@", self);
#endif
}
#pragma mark - Game Logic
- (void)startClientGameWithSession:(GKSession *)session playerName:(NSString *)name server:(NSString *)peerID
{
}
- (void)quitGameWithReason:(QuitReason)reason
{
}
#pragma mark - GKSessionDelegate
- (void)session:(GKSession *)session peer:(NSString *)peerID didChangeState:(GKPeerConnectionState)state
{
#ifdef DEBUG
NSLog(@"Game: peer %@ changed state %d", peerID, state);
#endif
}
- (void)session:(GKSession *)session didReceiveConnectionRequestFromPeer:(NSString *)peerID
{
#ifdef DEBUG
NSLog(@"Game: connection request from peer %@", peerID);
#endif
[session denyConnectionFromPeer:peerID];
}
- (void)session:(GKSession *)session connectionWithPeerFailed:(NSString *)peerID withError:(NSError *)error
{
#ifdef DEBUG
NSLog(@"Game: connection with peer %@ failed %@", peerID, error);
#endif
// Not used.
}
- (void)session:(GKSession *)session didFailWithError:(NSError *)error
{
#ifdef DEBUG
NSLog(@"Game: session failed %@", error);
#endif
}
#pragma mark - GKSession Data Receive Handler
- (void)receiveData:(NSData *)data fromPeer:(NSString *)peerID inSession:(GKSession *)session context:(void *)context
{
#ifdef DEBUG
NSLog(@"Game: receive data from peer: %@, data: %@, length: %d", peerID, data, [data length]);
#endif
}
@end
这是最低限度的,你需要做的就是重新编译。Game类中没有哪个方法是做任何有用的操作。还注意到你声明了新的枚举(enum),GameState,它包含游戏所占据的不同状态。稍后更多关于游戏的状态。
再次运行该应用。当你客户端连接到服务器时,你将简要见到“Connecting…”信息(只要建立与服务器的连接,这通常只有几分之一秒),然后应用程序切换到游戏画面。你不会注意到太大的区别,因为布局保持大致相同的(目的),除了主要的label现在说“Center Label,”,并且它是白色的替代了绿色。
还要注意,客户端从服务器的表视图中消失。这是因为在客户端上,关闭了JoinViewController,也就释放了MatchmakingClient对象。这个对象是唯一一个保留GKSession对象的,所以才会被释放,以及只要你离开Join Game screen.连接立即打破。(你可以验证服务器的调试输出;客户端的状态变为3,这个是GKPeerStateDisconnected)。
你会解决这个问题。
替换Game.m’s startClientGameWithSession… 方法如下:
- (void)startClientGameWithSession:(GKSession *)session playerName:(NSString *)name server:(NSString *)peerID
{
self.isServer = NO;
_session = session;
_session.available = NO;
_session.delegate = self;
[_session setDataReceiveHandler:self withContext:nil];
_serverPeerID = peerID;
_localPlayerName = name;
_state = GameStateWaitingForSignIn;
[self.delegate gameWaitingForServerReady:self];
}
这个方法控制GKSession 对象,并使“self”赋予delegate,即游戏对象,称为新的GKSessionDelegate以及数据接收处理程序。(你已经添加了这些委托的方法,但它们现在还是空的)。
你可以复制服务器的 peer ID和玩家的名字到你自己的实例变量中,然后这只游戏状态为“waiting for sign-in.” 这意味着客户端现在将等待服务器的特定讯息。最后,你告诉这个GameDelegate你已经准备好游戏开始。这采用了一种新的委托方法,所以添加到Game.h, GameDelegate @protocol:
- (void)gameWaitingForServerReady:(Game *)game;
Game的委托是 GameViewController,所以你应该实现这个方法:
- (void)gameWaitingForServerReady:(Game *)game
{
self.centerLabel.text = NSLocalizedString(@"Waiting for game to start...", @"Status text: waiting for server");
}
这是它所做的一起。它取代了中间label文字“Waiting for game to start…”。再次运行这个应用程序。将客户端连接后,你应该看到这一点:
由于你现在保持这个GKSession对象 在Join Game screen 关闭后还活着,服务器的表视图中仍然显示客户端的设备名称。
在这一点上还未为客户做太多东西,但你至少制作exit button 。在Game.m中替换如下方法:
- (void)quitGameWithReason:(QuitReason)reason
{
_state = GameStateQuitting;
[_session disconnectFromAllPeers];
_session.delegate = nil;
_session = nil;
[self.delegate game:self didQuitWithReason:reason];
}
尽管Game在服务器上还没有真正开始,你仍然可以退出。在服务器上看来就像是一个断开,它会从其表视图中删除客户。
你已经完成在GameViewController里的game:didQuitWithReason: 方法,该方法可以导致应用程序返回到主屏幕。因为你把一些NSLogging放到dealloc 方法中,你可以在Xcode的debug输出窗格中看看一切都被正确释放。测试吧!
在服务器上启动游戏
在服务器上开始游戏,跟你刚才做的没有什么太大的不同。从主机游戏画面点击Start button。对此你应该创建一个游戏对象,启动GameViewController。
HostViewController有一个startAction: 方法与 Start button绑定.眼下此方法不起作用。将其替换为:
- (IBAction)startAction:(id)sender
{
if (_matchmakingServer != nil && [_matchmakingServer connectedClientCount] > 0)
{
NSString *name = [self.nameTextField.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if ([name length] == 0)
name = _matchmakingServer.session.displayName;
[_matchmakingServer stopAcceptingConnections];
[self.delegate hostViewController:self startGameWithSession:_matchmakingServer.session playerName:name clients:_matchmakingServer.connectedClients];
}
}
这个Start button只会在一个有效MatchmakingServer的对象下工作(这是通常情况下,除非没有 Wi-Fi或Bluetooth),并至少有一个连接的客户端-自己玩没有乐趣!当这些条件都满足时,你可从 text field中得到玩家的名字,告诉MatchmakingServer不要接收任何新客户,并告诉委托 (MainViewController) ,它应该启动一个服务器游戏。
stopAcceptingConnections是新的,所以把它添加到MatchmakingServer.h:
- (void)stopAcceptingConnections;
添加到 MatchmakingServer.m:
- (void)stopAcceptingConnections
{
NSAssert(_serverState == ServerStateAcceptingConnections, @"Wrong state");
_serverState = ServerStateIgnoringNewConnections;
_session.available = NO;
}
这不像endSession,这不推倒GKSession对象。它只是移动MatchmakingServer到“ignoring new connections” 状态,所以当 GKPeerStateConnected 或 GKPeerStateDisconnected发生回调时 它不再接收新的连接。设置GKSession的可用属性为NO也意味着服务器的存在不再广播。
startAction: 也被称为一个新的委托方法,所以加其签名到HostViewController.h:
- (void)hostViewController:(HostViewController *)controller startGameWithSession:(GKSession *)session playerName:(NSString *)name clients:(NSArray *)clients;
并在MainViewController.m文件中实现此方法:
- (void)hostViewController:(HostViewController *)controller startGameWithSession:(GKSession *)session playerName:(NSString *)name clients:(NSArray *)clients
{
_performAnimations = NO;
[self dismissViewControllerAnimated:NO completion:^
{
_performAnimations = YES;
[self startGameWithBlock:^(Game *game)
{
[game startServerGameWithSession:session playerName:name clients:clients];
}];
}];
}
这与你为客户做的什么非常相似的,除非你在Game 类中调用了一个新的方法。添加此方法到Game.h:
- (void)startServerGameWithSession:(GKSession *)session playerName:(NSString *)name clients:(NSArray *)clients;
也添加到Game.m:
- (void)startServerGameWithSession:(GKSession *)session playerName:(NSString *)name clients:(NSArray *)clients
{
self.isServer = YES;
_session = session;
_session.available = NO;
_session.delegate = self;
[_session setDataReceiveHandler:self withContext:nil];
_state = GameStateWaitingForSignIn;
[self.delegate gameWaitingForClientsReady:self];
}
你做的客户端游戏有很多同样地事情,除了你设置isServer 属性为YES,并你调用另一个GameDelegate方法。添加这个方法到Game.h @protocol 中:
- (void)gameWaitingForClientsReady:(Game *)game;
在GameViewController.m中实施:
- (void)gameWaitingForClientsReady:(Game *)game
{
self.centerLabel.text = NSLocalizedString(@"Waiting for other players...", @"Status text: waiting for clients");
}
这就可以了! 现在,当你在主机上按下启动按钮,HostViewController得到谨慎解雇,和GameViewController出现:
现在你的应用程序可以连接一个服务器(主机)到过个客户端。但是这些设备只是坐等待主机开始游戏的客户端。玩家没有什么可以做的,除了点击退出按钮。因为你已经实现了exitAction:,并且客户端和服务器分享在 Game 和 GameViewController大部分的代码,在服务端点击退出按钮应该结束游戏。
注意:当你退出服务器时,客户端可能需要几秒钟才认识到服务器已断开。它也将继续停留在“Waiting for game to start…” 屏幕,因为你还没有在Game类中实施任何断线逻辑。 Disconnection logic(断线的逻辑)被用来处理 MatchmakingServer 和 MatchmakingClient 类,但现在你已经开始游戏,这些对象已经达到目的,并不再使用。这个Game对象已经接管了GKSessionDelegate的职责。
Data模型
这是个很好的时机来谈论这个游戏的数据模型。因为你使用的是UIKit(而不是Cocos2D 或 OpenGL),游戏中使用Model-View-Controller (MVC) 模式结构是有道理的。
cocos2d游戏的一种常见方法是引入该类别的CCSprite的子类,并把你的游戏对象逻辑放到这个类中。在这里你做的事情有点不同:你会把model, view, and view controller严格分离。
注意:它可能不会使所有的游戏使用MVC而变得有意义,但它适合为卡牌和棋牌游戏。你可以在model类中捕捉游戏规则,从任何表示逻辑中分离。这样做的好处,是让你可以轻松地单元测试这些游戏规则,以确保它们始终是正确的,虽然你在本教程中会跳过。
Game是data model(数据模型)的一部分。它处理游戏规则,已经在客户机之间的网络流量和服务器(它既是GKSession的委托又是数据接收处理者)。但是Game并不是唯一的数据模型对象。这里还有其它几个:
你见过Game和GameViewController类,但是其他类都是新的,你将在本教程的过程中把它们添加到项目中。参加游戏的玩家被Player对象表示。每位玩家都有成堆的卡,都是从Deck绘制出的。这些都是模型对象。
卡牌被CardView对象绘制在屏幕上。所有其它的视图:UILabels ,UIButtons ,UIImageViews。对于网络通信,Game使用GKSession来发送和接收数据包对象,它代表在不同的设备之间的网络发送的一个消息。
首先创建这个Player对象。在项目中添加一个新的Objective-C 类,继承自NSObject,命名为Player。由于这是一个数据模型类,把它添加到数据模型组内。替换 Player.h内容如下:
typedef enum
{
PlayerPositionBottom, // the user
PlayerPositionLeft,
PlayerPositionTop,
PlayerPositionRight
}
PlayerPosition;
@interface Player : NSObject
@property (nonatomic, assign) PlayerPosition position;
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *peerID;
@end
在Player.m中添加:
#import "Player.h"
@implementation Player
@synthesize position = _position;
@synthesize name = _name;
@synthesize peerID = _peerID;
- (void)dealloc
{
#ifdef DEBUG
NSLog(@"dealloc %@", self);
#endif
}
- (NSString *)description
{
return [NSString stringWithFormat:@"%@ peerID = %@, name = %@, position = %d", [super description], self.peerID, self.name, self.position];
}
@end
你现在保持它简单。玩家的三个不同方式的属性可以识别每位玩家。 1. 通过它们的名字。这是你要展现在用户面前的,但它不能保证是唯一的。这个名字是玩家在Host Game 或Join Game screens时输入的名字(如果他们没有输入任何名字,你会在这里使用设备名称)。
- 通过peer ID。这是GKSession内部使用,它是当你需要通过网络向它们发送消息时用来确定玩家的。
- 通过它们在屏幕上的“position”。这很有趣。每位玩家将看到自己在屏幕的底部,所以玩家的位置是相对的。正如你从typedef看到的,你看是在底部位置,然后到顺时针方向(左,上,右)。当你需要做的事情在保证的顺序下,你将使用位置,比如处理cards.
这是不同的玩家如何看自己和其他玩家坐在桌子周围的位置:
登录
游戏对象现在已经进入其初始状态,GameStateWaitingForSignIn,同时在客户端和服务器。在“waiting for sign-in”状态时,服务器将发送消息给所有的客户端,要求他们回应它们本地玩家的名字。
到目前为止,服务器只知道哪些客户端连接和它们的peer IDs和设备名称,但它不知道任何关于用户输入Your Name” 输入框的名称。一旦服务器知道了每个人的名字,它可以告诉所有的客户端的其他玩家。
添加Player 到Game.h:
#import "Player.h"
在Game.m:中添加新的实例变量
@implementation Game
{
. . .
NSMutableDictionary *_players;
}
你将把Player对象装到一个字典对象上。为了使它容易地通过peer IDs看到玩家,你就会使用 peer ID 关键字。字典需要马上分配,所以添加一个init方法到Game类中。
- (id)init
{
if ((self = [super init]))
{
_players = [NSMutableDictionary dictionaryWithCapacity:4];
}
return self;
}
在服务器上启动游戏的方法是 startServerGameWithSession:playerName:clients:,你已经在这里做了一些东西设置游戏。添加如下代码到该方法底部:
- (void)startServerGameWithSession:(GKSession *)session playerName:(NSString *)name clients:(NSArray *)clients
{
. . .
// Create the Player object for the server.
Player *player = [[Player alloc] init];
player.name = name;
player.peerID = _session.peerID;
player.position = PlayerPositionBottom;
[_players setObject:player forKey:player.peerID];
// Add a Player object for each client.
int index = 0;
for (NSString *peerID in clients)
{
Player *player = [[Player alloc] init];
player.peerID = peerID;
[_players setObject:player forKey:player.peerID];
if (index == 0)
player.position = ([clients count] == 1) ? PlayerPositionTop : PlayerPositionLeft;
else if (index == 1)
player.position = PlayerPositionTop;
else
player.position = PlayerPositionRight;
index++;
}
}
首先,你为服务器创建Player对象,并将其放置在屏幕位置的底部。然后你遍历所有连接的客户端peer IDs 数组,和为它们创建Player对象。你以顺时针方向的顺序指定客户端玩家的位置,这取决于总共有多少玩家。请注意,你不为这些Plaer对象设置“name”属性,因为在这一点上,你不知道客户的名字。
设备之间发送消息
现在,每个客户端都拥有一个Plaer对象,你可以发送“sign-in”请求到每个客户端。每个客户端将与它们的名字异步响应。收到这样的响应,你会看到那个客户端的Player对象,并根据客户端返回给你的内容设置它的“name”属性的名称。
GKSession有个方法叫做sendDataToAllPeers:withDataMode:error:将会发送NSData对象的内容到所有连接的peers(同龄人)。你可以使用这种方法从服务器来发送一个单一的消息到所有客户端。在这种情况下,该消息是一个NSData对象,并且NSData对象里面是什么完全取决于你。在Snap中!,所有的消息格式如下:
一个数据包至少是10个字节。这10个字节被称为 “header,”,和任何(可选)字节,可以按照 “payload.” (有效负荷)。不同类型的包具有不同的payloads,但是它们都具有相同的header 结构:
- header的头四个字节来自单词SNAP。这是一个所谓的magic number (0x534E4150 十六进制),你用来验证这些包确定是你的。
- 其次四个字节神奇的数字(真的是一个32位整数),当数据包到达顺序用于识别(后面会详细讲解)。
- header的最后两个字节表示数据包类型。你将有多种类型的消息再客户端和服务器之间来回发送,而这16位整数告诉你它是什么样的包。
对于某些类型的数据包,也有可能是更多的header(the payload)后面的数据。客户端返回给服务器 “sign-in response” 包,例如,包含玩家名称的一个 UTF-8 字符串。
这一切都好,但你想要在一个更好的界面下抽象这种底层东西。你要去创建一个Packet类,可以在场景后面处理bits-and-bytes 。在项目中添加一个新的Objective-C 类,继承自NSObject,命名为Packet。为了保持整洁,放置在“Networking” 组别中。
在 Packet.h 中替换如下内容:
typedef enum
{
PacketTypeSignInRequest = 0x64, // server to client
PacketTypeSignInResponse, // client to server
PacketTypeServerReady, // server to client
PacketTypeClientReady, // client to server
PacketTypeDealCards, // server to client
PacketTypeClientDealtCards, // client to server
PacketTypeActivatePlayer, // server to client
PacketTypeClientTurnedCard, // client to server
PacketTypePlayerShouldSnap, // client to server
PacketTypePlayerCalledSnap, // server to client
PacketTypeOtherClientQuit, // server to client
PacketTypeServerQuit, // server to client
PacketTypeClientQuit, // client to server
}
PacketType;
@interface Packet : NSObject
@property (nonatomic, assign) PacketType packetType;
+ (id)packetWithType:(PacketType)packetType;
- (id)initWithType:(PacketType)packetType;
- (NSData *)data;
@end
上面枚举类型包含了一个所有你发送和接收的不同信息类型的列表。在这一点,这个Packet类本身非常简单:它有一个方便的构造函数和设置数据包类型的init方法。这个"data"方法返回一个有关这封邮件内容消息的新NSData对象。这个NSData对象是你通过GKSession发送到其他设备的。
在Packet.m 中替换内容如下:
#import "Packet.h"
#import "NSData+SnapAdditions.h"
@implementation Packet
@synthesize packetType = _packetType;
+ (id)packetWithType:(PacketType)packetType
{
return [[[self class] alloc] initWithType:packetType];
}
- (id)initWithType:(PacketType)packetType
{
if ((self = [super init]))
{
self.packetType = packetType;
}
return self;
}
- (NSData *)data
{
NSMutableData *data = [[NSMutableData alloc] initWithCapacity:100];
[data rw_appendInt32:'SNAP']; // 0x534E4150
[data rw_appendInt32:0];
[data rw_appendInt16:self.packetType];
return data;
}
- (NSString *)description
{
return [NSString stringWithFormat:@"%@, type=%d", [super description], self.packetType];
}
@end
这里有趣的是这个data方法。它分配一个NSMutableData对象,让后把它里面放置两个32位整数和一个16位整数。这是我前面提到的10个字节的header。第一部分是单词“SNAP,”,第二部分是数据包数———时间你将保持为0,第三部分是数据包类型。
从这些“rw_appendIntXX” 方法名称来看,你已经可以推断它们来自一个类别。添加一个新的Objective-C类别文件到项目中。命名这个类别为“SnapAdditions”并使其在NSData (不是NSMutableData!)。
你欺骗了一下,在这里,实际上是讲类在NSMutableData。因为你稍后需要一个类似NSData类别,你把它们都放到同一个源文件下。在NSData+SnapAdditions.h 中替换内容如下:
@interface NSData (SnapAdditions)
@end
@interface NSMutableData (SnapAdditions)
- (void)rw_appendInt32:(int)value;
- (void)rw_appendInt16:(short)value;
- (void)rw_appendInt8:(char)value;
- (void)rw_appendString:(NSString *)string;
@end
你现在留下了空的NSData类别,并增加了第二类NSMutableData、正如你所见,有添加大小不同整数的方法,以及添加一个NSString的方法。在NSData+SnapAdditions.m替换内容如下:
#import "NSData+SnapAdditions.h"
@implementation NSData (SnapAdditions)
@end
@implementation NSMutableData (SnapAdditions)
- (void)rw_appendInt32:(int)value
{
value = htonl(value);
[self appendBytes:&value length:4];
}
- (void)rw_appendInt16:(short)value
{
value = htons(value);
[self appendBytes:&value length:2];
}
- (void)rw_appendInt8:(char)value
{
[self appendBytes:&value length:1];
}
- (void)rw_appendString:(NSString *)string
{
const char *cString = [string UTF8String];
[self appendBytes:cString length:strlen(cString) + 1];
}
@end
这些方法都非常相似,但仔细看看 rw_appendInt32::
- (void)rw_appendInt32:(int)value
{
value = htonl(value);
[self appendBytes:&value length:4];
}
在最后一行,你调用 [self appendBytes:length:] 以添加“value” 变量的内存长度,4个字节,NSMutableData对象。但在这之前,你使“value”调用htonl() 函数。这样做是确保整型值总是以“network byte order,”表示,这恰好是大端。然而,处理器将运行这个程序, x86 和 ARM CPUs,使用小尾数法。
你可以发送 “value” 变量的内存内容,但谁知道,一个新型号iPhone可能在未来使用不同的字节顺序,然后一个设备发送和另一个接收可能有不兼容的结构。
出于这个原因,它总是一个好主意,当处理数据传输时决定一个特定的字节顺序,网络编程应该是大端。如果在发送之前,你只是简单的为32位整数调用htonl() 函数,和为16位整数调用htons() 函数,那么你应该始终ok.
另外注意的是rw_appendString:,首先转换NSString为UTF-8,然后把它添加到NSMutableData对象上,包括在结束时一个NUL字节以纪念结束的字符串。
返回到Game类,在startServerGameWithSession…方法底部添加以下几行:
- (void)startServerGameWithSession:(GKSession *)session playerName:(NSString *)name clients:(NSArray *)clients
{
. . .
Packet *packet = [Packet packetWithType:PacketTypeSignInRequest];
[self sendPacketToAllClients:packet];
}
当然,这还不能编译。首先添加所需的导入:
#import "Packet.h"
然后添加这个 sendPacketToAllClients: 方法:
#pragma mark - Networking
- (void)sendPacketToAllClients:(Packet *)packet
{
GKSendDataMode dataMode = GKSendDataReliable;
NSData *data = [packet data];
NSError *error;
if (![_session sendDataToAllPeers:data withDataMode:dataMode error:&error])
{
NSLog(@"Error sending data to clients: %@", error);
}
}
太好了,现在你可以再次运行程序,并在服务器和客户端之间发送一些消息。主持一个新的游戏,加入一个或多个客户短,并观看调试输出窗格中。在客户端,它应该显示这些:
Game: receive data from peer: 1995171355, data: <534e4150 00000000 0064>, length: 10
这个输出来自GKSession 的data-receive-handler(数据接收处理程序)方法receiveData:fromPeer:inSession:context:。你还未在这个方法中做任何事情,但至少它会记录它收到来自服务器的消息。实际的数据,是你用GKSession发送到服务器上的NSData对象,是这样的:
534e4150 00000000 0064
这是10个字节长,但是以十六进制记数法表示。如果你的进制记数法有点生疏,下载 Hex Fiend或类似的十六进制编辑器,简单地复制粘贴以上内容:
这表明数据包确实以字SNAP开始(Big Endian字节序,或0x534E4150),其后由四个0字节(你还没有使用的数据包数),然后是16位的数据包类型。对于可读性的原因,我给了第一个数据包类型,PacketTypeSignInRequest,值为0×64 (你可以在Packet.h文件下看到),所以它在十六进制数据下很容易发现。
酷,所以你组织发送一条信息给客户端。现在客户端有回应。
返回到服务器发送响应
我几次提到 GKSession的 data-receive-handler (数据接收处理程序)方法。该方法的参数之一是一个新的NSData对象,因为它是从发送方收到的二进制消息内容。这种方法中你将NSData对象返过来到一个数据包种,看数据包什么类型,并决定用它做什么。
在Game.m中,替换如下方法:
- (void)receiveData:(NSData *)data fromPeer:(NSString *)peerID inSession:(GKSession *)session context:(void *)context
{
#ifdef DEBUG
NSLog(@"Game: receive data from peer: %@, data: %@, length: %d", peerID, data, [data length]);
#endif
Packet *packet = [Packet packetWithData:data];
if (packet == nil)
{
NSLog(@"Invalid packet: %@", data);
return;
}
[self clientReceivedPacket:packet];
}
你还是传入数据记录,然后调用一个新的便捷构造Packet-packetWithData:- 转变NSData到一个新的Packet对象。 这并不是总起作用。例如,如果传入数据不是以 ‘SNAP’ header开头的,然后你打印一个错误。但是,如果你有一个有效的数据包对象,你传递给另一个新的方法,clientReceivedPacket:。
首先为Packet类添加方便的构造函数。添加新的方法到Packet.h中:
+ (id)packetWithData:(NSData *)data;
对于 Packet.m,首先在@implementation下面一行添加如下:
+ (id)packetWithData:(NSData *)data
{
if ([data length] < PACKET_HEADER_SIZE)
{
NSLog(@"Error: Packet too small");
return nil;
}
if ([data rw_int32AtOffset:0] != 'SNAP')
{
NSLog(@"Error: Packet has invalid header");
return nil;
}
int packetNumber = [data rw_int32AtOffset:4];
PacketType packetType = [data rw_int16AtOffset:8];
return [Packet packetWithType:packetType];
}
首先,你验证的数据至少是10个字节。如果它是任何较小的,就有错误的。大多数的时候,你会在 “reliable” 模式下发送包,这意味着它们保证完全相同的内容正如你发送的,所以你不必担心在传输过程中任何位“falling over”(翻倒)。
但是无论如何,做一些合理性检查是很好的,作为防御性编程的一种形式。毕竟有人说,一些“流氓”的客户端不会发送不同的信息以欺骗我们?出于同样的原因,你检查第一个32位整数真正的代表字SNAP。
注意:这个词 ‘SNAP’ 可能看起来像字符串,但它不是。它也不是一个单一的字符,而是被称为四字符代码,或 简称“fourcc”。很多网络协议和文件格式都使用这样的32位代码,以表明自己的格式。为了好玩,在 Hex Fiend中打开一些随机的文件。你将会看到它们经常以一个fourcc开始。
Xcode不知道有关这些新的rw_intXXAtOffset:方法,所以将它们添加到NSData 类别。首先NSData+SnapAdditions.h:
@interface NSData (SnapAdditions)
- (int)rw_int32AtOffset:(size_t)offset;
- (short)rw_int16AtOffset:(size_t)offset;
- (char)rw_int8AtOffset:(size_t)offset;
- (NSString *)rw_stringAtOffset:(size_t)offset bytesRead:(size_t *)amount;
@end
这时候你要添加方法到NSData,而不是NSMutableData。毕竟GKSession的data-receive-handler(数据接收处理程序)只接收一个不变的NSData对象。把方法本身放到NSData+SnapAdditions.m:
@implementation NSData (SnapAdditions)
- (int)rw_int32AtOffset:(size_t)offset
{
const int *intBytes = (const int *)[self bytes];
return ntohl(intBytes[offset / 4]);
}
- (short)rw_int16AtOffset:(size_t)offset
{
const short *shortBytes = (const short *)[self bytes];
return ntohs(shortBytes[offset / 2]);
}
- (char)rw_int8AtOffset:(size_t)offset
{
const char *charBytes = (const char *)[self bytes];
return charBytes[offset];
}
- (NSString *)rw_stringAtOffset:(size_t)offset bytesRead:(size_t *)amount
{
const char *charBytes = (const char *)[self bytes];
NSString *string = [NSString stringWithUTF8String:charBytes + offset];
*amount = strlen(charBytes + offset) + 1;
return string;
}
@end
不像NSMutableData中的“append”方法一样每次你调用它们时更新一个写指针,这些读方法不自动更新读指针,这将是最方便的解决方案,但类别不能在类中添加新数据成员。
相反,你需要通过你想读的字节偏移量。请注意,这些方法假设数据是网络字节顺序byte-order(大端),因此使用 ntohl() 和ntohs() 函数来将它们转换回主机字节顺序。
rw_stringAtOffset:bytesRead: 特别值得一提,因为它返回参数中是一个可读的字节数。整型的方法,你已经知道你会读多少个字节,但这个数字可以是任何字符串。( bytesRead参数包含读取的字节数,包括终止字符串NUL字节 )。
剩下在Game.m中实施 clientReceivedPacket:
- (void)clientReceivedPacket:(Packet *)packet
{
switch (packet.packetType)
{
case PacketTypeSignInRequest:
if (_state == GameStateWaitingForSignIn)
{
_state = GameStateWaitingForReady;
Packet *packet = [PacketSignInResponse packetWithPlayerName:_localPlayerName];
[self sendPacketToServer:packet];
}
break;
default:
NSLog(@"Client received unexpected packet: %@", packet);
break;
}
}
你只需看看数据包类型,然后决定如何处理它。对于PacketTypeSignInRequest-我们目前拥有的唯一类型-你改变了游戏状态为“waiting for ready,”,然后发送一个“sign-in response”包到服务器。
这将使用一个新的类,PacketSignInResponse,而不是仅仅分组。sign-in response 将包含额外的数据超出标准的10字节header,对于这个项目,你做这样的packets继承自Packet。
但是在此之前,首先添加endPacketToServer: 方法(在sendPacketToAllClients:下):
- (void)sendPacketToServer:(Packet *)packet
{
GKSendDataMode dataMode = GKSendDataReliable;
NSData *data = [packet data];
NSError *error;
if (![_session sendData:data toPeers:[NSArray arrayWithObject:_serverPeerID] withDataMode:dataMode error:&error])
{
NSLog(@"Error sending data to server: %@", error);
}
}
它看起来跟 sendPacketToAllClients:非常相似,除非你没有数据包发送到所有连接的peers(同龄人),但只是由一个_serverPeerID标识。
这样做的原因是,在场景后面,, Game Kit用来连接每一个peer到其他每一个peer,因此,如果有两个客户端和一个服务器,客户端不仅连接到服务器,还彼此连接。对于这个游戏,你不希望客户端发送消息给对方,只发给服务器。(你可以从GKSession的session:peer:didChangeState: callback中看到输出“peer XXX changed state 2″)
现在你需要新的PacketSignInResponse类。在项目中添加新的 Objective-C类,继承自Packet,命名为:PacketSignInResponse。在新的.h文件中替换如下内容:
#import "Packet.h"
@interface PacketSignInResponse : Packet
@property (nonatomic, copy) NSString *playerName;
+ (id)packetWithPlayerName:(NSString *)playerName;
@end
除了Packet超类的常规数据,这个特殊的数据包还包含一个忘记的名字。替换.m文件如下:
#import "PacketSignInResponse.h"
#import "NSData+SnapAdditions.h"
@implementation PacketSignInResponse
@synthesize playerName = _playerName;
+ (id)packetWithPlayerName:(NSString *)playerName
{
return [[[self class] alloc] initWithPlayerName:playerName];
}
- (id)initWithPlayerName:(NSString *)playerName
{
if ((self = [super initWithType:PacketTypeSignInResponse]))
{
self.playerName = playerName;
}
return self;
}
- (void)addPayloadToData:(NSMutableData *)data
{
[data rw_appendString:self.playerName];
}
@end
这应该是相当简单,但可能除了addPayloadToData:。这是一个新的方法使Packet的子类可以重写自己的数据到NSMutableData对象中。这里你只需追加playerName属性的字符串内容到这个数据对象。为了使其起作用,你必须从父类调用此方法。
添加一个空的addPayloadToData: 方法到Packet.m:
- (void)addPayloadToData:(NSMutableData *)data
{
// base class does nothing
}
并从 “data” 方法中调用它:
- (NSData *)data
{
NSMutableData *data = [[NSMutableData alloc] initWithCapacity:100];
[data rw_appendInt32:'SNAP']; // 0x534E4150
[data rw_appendInt32:0];
[data rw_appendInt16:self.packetType];
[self addPayloadToData:data];
return data;
}
默认情况下,addPayloadToData:不会做任何事情,但是子类可以用它把自己的附件数据加到消息中。
还有一件事是需要做的是重新编译一切,这是一个在 Game.m中的新的Packet子类:
#import "PacketSignInResponse.h"
编译器仍然警报说:“the value stored in the local packetNumber variable is never read ”(in Packet’s packetWithData:))。一会ok的,你会很快做与该变量有关的东西。
现在编译和运行应用程序,无论是在客户端还是服务器,并开始新的游戏。跟以前一样,客户端应该受到 “sign-in”包(类型代码0×64),但作为响应,它现在应该想服务器发送一个数据包,包含它的玩家名字。服务器的调试输出如下:
Game: receive data from peer: 1100677320, data: <534e4150 00000000 00656372 617a7920 6a6f6500>, length: 20
粘贴数据(所有在<>之间的)到Hex Fiend中弄清楚什么被传回到服务器中:
这个包类型现在从0×65替换成0×64(PacketTypeSignInResponse 替换 PacketTypeSignInRequest),高亮选中的位显示的是这个特殊玩家的名字(“crazy joe”)包括NUL字节终止字符串的名称。
注意:保持限制你的Game Kit传输大小是个好主意。苹果建议1000字节或更少,虽然似乎上限是87千字节左右。
如果你发送小于1000个字节,所有的数据可以放入一个单一的TCP/ IP数据包,这将保证交互快捷。大数据将被接收者拆分和重组。Game Kit会为你处理这些,它仍然是相当快的。但为了获得最佳性能,保持低于1000字节。
处理服务器的响应
你可能已经注意到,在服务器的调试输出不仅表明数据被接收,它也这样说:
Client received unexpected packet: <Packet: 0x9294ba0>
这就是似乎不可思议,考虑到你在服务器上,而不是客户端。但服务器和客户端共享了很多代码也不是太奇怪,GKSession data-receive-handler(数据接收处理程序)也是相同的两个。所以你必须在仅用于传入服务器的消息与仅用于客户端消息之间做出区分。
改变Game.m文件下的data-receive-handler 方法:
- (void)receiveData:(NSData *)data fromPeer:(NSString *)peerID inSession:(GKSession *)session context:(void *)context
{
#ifdef DEBUG
NSLog(@"Game: receive data from peer: %@, data: %@, length: %d", peerID, data, [data length]);
#endif
Packet *packet = [Packet packetWithData:data];
if (packet == nil)
{
NSLog(@"Invalid packet: %@", data);
return;
}
Player *player = [self playerWithPeerID:peerID];
if (self.isServer)
[self serverReceivedPacket:packet fromPlayer:player];
else
[self clientReceivedPacket:packet];
}
现在你做一个基于 isServer属性的区分。对于服务器,重要的是要知道数据包来自哪个玩家,所以你看到Player对象使用新的playerWithPeerID:方法基于 发送者 peer ID 。添加此方法到Game.m:
- (Player *)playerWithPeerID:(NSString *)peerID
{
return [_players objectForKey:peerID];
}
非常简单,但是值得单独放到一个方法中,因为你将在其它地方多次使用。serverReceivedPacket:fromPlayer:也是新方法:
- (void)serverReceivedPacket:(Packet *)packet fromPlayer:(Player *)player
{
switch (packet.packetType)
{
case PacketTypeSignInResponse:
if (_state == GameStateWaitingForSignIn)
{
player.name = ((PacketSignInResponse *)packet).playerName;
NSLog(@"server received sign in from client '%@'", player.name);
}
break;
default:
NSLog(@"Server received unexpected packet: %@", packet);
break;
}
}
在服务器上运行应用程序,看负担得起多少客户端。当游戏开始时,服务器应该输出:
server received sign in from client 'Crazy Joe'
server received sign in from client 'Weird Al'
...and so on...
除非它不工作并以一个 “unrecognized selector sent to instance” 错误!崩溃
在上面的代码中,你投Packet到一个PacketSignInResponse对象,因为那是客户端发给你的内容,不是吗?嗯,这不是真滴。这个客户端只发送了一堆GKSession投入NSData对象的字节,你用packetWithData:方法把它放到一个Packet对象中。
你必须让Packet聪明的创建,并当packet类型为PacketTypeSignInResponse(即十六进制0×65)时返回一个PacketSignInResponse对象。在Packet.m中,更改packetWithData: 方法为:
+ (id)packetWithData:(NSData *)data
{
if ([data length] < PACKET_HEADER_SIZE)
{
NSLog(@"Error: Packet too small");
return nil;
}
if ([data rw_int32AtOffset:0] != 'SNAP')
{
NSLog(@"Error: Packet has invalid header");
return nil;
}
int packetNumber = [data rw_int32AtOffset:4];
PacketType packetType = [data rw_int16AtOffset:8];
Packet *packet;
switch (packetType)
{
case PacketTypeSignInRequest:
packet = [Packet packetWithType:packetType];
break;
case PacketTypeSignInResponse:
packet = [PacketSignInResponse packetWithData:data];
break;
default:
NSLog(@"Error: Packet has invalid type");
return nil;
}
return packet;
}
每一次你添加支持一个新的packet类型,你还需要添加一个case语句到这个方法中。不要忘了在Packet.m中导入:
#import "PacketSignInResponse.h"
请注意“sign-in response” packet,你在PacketSignInResponse 上调用packetWithData:方法而不是常规的包自身代用。我们的想法是在子类中覆盖packetWithData的来读取,特定数据包类型的数据 - 例如,玩家的名字。添加一些方法到PacketSignInResponse.m:中
+ (id)packetWithData:(NSData *)data
{
size_t count;
NSString *playerName = [data rw_stringAtOffset:PACKET_HEADER_SIZE bytesRead:&count];
return [[self class] packetWithPlayerName:playerName];
}
在这里,你简单第看到玩家的名字,在NSData对象开始的10个字节中(被PACKET_HEADER_SIZE表示的不变变量)。然后你调用正规的方便分配和初始化对象的构造函数。
这里唯一的问题是, PACKET_HEADER_SIZE是一个未知的符号。它在Packet.m中声明,但是这对于其他对象不可见,所以加一个前瞻性声明到packet.h中:
const size_t PACKET_HEADER_SIZE;
现在一切都应该完成--运行!--再次。试试吧。服务器写入了所有连接的客户端的名称。你已经实现了服务器和客户端之间的双向沟通!
下一步该做什么?
这是到目前为止所有教程系列的示例项目。
希望你没有厌烦这些有关网络的东西,因为在第4部分会有更多!当你准备好实施“游戏客户端和服务器之间的握手”时点击进入,,并创建主游戏画面!
在此期间,关于这个系列的一部分,如果您有任何问题或意见,请加入论坛讨论!