文章目錄
- 概述
- 主要實作要求
- 實作效果
- 服務器
- 客戶資訊
- 客戶資訊管理
- 對局資訊
- 對局管理
- 貪吃蛇資料
- 游戲服務器
- 單例
- 登陸相關操作
- 新鏈接
- 可讀
- 游戲處理
- 客戶端
- 網路連接
- 執行緒讀取訊息
- 接受用戶操作
- 百度云鏈接
概述
突然發現我的貪吃蛇還沒有聯機,補一個簡單的聯機版貪吃蛇程式,
主要實作要求
服務器的用戶鏈接邏輯(主回圈)和游戲運行邏輯(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
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/317676.html
標籤:其他
上一篇:大二學生的一些感悟
下一篇:游戲大綱細化
