概述
突然发现我的贪吃蛇还没有联机,补一个简单的联机版贪吃蛇程序。
主要实现要求
服务器的用户链接逻辑(主循环)和游戏运行逻辑(n个线程中),维护一个客户信息vector和一个对局信息map。
一个对局可以有两个用户(再多加的用户可以拓展成观战,有机会再搞吧),当链接进来两个用户同一个房间,就从vector移走信息,放入对局中,开启一个线程,持续提供服务。
客户端连上服务器之后只有两件事,接受键盘消息(并且给服务器发送自己的操作),接受服务器消息(显示画面)。并没有任何游戏逻辑的判断。
实现效果
重复登陆:
两人对局:
第三人加入:
服务器
首先要熟悉select网络模型。
客户信息
主要是socket(唯一标识)和对局id(进入对局标识)两个属性。
在对象析构的时候会自动断开链接。
struct Client
{
explicit Client(SOCKET sock) : sock(sock)
{
strcpy(this->userName, "");
code = 0;
}
Client(SOCKET sock, const char *userName, const sockaddr_in *addr)
{
this->sock = sock;
strcpy(this->userName, userName);
this->addr = *addr;
code = 0;
}
~Client()
{
if (sock != INVALID_SOCKET)
{
close(sock);
}
}
SOCKET sock; // 服务器socket
int code; // 对局id
char userName[USER_NAME_LENGTH]; // 用户名
sockaddr_in addr; // 用户地址
int lastPos; // 消息缓冲区的数据尾部位置
char szMsgBuf[MSG_BUFFER_LENGTH]; // 第二缓冲区 消息缓冲区
};
客户信息管理
使用一个vector来管理client,并且使用智能指针shared_ptr,在转移到对局的时候只要指针拷过去就行了。
其中可能ChangeClientName一开始看的时候有点问题,因为用户在链接进来的时候,服务器并不知道用户的name,所以没办法在一初始化的时候就设定name。
调用RemoveOne将client移出vector后,如果client没加入对局(这种情况 应该不会发生)或者加入对局还没开始(等对局也删除),就会断开链接。
class ClientManager
{
public:
// 添加一个客户端
void Add(SOCKET sock, const char *userName, const sockaddr_in *addr);
// 移除一个客户端 但是链接不一定会断
void RemoveOne(SOCKET sock);
// 添加所有客户端到 可读集合中
void SetAllRead(fd_set &fdRead, SOCKET &maxSock);
// 获取所有可读客户端
void GetAllRead(fd_set &fdRead, std::vector<std::shared_ptr<Client>> &read);
// 修改客户端名称
std::shared_ptr<Client> ChangeClientName(SOCKET sock, const char *userName);
// 打印数据
void Dump();
private:
std::vector<std::shared_ptr<Client>> clients; // 客户端socket
};
对局信息
一个对局有两个玩家(扩展了观战就会增加),一个线程,和一个游戏数据。
struct Table
{
std::shared_ptr<Client> playerA; // 玩家A
std::shared_ptr<Client> playerB; // 玩家B
bool start; // 开启对局
pthread_t tid; // 线程id
Snake snake; // 贪吃蛇数据
};
对局管理
使用一个map来管理对局,code为对局的唯一标识。
因为map会有多个线程存取,所以操作需要加锁。
class TableManager
{
public:
TableManager()
{
pthread_mutex_init(&mutex, NULL);
}
~TableManager()
{
pthread_mutex_destroy(&mutex);
}
// 加入对局 分为:不存在对局、对局有一个人、对局有两个人(可扩展)
int Add(int code, std::shared_ptr<Client> player);
// 得到对局 传入code不对可能有异常
Table& GetTable(int code);
// 删除对局
void RemoveOne(int code);
// 打印数据
void Dump();
private:
std::map<int, Table> tables; // 对局
pthread_mutex_t mutex; // 互斥锁
};
贪吃蛇数据
有一个地图,两个玩家的坐标和偏移方向。
如果一帧(这里设定1s,主要为了测试游戏)玩家没有任何操作,就朝着偏移方向前进。
死亡规则是碰到除了空以外的物体,如果两个玩家一起死亡就是平局。
说明一下本来贪吃蛇是不能回头的,这里可以回头但是会直接死亡。
class Snake
{
private:
struct Point
{
Point(int x = 0, int y = 0) : x(x), y(y) {}
bool operator==(const Point &p)
{
return x == p.x && y == p.y;
}
int x;
int y;
};
uint8_t world[WIDTH][HEIGHT]; // 游戏区
Point playerA; // 游戏者 A 的坐标
Point playerB; // 游戏者 B 的坐标
Point offsetA; // 游戏者 A 的移动偏移方向
Point offsetB; // 游戏者 B 的移动偏移方向
public:
Snake() { Init(); }
// 初始化游戏
void Init();
// 处理命令
void DealCmd(int cmd);
// 处理游戏逻辑
// 返回值 0 -- 正常游戏
// 1 -- A死亡
// 2 -- B死亡
// 3 -- 都死亡
int DealGame();
// 拷贝地图
void copyWord(uint8_t world[WIDTH][HEIGHT]);
};
游戏服务器
单例
单例使用了c++11的静态局部对象。
class Server : public ServerNet
{
private:
。。。
static Server *g_pSingleton; // 唯一单实例对象指针
。。。
public:
// 获取单实例对象
static Server &GetInstance()
{
// 局部静态特性的方式实现单实例
static Server server;
g_pSingleton = &server;
return server;
}
// 获取客户端
static ClientManager& GetClientManager()
{
return g_pSingleton->clientManager;
}
// 获取对局
static TableManager& GetTableManager()
{
return g_pSingleton->tableManager;
}
private:
// 禁止外部构造
Server() {}
// 禁止外部析构
virtual ~Server() {}
// 禁止外部复制构造
Server(const Server &server);
// 禁止外部赋值操作
const Server &operator=(const Server &server);
};
登陆相关操作
bool Server::OnRun()
{
if (IsRun())
{
fd_set fdRead; // 描述符(socket) 集合
FD_ZERO(&fdRead); // 清理集合
FD_SET(serverSock, &fdRead); // 将描述符(socket)加入集合
// 放置socket并且得到最大socket
SOCKET maxSock = serverSock;
clientManager.SetAllRead(fdRead, maxSock);
timeval t = { 1,0 };
if (select(maxSock + 1, &fdRead, NULL, NULL, &t) < 0)
{
std::cout << "select任务结束" << std::endl;
Close();
return false;
}
// 有链接
if (FD_ISSET(serverSock, &fdRead))
{
FD_CLR(serverSock, &fdRead);
Accept();
}
// 可读
std::vector<std::shared_ptr<Client>> read;
clientManager.GetAllRead(fdRead, read);
for_each(read.begin(), read.end(), [&](std::shared_ptr<Client> client) {
if (-1 == RecvData(szRecv, *client, std::bind(&Server::OnNetMsg, g_pSingleton, std::placeholders::_1, std::placeholders::_2)))
{
std::cout << "客户端 未开局<Socket=" << client->sock << ",userName=" << client->userName << ">已退出" << std::endl;
clientManager.RemoveOne(client->sock);
tableManager.RemoveOne(client->code);
}
});
return true;
}
return false;
}
新链接
这边有一个链接进来调用ServerNet::Accept(),其中调用了一个虚函数AddClient,由Server重写。
SOCKET ServerNet::Accept()
{
sockaddr_in clientAddr = {};
int nAddrLen = sizeof(sockaddr_in);
SOCKET clientSock = INVALID_SOCKET;
clientSock = accept(serverSock, (sockaddr*)&clientAddr, (socklen_t *)&nAddrLen);
if (INVALID_SOCKET == clientSock)
{
std::cout << "socket=<" << serverSock << ">错误,接受到无效客户端SOCKET" << std::endl;
}
else
{
// 有新的链接进来
AddClient(clientSock, "", &clientAddr);
std::cout << "socket=<" << serverSock << ">新客户端加入:socket = " << clientSock << ",IP = " << inet_ntoa(clientAddr.sin_addr) << std::endl;
}
return clientSock;
}
void Server::AddClient(SOCKET sock, const char *userName, const sockaddr_in *addr)
{
Dump();
clientManager.Add(sock, "", addr);
}
可读
对可读的客户端调用RecvData,粘包出来后会将消息丢给OnNetMsg,目前这只有登陆消息。
登陆成功,会将client加入对局,如果凑够两个人,就会开启对局线程,并且把client从vector移除。
void Server::OnNetMsg(SOCKET sock, DataHeader *header)
{
switch (header->cmd)
{
case CMD_JOIN:
{
Join *join = (Join*)header;
printf("收到客户端<Socket=%d>请求:CMD_JOIN,数据长度:%d,userName=%s,code=%d\n", sock, join->dataLength, join->userName, join->code);
// 记录用户名 加入对局
int number = tableManager.Add(join->code, clientManager.ChangeClientName(sock, join->userName));
if (1 == number)
{
// 开启对局
Table &table = tableManager.GetTable(join->code);
if (pthread_create(&table.tid, NULL, thread, reinterpret_cast<void*>(join->code)) != 0)
{
std::cout << "pthread_create error" << std::endl;
}
pthread_detach(table.tid);
clientManager.RemoveOne(table.playerA->sock);
clientManager.RemoveOne(table.playerB->sock);
SendJoinResult(table.playerA->sock, number); // 开启对局
}
SendJoinResult(sock, number); // 登录返回消息
// 暂且结束 待扩展
if (2 == number)
{
clientManager.RemoveOne(sock);
}
break;
}
}
}
游戏处理
void* Server::thread(void *arg)
{
char szRecv[MSG_BUFFER_LENGTH]; // 接收一级缓冲区
int code = reinterpret_cast<long>(arg);
TableManager &tableManager = Server::GetTableManager();
Table &table = tableManager.GetTable(code);
SOCKET maxSock = std::max(table.playerA->sock, table.playerB->sock);
long long oldclock = ustime(); // 记录上一次 tick
while (true)
{
fd_set fdRead; // 描述符(socket) 集合
FD_ZERO(&fdRead); // 清理集合
// 将描述符(socket)加入集合
FD_SET(table.playerA->sock, &fdRead);
FD_SET(table.playerB->sock, &fdRead);
timeval t = { 0, 900000 };
int ret = select(maxSock + 1, &fdRead, NULL, NULL, &t);
if (ret < 0)
{
std::cout << "thread select任务结束" << std::endl;
}
// 检查玩家A是否可读
if (FD_ISSET(table.playerA->sock, &fdRead))
{
if (-1 == RecvData(szRecv, *table.playerA, std::bind(&Server::OnThreadNetMsg, g_pSingleton, std::placeholders::_1, std::placeholders::_2)))
{
std::cout << "对局 <code=" << code << ">结束" << std::endl;
break;
}
}
// 检查玩家B是否可读
if (FD_ISSET(table.playerB->sock, &fdRead))
{
if (-1 == RecvData(szRecv, *table.playerB, std::bind(&Server::OnThreadNetMsg, g_pSingleton, std::placeholders::_1, std::placeholders::_2)))
{
std::cout << "对局 <code=" << code << ">结束" << std::endl;
break;
}
}
// 给两个玩家发送消息
auto sendServerSync = std::bind(&Server::SendServerSync, g_pSingleton, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3);
switch (table.snake.DealGame())
{
case 0:
sendServerSync(table.playerA->sock, table.snake, 0);
sendServerSync(table.playerB->sock, table.snake, 0);
break;
case 1:
sendServerSync(table.playerA->sock, table.snake, 1);
sendServerSync(table.playerB->sock, table.snake, 2);
break;
case 2:
sendServerSync(table.playerA->sock, table.snake, 2);
sendServerSync(table.playerB->sock, table.snake, 1);
break;
case 3:
sendServerSync(table.playerA->sock, table.snake, 3);
sendServerSync(table.playerB->sock, table.snake, 3);
break;
}
// 等到一帧结束 再开始接受消息
HpSleep(1000000, oldclock);
}
tableManager.RemoveOne(code);
return NULL;
}
这边对于一帧的处理使用了延时HpSleep,不是简单的直接sleep,类似于下图理想的延时函数,把程序运行结果都包含在内。
long long Server::ustime(void)
{
struct timeval tv;
long long ust;
gettimeofday(&tv, NULL);
ust = ((long long)tv.tv_sec)*1000000;
ust += tv.tv_usec;
return ust;
}
void Server::HpSleep(int us, long long &oldclock)
{
oldclock += us; // 更新 tick
if (ustime() > oldclock) // 如果已经超时,无需延时
{
oldclock = ustime();
}
else
{
while (ustime() < oldclock) // 延时
{
usleep(2000); // 释放 CPU 控制权,降低 CPU 占用率
}
}
}
用户操作处理也很简单
void Server::OnThreadNetMsg(SOCKET sock, DataHeader *header)
{
switch (header->cmd)
{
case CMD_CLIENT_OPERATION:
{
ClientOperation *clientOperation = (ClientOperation*)header;
Table &table = tableManager.GetTable(clientOperation->code);
table.snake.DealCmd(clientOperation->gameCmd);
break;
}
}
}
void Server::SendServerSync(SOCKET sock, Snake &snake, int result)
{
ServerSync ret;
ret.result = result;
snake.copyWord(ret.world);
SendData(sock, &ret);
}
客户端
网络连接
Read读取的消息会交给OnNetMsg处理,SendLogin和SendOperation有额外的处理,所以都放到后面实现了。
class Net
{
public:
Net() : socketClient(INVALID_SOCKET) {}
~Net()
{
if (INVALID_SOCKET != socketClient)
{
closesocket(socketClient);
}
WSACleanup();
}
// 初始化网络
bool Init();
// 读取消息
void Read();
// 响应网络消息
virtual void OnNetMsg(DataHeader *header) = 0;
// 发送用户加入
virtual void SendLogin(Join &join) = 0;
// 发送用户操作
virtual void SendOperation(ClientOperation &clientOperation) = 0;
protected:
SOCKET socketClient; // socket
private:
// 粘包问题 分包
char szRecv[4096]; // 接收缓冲区
char szMsgBuf[10240]; // 第二缓冲区 消息缓冲区
int lastPos = 0; // 消息缓冲区的数据尾部位置
};
线程读取消息
这里用的c++11的线程。
class MyThread
{
public:
MyThread() : isRun(false) {}
// 线程是否在运行
bool IsRun() { return isRun; }
// 设置线程是否在运行
void setIsRun(bool isRun) { this->isRun = isRun; }
// 线程函数
virtual void CallBack() = 0;
protected:
std::shared_ptr<std::thread> t;
private:
bool isRun; // 线程是否运行
};
当服务器返回登录结果为1的时候就分配线程对象,开启线程。
void Game::OnNetMsg(DataHeader *header)
{
switch (header->cmd)
{
case CMD_JOIN_RESULT:
{
。。。
else if (1 == joinResult->result)
{
// 开启对局
Clear();
setIsRun(true);
t = std::make_shared<std::thread>(&Game::CallBack, this);
t->detach();
}
。。。
}
。。。
}
}
void Game::CallBack()
{
while (IsRun())
{
Read();
}
}
接受用户操作
这边使用的GetAsyncKeyState检测按键状态,并且也是做了1s的延时。
int main(void)
{
。。。
while (game.IsRun())
{
game.DealCmd();
game.HpSleep(1000);
}
。。。
}
void Game::DealCmd()
{
ClientOperation clientOperation;
clientOperation.gameCmd = (GAMECMD)0;
if (isPlayerA)
{
if (GetAsyncKeyState('W'))
{
clientOperation.gameCmd = CMD_A_UP;
}
else if (GetAsyncKeyState('S'))
{
clientOperation.gameCmd = CMD_A_DOWN;
}
else if (GetAsyncKeyState('A'))
{
clientOperation.gameCmd = CMD_A_LEFT;
}
else if (GetAsyncKeyState('D'))
{
clientOperation.gameCmd = CMD_A_RIGHT;
}
}
else
{
if (GetAsyncKeyState(VK_UP))
{
clientOperation.gameCmd = CMD_B_UP;
}
else if (GetAsyncKeyState(VK_DOWN))
{
clientOperation.gameCmd = CMD_B_DOWN;
}
else if (GetAsyncKeyState(VK_LEFT))
{
clientOperation.gameCmd = CMD_B_LEFT;
}
else if (GetAsyncKeyState(VK_RIGHT))
{
clientOperation.gameCmd = CMD_B_RIGHT;
}
}
if (0 != clientOperation.gameCmd)
{
SendOperation(clientOperation);
}
}
void Game::HpSleep(int ms)
{
static clock_t oldclock = clock(); // 静态变量,记录上一次 tick
oldclock += ms * CLOCKS_PER_SEC / 1000; // 更新 tick
if (clock() > oldclock) // 如果已经超时,无需延时
{
oldclock = clock();
}
else
{
while (clock() < oldclock) // 延时
{
Sleep(1); // 释放 CPU 控制权,降低 CPU 占用率
}
}
}
百度云链接
easyx源程序链接
贪吃蛇游戏的双人对战版
代码百度云链接:https://pan.baidu.com/s/1pQNsEnaErfBlSFxutzxT6Q
提取码:tn4x