让游戏实现网络通讯功能
技术与开发
在Windows下做网络通讯编程,我们可以选择使用DirectPlay或WinSock。但早期的DirectPlay版本效率很低,以至于许多游戏开发者都弃之而使用WinSock。从DirectX8.0版开始,微软对DirectPlay整个模块都重新进行了构造,比起以前的版本有了很大的进步。但据我所知,现在的游戏开发者仍然很少使用DirectPlay,因此,我们这篇文章所讲的还是主流的WinSock网络编程。
小知识:Windows Socket(简称WinSock)是一种用于网络数据通信的标准API。使用WinSock,应用程序可以通过网络协议(如TCP/IP、UDP/IP)建立通信。
认识WinSock网络编程
1.使用TCP/IP协议
通过TCP/IP协议创建的网络应用程序是一种面向连接的网络通信程序。使用TCP通信,在源计算机和目标计算机之间会建立起一个虚拟的连接。TCP保证数据传输的无误,为了使数据传输无误,就要进行数据校验,这样会牺牲一点速度。在许多情况下,牺牲这一点速度是很有必要的。如在传输文件时,为了保证文件的完整,就必须使用面向连接的通信。在网络游戏开发中,TCP/IP协议应用得很广泛,一些较为重要的数据都是使用TCP/IP协议进行传输的,像玩家的金钱、装备等等。
我们以一个简单的例子来看看TCP/IP协议的数据通讯。例子分为服务器端程序与客户端程序,服务器端接收客户端所发送的数据。
服务器端代码:
StartupWinSock() // 初始化WinSock
Sock = socket( TCP ) // 创建TCP/IP套接字
bind( Sock, Address ) // 将套接字绑定到一个已知的地址上
listen( Sock ) // 监听连接
ClientSock = accept( Sock ) // 接受连接
while( !bQuit )
{ recv( ClientSock, buf ) // 接收数据 }
// 关闭套接字
closesocket( Sock )
closesocket( ClientSock )
CleanupWinSock() // 释放Winsock分配的资源
客户端代码:
StartupWinSock() // 初始化WinSock
Sock = socket( TCP ) // 创建TCP/IP套接字
connect( Sock, ServerAddress ) // 连接服务器
while( !bQuit )
{ InputData( buf ) // 输入数据
send( Sock, buf ) // 发送数据 }
// 关闭套接字
closesocket( Sock )
CleanupWinSock() // 释放Winsock分配的资源

从图中可以更清楚地看到服务器与客户端是如何工作的:
服务器在成功创建了套接字与绑定后,便会进入监听连接阶段。监听连接函数使程序进入等待状态,直到客户端调用了连接函数,服务器在监听到有连接请求后才会跳到接受连接这一步,成功接受了连接后便开始接收数据。接收数据函数也会使程序进入等待状态,直到接收到数据(无论成功或失败)程序才会跳到下一句。
客户端比服务器端要简单得多。它不用像服务器端要进行绑定、监听与接受连接。客户端在创建套接字后,就可以通过SOCKADDR_IN结构给出服务器端的IP地址与端口号,然后就可以用connect函数与服务器进行连接了。
(1)初始化与释放WinSock
初始化WinSock即加载WinSock库,每个WinSock应用程序都必须加载合适的WinSock库。这一步通过WSAStartup函数实现:
int WSAStartup(
WORD wVersionRequested, // 用于指定准备加载的WinSock库的版本。
LPWSADATA lpWSAData // WSADATA结构的指针
);
在使用完WinSock之后,要把WinSock分配的资源释放。这一步用WSACleanup函数完成:
int WSACleanup(void);
(2)创建套接字
“套接字”一词是从英文单词“socket”得来,而“socket”的原意是“插座”。为什么网络套接字要用“插座”这一词呢?大家可以想象一下,插座是把电器与电源连接起来的端口,因此可以认为网络套接字是网络连接的端口。
用于创建套接字的函数是socket(WinSock2版本用的是WSASocket):
SOCKET socket(
int af, // 协议的地址族,使用IP协议的话设成AF_INE
int type, // 协议的套接字类型
int protocol // 套接字的使用协议
);
我们这个例子用的是TCP/IP,所以可以像下面这样创建套接字:
SOCKET ServerSocket = socket( AF_INET, SOCK_STREAM, IPPROTO_TCP );
(3)绑定
所谓的绑定是指把一个已创建好的套接字绑定到一个已知的地址上。这一步通过bind函数完成:
int bind(
SOCKET s, //服务器的套接字
const struct sockaddr FAR* name, //要绑定的地址
int namelen //name的长度
);
ServerAddr.sin_family = AF_INET;
ServerAddr.sin_port = htons( Port );
ServerAddr.sin_addr.s_addr = htonl( INADDR_ANY );
bind( ServerSocket, ( SOCKADDR* )&ServerAddr, sizeof( ServerAddr ) );
“将套接字绑定到一个已知的地址上”,这个所谓的“地址”指的是什么呢?它指的是IP地址与端口号。应用程序通过SOCKADDR_IN结构来指定IP地址和端口号:
struct sockaddr_in
{
short sin_family; //设为AF_INET,告知WinSock使用IP地址族
u_short sin_port; //通信端口号
struct in_addr sin_addr; // 地址
char sin_zero[8]; //填充项,使与结构SOCKADDR长度一样
};
(4)监听
把套接字绑定到已知的地址后,接下来是要将套接字置入监听模式,指示套接字等候客户端的连接。监听这一步骤由listen函数完成。在使用套接字调用了监听函数后,程序将在关闭该套接字前一直处于监听状态。
int listen(
SOCKET s, // 被绑定的服务器端套接字
int backlog // 可同时接受的连接请求数量,一般设为5即可
);
(5)接受连接
通过完成以上的所有步骤,服务器端就可以接受客户端的连接了。接受连接这一步骤由accept函数完成(WinSock2版本的是WSAAccept):
SOCKET accept(
SOCKET s, // 端套接字
struct sockaddr FAR* addr, // SOCKADDR_IN结构指针
int FAR* addrlen // SOCKADDR_IN结构的长度
);
accept函数成功返回后,会返回一个已接受的客户端套接字,以后服务器可以通过此套接字与该客户端进行数据收发。而addr结构是客户端的IPv4址址信息,addrlen指出addr结构的长度。
(6)接收数据
接收数据由recv函数完成(WinSock2版本的是WSARecv)。在TCP/IP的阻塞模式下,程序运行到recv函数会停下来等待另一端发送数据。
int recv(
SOCKET s, // 数据接收端的套接字
char FAR* buf, // 用于接收数据的缓冲区
int len, // buf缓冲区的长度
int flags // 一般设为0即可
);
如果接收数据成功,recv函数返回的是接收到的数据长度,否则返回错误代码。
char buffer[1024];
recv( ClientSocket, buffer, 1024, 0 );
(7)connect函数
客户端通过connect函数可以与服务器端进行连接:
int connect(
SOCKET s, // 套接字
const struct sockaddr FAR* name, // IP地址
int namelen // name的长度
);
(8)发送数据
发送数据可以通过send函数来完成:
int send(
SOCKET s, // 套接字
const char FAR* buf, // 数据缓冲区
int len, // buf缓冲区的长度
int flags //一般设为0即可
);
如果发送成功,send函数返回发送出去的字节数,否则返回错误代码。
(9)关闭套接字
通过closesocket函数即可关闭套接字:
int colsesocket(
SOCKET s //将要关闭的套接字
);
2.使用UDP/IP协议
UDP/IP网络通信是一种面向无连接的网络通信,服务器端与客户端之间不需要建立连接便能进行网络通信。面向无连接的网络通信不确保可靠的数据传输,发送端发送数据时,它不管接收端是否准备接收这些数据,接收端在接收到数据后也不会发送消息确认数据已收到。也就是说,面向无连接的网络通信不同于面向连接的网络通信,它不进行数据校验,也因为这样,它的数据收发速度要比面向连接的网络通信快。
网络游戏中,一般是TCP/IP协议与UDP/IP协议一起使用的。较为重要的数据使用TCP/IP协议进行传输,较为无关紧要的数据使用UDP/IP协议进行传输。如在MMORPG网络游戏中,各个玩家的坐标值经常会改变,像这些数据使用UDP/IP协议进行传输就是不错的办法,即使偶尔丢掉了一两个坐标值,对于游戏也关系不大。
我们还是以一个同样简单的例子进行讲解。
服务器端代码:
StartupWinSock() // 初始化WinSock
Sock = socket( UDP ) // 创建UDP/IP套接字
bind( Sock, Address ) // 将套接字绑定到一个已知的地址上
while( !bQuit )
{ recvfrom( buf ) // 接收数据 }
// 关闭套接字
closesocket( Sock )
CleanupWinSock() // 释放WinSock分配的资源
客户端代码:
StartupWinSock() // 初始化WinSock
Sock = socket( UDP ) // 创建UDP/IP套接字
while( !bQuit )
{ InputData( buf ); // 输入数据
sendto( Sock, ServerAddress, buf ) // 发送数据 }
// 关闭套接字
closesocket( Sock )
CleanupWinSock() // 释放WinSock分配的资源
从给出的UDP/IP服务器伪代码可以看出,它比TCP/IP的服务器代码要简短,很快你就会发现,它比TCP/IP服务器少了监听与接受连接两个步骤。这是因为在面向无连接的通信中,机器之间并不用建立连接便能进行网络通信。再往下看,你又会发现在UDP/IP网络通信中,用于接收数据的函数是recvfrom。
int recvfrom(
SOCKET s, //数据接收端的套接字
char FAR* buf, //用于接收数据的缓冲区
int len, //buf缓冲区的长度
int flags, //一般设为0即可
struct sockaddr FAR* from, //如果接收成功,将填入发送者的地址信息
int FAR* fromlen //from的长度
);
可以看到在接收数据时,UDP/IP不同于TCP/IP,它并不需要得到发送端的套接字,recvfrom不是专用于接收某个套接字的数据。它可以接收网络上任何一台机器的数据包。
因为是面向无连接的网络通信,所以在客户端的程序中我们可以看到,程序并没有用connect函数与服务器进行连接。在无连接的套接字上发送数据,使用的是sendto函数。
int sendto(
SOCKET s, //用于发送数据的套接字
const char FAR* buf, //数据缓冲区
int len, //buf缓冲区的长度
int flags, //一般设为0即可
const struct sockaddr FAR* to, //数据接收方的地址
int tolen // to的长度
);
正确的学习步骤
要学习网络编程,必须先对计算机网络有所了解,《计算机网络(第四版)》这本书很值得推荐。对计算机网络有一定了解后建议看《Windows网络编程(第二版)》,学习网络编程大概需要三个月时间。