联机版贪吃蛇

概述

突然发现我的贪吃蛇还没有联机,补一个简单的联机版贪吃蛇程序。

主要实现要求

服务器的用户链接逻辑(主循环)和游戏运行逻辑(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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值