当前位置: 首页 > news >正文

项目——在线五子棋对战

项目仓库:https://gitee.com/xia-sicheng/gobang 

一项目介绍

1.本项目主要实现了一个网页版五子棋对战游戏,其主要支持一下核心功能:

  • 用户管理:实现用户注册,登录功能,保存用户天梯分数、游戏场次、好友信息等属性
  • 好友管理:保存好友信息,以及实现添加好友的功能
  • 匹配对战:实现两个玩家在网页端根据天梯分数匹配游戏对手,并进行五子棋对战的功能
  • 聊天功能:实现两个玩家在下棋的同时可以实时进行聊天沟通

2.开发环境

  • Linux-Ubuntu22.04
  • VScode/Vim
  • g++/gdb
  • Makefile

3.核心技术

  • HTTP/WebSocket
  • WebSocket++
  • JsonCpp
  • Mysql
  • C++11
  • BlockQueue
  • HTML/CSS/JS/AJAX

二.项目展示

1.首页

2. 注册

注册成功后,点击确认会自动跳转到登录界面

 如果用户名已经被使用了,此时就会提醒我们注册失败,用户名已经被占用

3.登录

登录成功后,会自动跳转到游戏大厅界面

 如果用户名或者密码错误,测登录失败

4.游戏大厅 

  1. 用户生涯信息展示:包含用户名、id、段位、总场次、胜场、胜率
  2. 匹配按钮:点击匹配按钮,开始匹配
  3. 段位展示框:介绍该五子棋的所有段位信息
  4. 好友列表:分为在线与离线状态
  5. 搜索框:用来通过用户名+id来添加指定玩家
  6. 关机按钮:退出游戏

 5.添加好友功能

当发送好友申请后,对方就会收到好友申请。

 同意后,则会提示双方添加好友成功,并刷新好友列表

6.匹配功能

点击匹配,同一段位的用户便会进行随机匹配。未定级用户则会默认与黑铁段位一同匹配。 

开始匹配后,则会开始计时

 匹配成功后,同时进入游戏房间

7.游戏房间

  1. 棋盘
  2. 聊天框
  3. 提示信息:用来提示当前该谁走棋等一系列提示信息 

 游戏结束,点击返回大厅,即可返回大厅。

当完成了一局比赛,此时用户的段位信息就会显示: 

8.好友邀请对战 

可以通过好友列表,对在线且为在游戏中的好友进行对战邀请,如果对方同意,则会进行比赛

 9.状态改变

当好友登录后,此时好友列表便会从离线变为在线状态,如果好友在游戏中,则会显示游戏中。并且此时无法邀请对战。

10.游戏退出

我们即可以使用右上角的退出按钮退出,也可以直接关闭页面或者浏览器。无论是哪种方式,它的好友都会立刻收到其离线的消息,立刻更新好友列表为离线。 

三.相关知识

1.WebSocket

http协议是一种无状态,短链接的通信协议。它遵循“一次请求,一次响应”的半双工工作模式,服务器无法主动向客户端发送消息。每一次发送请求的过程,都是重新建立新连接的过程。

而我们要实现的在线五子棋是需要很高的实时性的,并且要依靠服务器来向双方发送通知,来实现对战的效果。而http无法直接实现服务器主动发送消息到客户端,只能依靠轮询或者长轮询的方式来解决实时性问题。

  • 轮询:即客户端定期向服务器发送请求,询问服务器是否有新数据,服务器立即响应,缺点是冗余请求多、带宽浪费大、延迟高;
  • 长轮询:客户端发送请求给服务器,如果此时服务器没有数据更新,就不响应,也不关闭该连接,当有数据到来了,此时做出响应,关闭连接,缺点是连接仍需频繁重建,服务器资源消耗大。

为了解决http协议在实时场景下的局限性,WebSocket协议横空出世。WebSocket 协议是一种在单个 TCP 连接上提供全双工(full-duplex) 通信的应用层协议,它允许客户端和服务器之间建立持久连接,双方可以随时主动发送数据,无需等待对方请求,是实时 Web 应用的核心技术之一。

1.工作原理

先通过http协议发送升级协议请求,如果验证成功则切换为WebSocket协议,之后就通过websocket协议进行通信。

想要建立一个WebSocket连接,客户端要向服务器发送一个协议升级的http get请求,该请求正文包含Connection: Upgrade,表示这是一个协议升级请求。Upgrade:WebSocket则表示要升级为什么协议。

Sec-WebSocket_Version:表示WebSocekt协议版本

Sec-WebSocket_Key:是一个客户端生成的随机 16 字节字符串(Base64 编码),用于服务器验证。 

服务器收到该升级请求后,验证成功后,会返回升级协议响应:

响应报文中包含Sec_Websocket_Accept字段,由key字段计算而来。当客户端计算出的该字段与服务器一致时,此时HTTP协议正式升级为WebSocket协议。

2.WebSocket报文格式

FIN:WebSocket传输数据以消息为概念单位,一个消息可能由一个或者多个帧组成,FIN字段为1,则表示这是该消息的最后一个帧。

RSV1~3:保留字段,只在扩展时使用。若未启用扩展,则必须为0。

opcode:标志当前数据帧的类型

  • 0x0:延续帧,即本次数据传输使用了数据分片,这只是该消息中的其中一片。配合FIN使用
  • 0x1:文本帧,有效载荷为 UTF-8 编码的文本。
  • 0x2:二进制帧,有效载荷为二进制数据。
  • 0x3-0x7:保留,暂未使用
  • 0x8:关闭帧,请求关闭连接
  • 0x9:ping帧,用于心跳检测或主动测试连接。
  • 0xa:pong帧,对 Ping 帧的响应。
  • 0xb-0xf:保留,暂未使用

Mask:标识有效载荷是否使用掩码,c->s:必须设置为1,s->c:必须设置为0.若为1则必有Mask-Key,⽤于解码Payload数据。防止中间网络设备(如代理)缓存 WebSocket 数据,增强安全性。

Payload Length:表示有效载荷的字节长度,长度区间如下

  • 0-125:则直接表示长度
  • 126:后面的16位(2个字节)表示该有效载荷的长度
  • 127:后面的64位(8个字节)表示该有效载荷的长度 

Mask key:当mask为1时存在,⻓度为4字节,用来解码,解码规则为:P为原始载荷,C为加密后载荷,加密后字节C[i] = P[i] ^ M[i % 4]

Payload data:实际传输的数据内容。

2.WebSocketpp

WebSocketpp是一个基于 C++ 的现代化 WebSocket 库,它提供了简洁、灵活且高性能的 API,用于开发 WebSocket 客户端和服务器。该库设计注重安全性、可扩展性和跨平台兼容性,广泛应用于网络游戏、实时数据传输、物联网等领域。

核心特点:

纯C++实现,不依赖Boost,仅需C++11标准库(部分功能需OpenSSL),支持跨平台

高性能,基于Asio异步I/O模型,支持事件驱动和多线程,零拷贝设计,减少内存开销

安全可靠,支持TLS/SSL

WebSocketpp用户手册:WebSocket++: Main Page

3.JsonCpp

JsonCpp库主要用于实现Json格式数据的序列化和反序列化,它实现了将多个数据对象组织成为Json格式字符串,以及将Json格式字符串解析得到多个数据对象的功能。

示例代码:Json序列化和反序列化

#include <iostream>
#include <string>
#include <sstream>
#include <jsoncpp/json/json.h>// json实现序列化
std::string serialize() 
{// 1.创建一个json::value对象用来存储数据Json::Value root;// 2.给该value对象中插入数据root["name"] = "张三";root["sex"] = "男";root["age"] = 18;root["hobby"].append("swim");root["hobby"].append("run");root["hobby"].append("sleep");// 3.实例化一个工厂Json::StreamWriterBuilder builder;// 4.由工厂生产一个streamwriter对象Json::StreamWriter *writer = builder.newStreamWriter();// 5.使用该writer对象进行序列化std::stringstream ss;writer->write(root, &ss);std::cout << ss.str() << std::endl;delete writer;return ss.str();
}// json 实现反序列化
void deserialize(std::string str)
{// 1.创建Json::value对象Json::Value root;// 2.实例化CharReaderBuilder工厂类Json::CharReaderBuilder builder;// 3.用该工厂生产一个CharReader对象Json::CharReader *reader = builder.newCharReader();// 4.使用该对象对json串进行反序列化,将数据写入到Json::Value中std::string err;bool ret = reader->parse(str.c_str(), str.c_str() + str.size(), &root, &err);if(!ret) {std::cout << "deserialize error->" << err << std::endl;return;}std::cout << "name-> " << root["name"].asString() << std::endl;std::cout << "age-> " << root["age"].asInt() << std::endl;std::cout << "sex-> " << root["sex"].asString() << std::endl;int sz = root["hobby"].size();std::cout << "hobby-> ";for(int i=0; i<sz; i++) {std::cout << root["hobby"][i].asString() << " ";}std::cout << std::endl;delete reader;
}int main()
{std::string str = serialize();deserialize(str);return 0;
}

4.MySQL API 

在该项目中,我们需要使用数据库来保存用户的各项信息,所以这就要求我们能使用一些api接口来执行sql语句,以修改数据库中用户的信息。

我们有如下测试表:

+---------+-------------+------+-----+---------+----------------+
| Field    | Type        | Null | Key | Default | Extra          |
+---------+-------------+------+-----+---------+----------------+
| id          | int(11)     | NO   | PRI | NULL    | auto_increment |
| name    | varchar(32) | NO   |     | NULL    |                |
| age       | tinyint(4)  | NO   |     | NULL    |                |
| chinese | float(4,2)  | YES  |     | NULL    |                |
| english  | float(4,2)  | YES  |     | NULL    |                |
| math     | float(4,2)  | YES  |     | NULL    |                |
+---------+-------------+------+-----+---------+----------------+

 插入、删除、修改语句都如下:

#include <iostream>
#include <string>
#include <vector>
#include <mariadb/mysql.h>#define HOST "127.0.0.1"
#define PORT 3306
#define USER "root"
#define PASSWORD "Xsc.200411"
#define DB "gobang"int main()
{// 1.获取mysql操作句柄MYSQL *mysql = mysql_init(nullptr);if(mysql == nullptr) {std::cerr << "mysql_init error-> " << mysql_error(mysql) << std::endl;;return -1;}    // 2.连接mysql服务器if(!mysql_real_connect(mysql, HOST, USER, PASSWORD, DB, PORT, nullptr, 0)) {std::cerr << "connect mysql server error-> " << mysql_error(mysql) << std::endl;return -1;}// 3.设置客户端字符集if(!mysql_set_character_set(mysql, "utf8mb4_general_ci")) {std::cerr << "mysql_set_character_set error-> " << mysql_error(mysql) << std::endl;return -3;}// 4.选择要操作的数据库mysql_select_db(mysql, DB);// 5.执行sql语句std::string sql = "insert into test_stu (name, age, chinese, english, math) values ('张三', 18, 10, 20, 49);";int ret = mysql_query(mysql, sql.c_str());if(ret != 0) {// mysql_query返回值等于0表示sql语句执行成功std::cout << sql << std::endl;std::cout << mysql_error(mysql) << std::endl;// 此时不能直接return,避免资源泄露,要先关闭连接,销毁句柄mysql_close(mysql);}// 6.关闭mysql句柄mysql_close(mysql);return 0;
}

对于查询语句来说,执行完语句后,我们要将执行结果拿到本地来,然后在一次拿出结果集

#include <iostream>
#include <string>
#include <vector>
#include <mariadb/mysql.h>#define HOST "127.0.0.1"
#define PORT 3306
#define USER "root"
#define PASSWORD "Xsc.200411"
#define DB "gobang"int main()
{// 1.获取mysql操作句柄MYSQL *mysql = mysql_init(nullptr);if(mysql == nullptr) {std::cerr << "mysql_init error-> " << mysql_error(mysql) << std::endl;;return -1;}    // 2.连接mysql服务器if(!mysql_real_connect(mysql, HOST, USER, PASSWORD, DB, PORT, nullptr, 0)) {std::cerr << "connect mysql server error-> " << mysql_error(mysql) << std::endl;return -1;}// 3.设置客户端字符集if(!mysql_set_character_set(mysql, "utf8mb4_general_ci")) {std::cerr << "mysql_set_character_set error-> " << mysql_error(mysql) << std::endl;return -3;}// 4.选择要操作的数据库mysql_select_db(mysql, DB);// 5.执行sql语句std::string sql = "select * from test_stu";int ret = mysql_query(mysql, sql.c_str());if(ret != 0) {// mysql_query返回值等于0表示sql语句执行成功std::cout << sql << std::endl;std::cout << mysql_error(mysql) << std::endl;// 此时不能直接return,避免资源泄露,要先关闭连接,销毁句柄mysql_close(mysql);}// 6.查询并获取结果集到本地MYSQL_RES *res = mysql_store_result(mysql);if(res == nullptr) {std::cout << "mysql_store_result error-> " << mysql_error(mysql) << std::endl;return -5;}// 7.获取行数和列数int row = mysql_num_rows(res);int col = mysql_num_fields(res);// 8.遍历结果集for(int i=0; i<row; i++) {// 一行一行取出MYSQL_ROW s = mysql_fetch_row(res);for(int j=0; j<col; j++) {std::cout << s[j] << "\t";}std::cout << std::endl;}// 9.释放结果集mysql_free_result(res);// 10.关闭句柄mysql_close(mysql);return 0;
}

四.项目实现

1.util.hpp

该文件用来实现一些边缘化的接口,以及一些工具接口,例如间的的日志打印,mysqlAPI的封装,json的序列与反序列化,文件操作,以及字符串剪切等。

0x1.头文件包含

#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <memory>
#include <sstream>
#include <fstream>
#include <mariadb/mysql.h>
#include <cstdio>
#include <time.h>
#include <jsoncpp/json/json.h>
#include <websocketpp/server.hpp>
#include <websocketpp/config/asio_no_tls.hpp>

0x2.日志宏

日志这个使用了C风格的不定参数...,使用该不定参时用__VA_ARGS__来解析。前面的##是为了避免不定参为空,导致多一个逗号的情况。

#define DEBUG 0
#define INFO 1
#define WARNING 2
#define ERROR 3
#define FATAL 4
#define DEFAULT_LEVEL DEBUG#define LOG(level, format, ...)                                                              \do                                                                                       \{                                                                                        \if (level < DEFAULT_LEVEL)                                                           \break;                                                                           \time_t t = time(NULL);                                                               \struct tm *tm = localtime(&t);                                                       \char buffer[32] = {0};                                                               \strftime(buffer, 31, "%Y-%m-%d %H:%M:%S", tm);                                       \fprintf(stdout, "[%s][%s:%d]" format "\n", buffer, __FILE__, __LINE__, ##__VA_ARGS__); \} while (0);#define debug(format, ...) LOG(DEBUG, format, ##__VA_ARGS__)
#define info(format, ...) LOG(INFO, format, ##__VA_ARGS__)
#define warning(format, ...) LOG(WARNING, format, ##__VA_ARGS__)
#define error(format, ...) LOG(ERROR, format, ##__VA_ARGS__)
#define fatal(format, ...) LOG(FATAL, format, ##__VA_ARGS__)

0x3.mysql封装

使用mysqlAPI时,都要进行获取句柄,连接等等操作,我们将这几部操作进行封装,简化使用成本。然后在对指定mysql语句的函数进行封装等。

class mysql_util
{
public:static MYSQL *mysql_init_handle(const std::string &host,uint16_t port,const std::string &user,const std::string &password,const std::string &dbname){// 1.获取mysql操作句柄MYSQL *mysql = mysql_init(nullptr);if(mysql == nullptr) {error("%s->%s", "mysql_init error", mysql_error(mysql));return NULL;}    // 2.连接mysql服务器if(!mysql_real_connect(mysql, host.c_str(), user.c_str(), password.c_str(), dbname.c_str(), port, nullptr, 0)) {error("%s->%s", "mysql_real_connect error", mysql_error(mysql));return NULL;}// 3.设置客户端字符集if(!mysql_set_character_set(mysql, "utf8mb4_general_ci")) {error("%s->%s", "mysql_set_character_set error", mysql_error(mysql));return NULL;}// 4.选择要操作的数据库mysql_select_db(mysql, dbname.c_str());info("%s", "mysql_init success!!!");return mysql;}static bool mysql_exe(MYSQL* mysql, const std::string &sql){int ret = mysql_query(mysql, sql.c_str());if(ret != 0) {error("%s\n%s", sql.c_str(), mysql_error(mysql));return false;}return true;}static void mysql_destory(MYSQL *mysql) { if(mysql) mysql_close(mysql); }
};

0x4.json序列化与反序列化

class json_util
{
public:static bool serialize(const Json::Value &root, std::string &json_string){std::stringstream ss;Json::StreamWriterBuilder builder;std::unique_ptr<Json::StreamWriter> writer(builder.newStreamWriter());int ret = writer->write(root, &ss);if(ret != 0) {error("%s", "serialize error");return false;}json_string = ss.str();return true;}static bool deserialize(const std::string json_string, Json::Value &root){std::stringstream ss;Json::CharReaderBuilder builder;std::unique_ptr<Json::CharReader> reader(builder.newCharReader());std::string err;if(!reader->parse(json_string.c_str(), json_string.c_str() + json_string.size(), &root, &err)) {return false;}return true;}
};

0x5.字符串分割

按照指定的分隔符,将字符串分为一个一个的子串存储在数组中。

class string_util
{
public:static int split(const std::string &str, const std::string &sep, std::vector<std::string> &res){size_t pos = 0, index = 0;while(index < str.size()) {pos = str.find(sep, index);if(pos == std::string::npos) { // 没有找到分隔符,说明从pos位置开始到结束位置都是有效字符,直接插入到结果数组中res.emplace_back(str.substr(index));break;}// 找到了分隔符,就将从index开始到pos位置的字符串切割出来,插入到res中// 有可能有多个分隔符连在一起的情况,我们需要进行判断,防止插入空串到res中if(index == pos) {index = pos + sep.size(); //更新下一次开始找的位置continue;}res.emplace_back(str.substr(index, pos - index));index = pos + sep.size(); // 下一次开始的位置要跳过分隔符}return res.size();}
};

0x6.文件操作

因为在该项目中,用户访问服务器,我们要返回html给用户,这就要求我们要读取文件内容,然后通过网络发送过去。

class file_util
{
public:static bool read(const std::string &filename, std::string &body){// 1. 打开文件std::ifstream ifs(filename, std::ios::binary);if(!ifs.is_open()) {error("open file error-> %s", filename.c_str());return false;}// 2.计算文件大小size_t file_size = 0;ifs.seekg(0, std::ios::end);file_size = ifs.tellg();ifs.seekg(0, std::ios::beg);body.resize(file_size);//std::cout << "文件大小为-> " << file_size << std::endl;// 3.读取文件ifs.read(&body[0], file_size);if(!ifs.good()) {error("read file error-> %s", filename.c_str());ifs.close();return false;}// 4.关闭文件ifs.close();return true;}
};

2.数据管理模块

2.1数据库设计

用户信息表:

  • 用户id
  • 用户名
  • 密码
  • ladder、rank、score:天梯分数,以便实现同等水平的用户才能进行对战,首先是黑铁到赋能这9个大段位,用ladder表示;接着每个段位都分为4个小段位,用rank表示;最后是分数,每个小段有50分,达到50分就会升一个小段,小段达到1,则会升一个大段。
  • status:表明用户当前是在线,游戏中,还是离线
  • last_active:表示用户最后的活跃时间,结合心跳机制来实现用户因为网络问题而导致离线的问题。
  • session_id:保留字段

好友信息表:

  •  id:作为主键
  • user_id:表明当前用户
  • friend_id:user_id的好友

对于好友关系来说,use_id和friend_id都要收到外键约束,它们必须都是用户表中存在的用户。因为好友关系是双方的,所以在建立好友关系的时候,出来建立A->B,也要建立B->A的双向关系。同样要对user_id和friend_id做唯一键约束,避免重复添加。

drop database if exists gobang;
create database if not exists gobang;
use gobang;
create table if not exists user_info (id int primary key auto_increment,username varchar(32) unique key not null,password varchar(255) not null,ladder tinyint comment '大段位',rank tinyint comment '小段位',score tinyint comment '当前段位的分数',total_count int comment '总场次',win_count int comment '胜场次',last_active timestamp default current_timestamp on update current_timestamp comment '最后上线时间',status enum('在线', '离线', '游戏中') default '离线',session_id int not null default -1
);create table if not exists friends_info (id int primary key auto_increment,user_id int not null comment '用户id',friend_id int not null comment '朋友id',unique key `uni_user_friend` (user_id, friend_id),foreign key (user_id) references user_info(id),foreign key (friend_id) references user_info(id)
);

2.2数据库类的实现

对于数据库类来说,主要完成的功能就是借助mysql接口来实现对用户表或者好友表进行增删改查操作。

#pragma once#include "util.hpp"
#include <cstdio>
#include <cassert>
#include <mutex>class User_Info_Table
{
public:User_Info_Table(const std::string &host,uint16_t port,const std::string &user,const std::string &password,const std::string &dbname){_mysql = mysql_util::mysql_init_handle(host, port, user, password, dbname);assert(_mysql != NULL);}~User_Info_Table() { if(_mysql) mysql_util::mysql_destory(_mysql); }bool set_all_offline(){
#define OFFLINE "update user_info set status='离线';"bool ret = mysql_util::mysql_exe(_mysql, OFFLINE);if(!ret) {debug("set_all_offline fail");return false;}debug("set_all_offline success");return true;}bool is_exists(const std::string &username){
#define CHECK "select username from user_info where username='%s';"char sql[4096] = {0};sprintf(sql, CHECK, username.c_str());mysql_util::mysql_exe(_mysql, sql);MYSQL_RES *res = nullptr;{// 对查询语句进行加锁保护// 查询成功,判断数据是否只有一行,只有一行则数据正常有效,如果有多行,则查询失败,数据异常无效std::unique_lock<std::mutex> lock(_mutex);res = mysql_store_result(_mysql);if(res == nullptr) {error("%s", "user does not exist");return false;}int row = mysql_num_rows(res);if(row != 1) {return false;}}return true;}//用户注册,向用户信息表中新增数据bool insert(Json::Value &userinfo){
// 新增用户的sql语句
#define INSERT_USER "insert into user_info                                  \(username, password, ladder, rank, score, total_count, win_count)   \values ('%s', password('%s'), 0, 0, 0, 0, 0);"                         // 插入前,先判断该用户是否已经存在// 因为我们的username具有唯一键约束,所以如果该用户已经存在,sql语句就会执行失败char sql[4096] = {0};sprintf(sql, INSERT_USER, userinfo["username"].asCString(), userinfo["password"].asCString());bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("%s", "insert user error!!!");return false;}return true;}// 用户登录bool login(Json::Value &userinfo) {
//用户登录时的查询sql语句
#define LOGIN_USER "select id, ladder, rank, score, total_count, win_count, status\from user_info where username='%s' and password=password('%s');"char sql[4096] = {0};sprintf(sql, LOGIN_USER, userinfo["username"].asCString(), userinfo["password"].asCString());bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("%s", "user login error!!!");return false;}MYSQL_RES *res = nullptr;{// 对查询语句进行加锁保护// 查询成功,判断数据是否只有一行,只有一行则数据正常有效,如果有多行,则查询失败,数据异常无效std::unique_lock<std::mutex> lock(_mutex);res = mysql_store_result(_mysql);if(res == nullptr) {error("%s", "user does not exist");return false;}int row = mysql_num_rows(res);if(row != 1) {error("%s", "Incorrect username or password");return false;}// 查询成功后,修改状态char sql[4096] = {0};sprintf(sql, "update user_info set status='在线' where username='%s'", userinfo["username"].asString().c_str());std::cout << "状态改变->在线" << std::endl;ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("%s", "update status error");return false;}}MYSQL_ROW one_line = mysql_fetch_row(res); // 读取该一行数据userinfo["id"] = std::stoi(one_line[0]);userinfo["ladder"] = std::stoi(one_line[1]);userinfo["rank"] = std::stoi(one_line[2]);userinfo["score"] = std::stoi(one_line[3]);userinfo["total_count"] = std::stoi(one_line[4]);userinfo["win_count"] = std::stoi(one_line[5]);userinfo["status"] = one_line[6];mysql_free_result(res);return true;}// 用户退出游戏,我们将用户状态设置为离线bool logout(int id){
#define LOGOUT "update user_info set status='离线' where id=%d;"char sql[4096] = {0};sprintf(sql, LOGOUT, id);bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("logout error");return false;}return true;}bool online(int id){
#define ONLINE "select status from user_info where id=%d;"char sql[4096] = {0};sprintf(sql, ONLINE, id);bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("logout error");return false;}MYSQL_RES *res = nullptr;{// 对查询语句进行加锁保护// 查询成功,判断数据是否只有一行,只有一行则数据正常有效,如果有多行,则查询失败,数据异常无效std::unique_lock<std::mutex> lock(_mutex);res = mysql_store_result(_mysql);}MYSQL_ROW one_line = mysql_fetch_row(res); // 读取该一行数据std::string status = one_line[0];return status == "在线";}std::string last_active(int id){
#define TIME "select last_active from user_info where id=%d;"char sql[4096] = {0};sprintf(sql, TIME, id);bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("logout error");return "";}MYSQL_RES *res = nullptr;{// 对查询语句进行加锁保护// 查询成功,判断数据是否只有一行,只有一行则数据正常有效,如果有多行,则查询失败,数据异常无效std::unique_lock<std::mutex> lock(_mutex);res = mysql_store_result(_mysql);}MYSQL_ROW one_line = mysql_fetch_row(res); // 读取该一行数据std::string time = one_line[0];return time;}bool alert_last_active(int id) {
#define ALERT "update user_info set last_active = NOW() where id=%d;"char sql[4096] = {0};sprintf(sql, ALERT, id);bool ret = mysql_util::mysql_exe(_mysql, sql);if(ret == false) {error("alert last_active error");return false;}return true;}// 用户进入游戏,将用户状态设置为游戏中bool enter_game(int id){
#define ENTER_GAME "update user_info set status='游戏中' where id=%d;"char sql[4096] = {0};sprintf(sql, ENTER_GAME, id);bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("enter game error");return false;}return true;}// 用户退出游戏,将用户状态设置为在线bool exit_game(int id){
#define EXIT_GAME "update user_info set status='在线' where id=%d;"char sql[4096] = {0};sprintf(sql, EXIT_GAME, id);bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("exit game error");return false;}return true;}// 通过用户名查询用户信息bool select_by_username(const std::string &username, Json::Value &userinfo) {
//通过用户查询用户信息的sql语句
#define SELECT_USER_BY_NAME "select id, ladder, rank, score, total_count, win_count, last_active, status, session_id\from user_info where username='%s';"char sql[4096] = {0};sprintf(sql, SELECT_USER_BY_NAME, username.c_str());bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("%s", "Failed to obtain user information through the username!!!");return false;}MYSQL_RES *res = nullptr;{// 对查询语句进行加锁保护// 查询成功,判断数据是否只有一行,只有一行则数据正常有效,如果有多行,则查询失败,数据异常无效std::unique_lock<std::mutex> lock(_mutex);res = mysql_store_result(_mysql);if(res == nullptr) {error("%s", "user does not exist");return false;}int row = mysql_num_rows(res);if(row != 1) {error("%s", "Data anomaly, user information is not unique");return false;}}MYSQL_ROW one_line = mysql_fetch_row(res); // 读取该一行数据userinfo["id"] = std::stoi(one_line[0]);userinfo["ladder"] = std::stoi(one_line[1]);userinfo["rank"] = std::stoi(one_line[2]);userinfo["score"] = std::stoi(one_line[3]);userinfo["total_count"] = std::stoi(one_line[4]);userinfo["win_count"] = std::stoi(one_line[5]);userinfo["last_active"] = one_line[6];userinfo["status"] = one_line[7];userinfo["session_id"] = std::stoi(one_line[8]);mysql_free_result(res);return true;}// 通过用户id查询用户信息bool select_by_id(const int id, Json::Value &userinfo) {
//通过用户查询用户信息的sql语句
#define SELECT_USER_BY_ID "select username, ladder, rank, score, total_count, win_count, last_active, status, session_id, id\from user_info where id=%d;"char sql[4096] = {0};sprintf(sql, SELECT_USER_BY_ID, id);bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("%s", "Failed to obtain user information through the id!!!");return false;}MYSQL_RES *res = nullptr;{// 对查询语句进行加锁保护// 查询成功,判断数据是否只有一行,只有一行则数据正常有效,如果有多行,则查询失败,数据异常无效std::unique_lock<std::mutex> lock(_mutex);res = mysql_store_result(_mysql);if(res == nullptr) {error("%s", "user does not exist");return false;}int row = mysql_num_rows(res);if(row != 1) {error("%s", "Data anomaly, user information is not unique");return false;}} MYSQL_ROW one_line = mysql_fetch_row(res); // 读取该一行数据userinfo["username"] = one_line[0];userinfo["ladder"] = std::stoi(one_line[1]);userinfo["rank"] = std::stoi(one_line[2]);userinfo["score"] = std::stoi(one_line[3]);userinfo["total_count"] = std::stoi(one_line[4]);userinfo["win_count"] = std::stoi(one_line[5]);userinfo["last_active"] = one_line[6];userinfo["status"] = one_line[7];userinfo["session_id"] = std::stoi(one_line[8]);userinfo["id"] = std::stoi(one_line[9]);mysql_free_result(res);return true;}// 当玩家胜利了,我们需要拿出该用户的信息// 并对score进行加分(10分),如果score>50分,则上一个小段,如果此时小段超过了4,则进一个大段// 下面是段位的详细配置//  大段位ladder:黑铁1,青铜2,白银3,黄金4,铂金5,钻石6,超凡7,神话8,赋能9//  小段位rank:每个大段位都分有四个小段位,以神话为例,神话Ⅳ1,神话Ⅲ2,神话Ⅱ3,神话Ⅰ4,赋能段位与其他段位不同,只有分数,没有小段,且分数没有上线// 小段位分数socre:除赋能外,每个都只有50分// 当玩家刚注册,没有进行对局,这时候默认是没有段位的,即段位为0bool win(int id) {// 1.先拿出该用户的信息Json::Value root;select_by_id(id, root);int score = root["score"].asInt();int rank = root["rank"].asInt();int ladder = root["ladder"].asInt();int win_count = root["win_count"].asInt();int total_count = root["total_count"].asInt();// 2.先判断玩家当前在那个大段位, 然后再更新分数if(ladder == 9) {// 赋能段位score += 10;}else if(ladder == 0) {// 没有段位score += 10;rank = 1;ladder = 1;}else {// 其他段位score += 10;if(score >= 50) {score -= 50;rank += 1;if(rank > 4) {rank = 1;ladder += 1;// 因为此时在其他段位,直接大段位+1即可// 增加一个大段位,避免直接输一局掉下来,所有给个保底分score += 5;}}}// 3.更新场次数据win_count++;total_count++;// 4.更新数据库中该玩家的数据
#define UPDATA_USER_WIN "update user_info set \ladder=%d, rank=%d, score=%d, total_count=%d, win_count=%d \where id=%d;"char sql[4096] = {0};sprintf(sql, UPDATA_USER_WIN, ladder, rank, score, total_count, win_count, id);bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("%s", "Failed to update player data!!!");return false;}return true;}// 玩家失败,对score进行减少5,当score<0则掉一个小段,如果小段为0,则掉一个大段bool lose(int id) {// 1.先拿出该用户的信息Json::Value root;select_by_id(id, root);int score = root["score"].asInt();int rank = root["rank"].asInt();int ladder = root["ladder"].asInt();int win_count = root["win_count"].asInt();int total_count = root["total_count"].asInt();// 2.先判断玩家当前在那个大段位, 然后再更新分数score -= 5;if(score < 0) {if(ladder == 0/*没有段位,输了一局将段位置为最低段位*/) {ladder = 1;rank = 1;score = 0;}if(ladder == 1 && rank == 1) {//最低段位ladder = 1;rank = 1;score = 0;}else {// 对段位进行更新score = 50 + score;rank--; //小段位--if(rank < 1) {//掉一个大段rank = 4;ladder = ladder == 1 ? ladder : ladder-1; //更新大段}}}// 3.更新场次数据total_count++;// 4.更新数据库中该玩家的数据
#define UPDATA_USER_LOSE "update user_info set \ladder=%d, rank=%d, score=%d, total_count=%d, win_count=%d \where id=%d;"char sql[4096] = {0};sprintf(sql, UPDATA_USER_LOSE, ladder, rank, score, total_count, win_count, id);bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("%s", "Failed to update player data!!!");return false;}return true;}// 添加好友//由接收方调用该函数,创建a->b b->a的双向好友关系bool add_friend(int usr_id1, int usr_id2){
#define ADD_FRIEND "insert into friends_info (user_id, friend_id) values (%d, %d);"char sql[4096] = {0};sprintf(sql, ADD_FRIEND, usr_id1, usr_id2);bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("%s", "Failed to add friend");return false;}return true;}// 查询好友bool select_friend_id_by_id(int usr, std::vector<int> &friends) {
#define SELECT_FRIENDS "select friend_id from friends_info where user_id=%d;" char sql[4096] = {0};sprintf(sql, SELECT_FRIENDS, usr);bool ret = mysql_util::mysql_exe(_mysql, sql);if(!ret) {error("Failed to select friend");return false;}MYSQL_RES *res = nullptr;{// 对查询语句进行加锁保护std::unique_lock<std::mutex> lock(_mutex);// 1.获取结果集res = mysql_store_result(_mysql); if(res == nullptr) {error("%s", "user does not exist");return false;}// 2.将该用户所有的好友id都存储到数组中MYSQL_ROW row;while(row = mysql_fetch_row(res)) {friends.emplace_back(std::stoi(row[0]));}// 3.遍历ids数组,通过id查询用户信息,获取好友的用户名和段位等信息// 外部完成,避免死锁} return true;}
private:MYSQL *_mysql;std::mutex _mutex;
};

3.在线用户管理模块

因为我们本项目主要是居于WebSocket通信的,当用户登录之后,就会由http进行协议升级,此时我们每一个用户都有一个长连接用来与服务器进行通信。

在线的用户,要么在游戏大厅中,要么就在游戏房间中,我们需要对这两个地方的用户进行管理,能够通过用户id获取到该用户的连接,以便于该用户进行通信。同时该模块也能够判断用户是否处于在线状态。

#pragma once#include "util.hpp"
#include <mutex>
#include <unordered_map>using ws_server_t = websocketpp::server<websocketpp::config::asio>;
class online_user_manager
{
public:void enter_game_hall(int uid, ws_server_t::connection_ptr &conn){std::unique_lock<std::mutex> lock(_mutex);_hall_user.emplace(uid, conn);}void enter_game_room(int uid, ws_server_t::connection_ptr &conn){std::unique_lock<std::mutex> lock(_mutex);_room_user.emplace(uid, conn);}void exit_game_hall(int uid){std::unique_lock<std::mutex> lock(_mutex);_hall_user.erase(uid);}void exit_game_room(int uid){std::unique_lock<std::mutex> lock(_mutex);_room_user.erase(uid);}bool is_in_game_hall(int uid){std::unique_lock<std::mutex> lock(_mutex);auto iter = _hall_user.find(uid);if(iter == _hall_user.end()) return false;return true;}bool is_in_game_room(int uid){std::unique_lock<std::mutex> lock(_mutex);auto iter = _room_user.find(uid);if(iter == _room_user.end()) return false;return true;}ws_server_t::connection_ptr get_conn_from_hall(int uid){std::unique_lock<std::mutex> lock(_mutex);auto iter = _hall_user.find(uid);if(iter == _hall_user.end()) return ws_server_t::connection_ptr();return iter->second;}ws_server_t::connection_ptr get_conn_from_room(int uid){std::unique_lock<std::mutex> lock(_mutex);auto iter = _room_user.find(uid);if(iter == _room_user.end()) return ws_server_t::connection_ptr();return iter->second;}std::vector<int> get_hall_usrid(){std::unique_lock<std::mutex> lock(_mutex);std::vector<int> res;for(auto &[key,value] : _hall_user) {res.emplace_back(key);}return res;}private:// 都是user_id与连结的对应关系std::unordered_map<int, ws_server_t::connection_ptr> _hall_user;std::unordered_map<int, ws_server_t::connection_ptr> _room_user;std::mutex _mutex;
};

4.游戏房间管理模块

当玩家匹配成功后,我们要对匹配成功的玩家创建房间,建立起一个小范围的玩家之间的关联关系,房间里一个玩家的动作会广播给房间的其他用户

房间可能有很多,有的刚刚创建,有的正在对局,有的比赛刚刚结束要准备释放房间,所以我们还要对房间进行管理。

4.1游戏房间模块

管理的数据:

  • 房间id
  • 房间的状态:决定了一个玩家退出房间时所作的动作,如果房间刚刚创建,玩家退出,则另一方胜利;如果比赛已经结束,此时退出房间,不影响结果
  • 白棋玩家id
  • 黑棋玩家id
  • 数据库句柄:游戏结束后对玩家的段位信息进行更新
  • 棋盘信息
  • 在线用户管理句柄:用来判断玩家是否在线,获取连接进行通信

提供的操作:

  • 下棋
  • 聊天

在房间设计中,我们除了一些get和set方法外,需要实现的就有:下棋,聊天,退出这几步操作,除此之外,我们还要实现广播,将用户的合理合法的动作广播给房间内的所有用户。

对于下棋操作来说,用户要下棋,本质上就是对服务器发送请求,服务器判断该位置可不可下,然后返回给用户响应即可。聊天也同样如此,所以我们就得定制协议,来方便通信期间的数据传递

下棋请求
{"optype"  = "put_chess","room_id" = 222,"id"      = 1,"row"     = 3,"col"     = 1
}下棋响应:如果result是失败,则后序字段无效
{"optype" = "put_chess", "result" = "false", "reason" = "xxxx","id"     = 1, "row"    = 3, "col"    = 1, "winner" = 0/id
}聊天请求
{"optype"  = "chat", "room_id" = 1, "id"      = 1, "message" = "科利马查"
}
#pragma once#include "util.hpp"
#include "db.hpp"
#include "online.hpp"#define BOARD_ROW 15
#define BOARD_COL 15
#define WHITE_CHESS 1
#define BLACK_CHESS 2
typedef enum {START,OVER
} room_state;class room
{
public:room(int room_id, User_Info_Table *tb_handle, online_user_manager *online_user):_room_id(room_id), _table_handle(tb_handle),_online_user(online_user),_state(START),_player_count(0),_board(BOARD_ROW, std::vector<int>(BOARD_COL)){debug("%d 房间创建成功!!!", _room_id);}~room() {debug("%d 房间被销毁!!!", _room_id);}int room_id() { return _room_id; }room_state state() { return _state; }int player_count() { return _player_count; }int white_id() { return _white_id; }int black_id() { return _black_id; }void add_white_chess(int id) { _white_id = id; _player_count++; }void add_black_chess(int id) { _black_id = id; _player_count++; }// 下棋操作// 玩家执行下棋操作,我们所收到的请求报文格式{ "optype" = "put_chess", "room_id" = 222, "id" = 1, "row" = 3, "col" = 1}// 响应报文格式:// 失败-> {"optype" = "put_chess", "result" = "false", "reason" = "xxxx"}// 成功-> {"optype" = "put_chess", "result" = "true", "reason" = "xxx", "id" = 1, "row" = 3, "col" = 1, winner = 0/id}Json::Value handle_chess(Json::Value &req){Json::Value resp = req;// 1. 判断房间号是否匹配 —— 外部// 2.判断房间中的两位玩家是否在线,不在线,则另一方胜利,掉线者扣分,如果都掉线了,则都扣分int row = req["row"].asInt();int col = req["col"].asInt();if(!_online_user->is_in_game_room(_white_id) || !_online_user->is_in_game_room(_black_id)) {// 至少有一方掉线if(!_online_user->is_in_game_room(_white_id) && !_online_user->is_in_game_room(_black_id)) {// 同时掉线resp["result"] = false;resp["reason"] = "双方都掉线了";resp["winner"] = 0;}else if(!_online_user->is_in_game_room(_white_id)) {// 白棋掉线resp["result"] = true;resp["reason"] = "对方掉线";resp["winner"] = _black_id;}else {// 黑棋掉线resp["result"] = true;resp["reason"] = "对方掉线";resp["winner"] = _white_id;}return resp;}// 3. 获取下棋位置,判断该位置是否合理(是否被占用)if(_board[row][col] != 0) {// 该位置已经有棋子了resp["result"] = false;resp["reason"] = "该位置已经有棋子了";return resp;}int color = (req["uid"].asInt() == _white_id ? WHITE_CHESS : BLACK_CHESS);_board[row][col] = color;//下棋// 4.判断是否有玩家胜利,四个方向,判断是否有5个及以上棋子的int winner_id = check(row, col, color);resp["result"] = true;resp["reason"] = "成五";resp["winner"] = winner_id;return resp;}// 聊天请求报文 {"optype" = "chat", room_id = 1, "id" = 1, "message" = "科利马查"}Json::Value handle_chat(Json::Value &req){Json::Value resp = req;// 1. 判断房间号是否匹配 —— 外部// 2.检查消息中是否包含敏感词,如包含敏感词用*代替std::string msg = req["message"].asString();std::string shield = "垃圾";size_t pos = msg.find(shield);if(pos != std::string::npos) {int i = shield.size();while(i--) {msg[pos++] = '*';}resp["message"] = msg;}// 3.广播消息——返回消息Json::Value sender_info;_table_handle->select_by_id(req["uid"].asInt(), sender_info);resp["sender_username"] = sender_info["username"].asString();resp["result"] = true;return resp;}void handle_exit(int id){Json::Value resp;// 玩家退出房间,判断是游戏中退出,还是游戏结束退出if(_state == START) {// 游戏中退出int winner_id = id == _white_id ? _black_id : _white_id;resp["optype"] = "put_chess";resp["result"] = true;resp["reason"] = "对方掉线";resp["uid"] = id;resp["row"] = -1;resp["col"] = -1;resp["winner"] = winner_id;int loser_id = winner_id == _white_id ? _black_id : _white_id;_table_handle->win(winner_id);_table_handle->lose(loser_id);_state = OVER;broadcast(resp);}_player_count--;}void handle_request(Json::Value &req){Json::Value resp;// 1. 判断房间号是否匹配if(req["room_id"].asInt() != _room_id) {// 房间号不匹配,直接构建响应报文,返回下棋失败resp["optype"] = req["optype"].asString();resp["result"] = false;resp["reason"] = "房间号不匹配";return broadcast(resp);}// 2.分类请求类型if(req["optype"].asString() == "put_chess") {resp = handle_chess(req);if(resp["winner"].asInt() != 0) {// 有人获胜// 调用数据库接口,修改信息int winner_id = resp["winner"].asInt();int loser_id = resp["winner"].asInt() == _white_id ? _black_id : _white_id;_table_handle->win(winner_id);_table_handle->lose(loser_id);_state = OVER;}}else if(req["optype"].asString() == "chat") {resp = handle_chat(req);}else {resp["optype"] = req["optype"].asString();resp["result"] = false;resp["reason"] = "Unknow request";}return broadcast(resp);}// 广播void broadcast(Json::Value &resp){// 将格式化信息进行序列化成为一个字符串std::string str;json_util::serialize(resp, str);// 获取房间内用户的连接,并发送响应信息ws_server_t::connection_ptr white_conn = _online_user->get_conn_from_room(_white_id);if(white_conn.get() != nullptr) {white_conn->send(str);}ws_server_t::connection_ptr black_conn = _online_user->get_conn_from_room(_black_id);if(black_conn.get() != nullptr) {black_conn->send(str);}}
private:bool is_five(int row, int col, int row_offset, int col_offset, int color) {int count = 1;int search_row = row + row_offset;int search_col = col + col_offset;while(search_row>=0 && search_row<BOARD_ROW &&search_col>=0 && search_col<BOARD_COL &&_board[search_row][search_col] == color) {count++;search_row += row_offset;search_col += col_offset;}// 反方向继续判断search_row = row - row_offset;search_col = col - col_offset;while(search_row>=0 && search_row<BOARD_ROW &&search_col>=0 && search_col<BOARD_COL &&_board[search_row][search_col] == color) {count++;search_row -= row_offset;search_col -= col_offset;}return (count >= 5);}int check(int row, int col, int color){// 0表示无人获胜,非0即id,表示获胜者的idif(is_five(row, col, 0, 1, color)/*横*/ ||is_five(row, col, 1, 0, color)/*纵*/ ||is_five(row, col, -1, 1, color)/*左下到右上*/ ||is_five(row, col, 1, 1, color)/*左上到右下*/) {return color == WHITE_CHESS ? _white_id : _black_id;}return 0;}
private:int _room_id; //房间idroom_state _state; // 房间状态int _player_count; // 玩家数量int _white_id; // 白棋玩家idint _black_id; // 黑棋玩家idUser_Info_Table *_table_handle; // 数据库句柄online_user_manager *_online_user; // 在线玩家std::vector<std::vector<int>> _board; //棋盘
};

4.2房间管理模块

管理的数据:

  •             1.数据管理模块句柄
  •             2.在线用户管理模块句柄
  •             3.房间ID分配计数器
  •             4.互斥锁
  •             5.unordered_map<room_id, std::shared_ptr<room>> 房间信息管理
  •             6.unordered_map<id, room_id> 房间id与用户id的关联关系管理

提供的操作

  •             1.创建房间(需要两个玩家的用户id)
  •             2.查找房间(通过房间id或者用户id)
  •             3.销毁房间(根据房间id销毁房间/房间没有玩家了,销毁房间)

class room_manager
{
public:room_manager(User_Info_Table *tb, online_user_manager *ou):_room_counter(1), _table_handle(tb),_online_user(ou){}~room_manager(){}std::shared_ptr<room> create_room(int id1, int id2){// 1.两个用户都在大厅中if(!_online_user->is_in_game_hall(id1) || !_online_user->is_in_game_hall(id2)) {return std::shared_ptr<room>();}// 2.创建房间std::unique_lock<std::mutex> lock(_mutex);std::shared_ptr<room> r = std::make_shared<room>(_room_counter, _table_handle, _online_user);// 3.分配黑白棋 r->add_black_chess(id1);r->add_white_chess(id2);// 4.建立映射关系_rooms.emplace(_room_counter, r);_user_2_room.emplace(id1, _room_counter);_user_2_room.emplace(id2, _room_counter);// 5.自增计数器_room_counter++;return r;}std::shared_ptr<room> get_room_by_room_id(int room_id){std::unique_lock<std::mutex> lock(_mutex);auto iter = _rooms.find(room_id);if(iter == _rooms.end()) {return std::shared_ptr<room>();}return iter->second;}std::shared_ptr<room> get_room_by_user_id(int user_id){std::unique_lock<std::mutex> lock(_mutex);auto iter1 = _user_2_room.find(user_id);if(iter1 == _user_2_room.end()) {return std::shared_ptr<room>();}int room_id = iter1->second;auto iter2 = _rooms.find(room_id);if(iter2 == _rooms.end()) {return std::shared_ptr<room>();}return iter2->second;}void remove_room(int room_id){auto room = get_room_by_room_id(room_id);if(room.get() == nullptr) return;int uid1 = room->black_id();int uid2 = room->white_id();std::unique_lock<std::mutex> lock(_mutex);_user_2_room.erase(uid1);_user_2_room.erase(uid2);_rooms.erase(room_id);}void remove_room_user(int user_id){auto room = get_room_by_user_id(user_id);if(room.get() == nullptr) return;room->handle_exit(user_id);if(room->player_count() == 0) {remove_room(room->room_id());}}void player_enter(int uid){_table_handle->enter_game(uid); // 更新用户状态为游戏中notify_friends_status_change(uid, "游戏中");}void player_exit(int uid){_table_handle->exit_game(uid); // 更新用户状态为在线notify_friends_status_change(uid, "在线");}void notify_friends_status_change(int uid, const std::string& status){std::vector<int> friend_ids;_table_handle->select_friend_id_by_id(uid, friend_ids);Json::Value resp;resp["optype"] = "friend_status_change";resp["data"]["id"] = uid;resp["data"]["status"] = status;std::string json_str;json_util::serialize(resp, json_str);for (int friend_id : friend_ids){auto conn = _online_user->get_conn_from_hall(friend_id);if (conn.get()){conn->send(json_str);}}}
private:int _room_counter;// 房间计数器std::mutex _mutex;User_Info_Table *_table_handle; // 数据库句柄online_user_manager *_online_user; // 在线玩家句柄std::unordered_map<int, std::shared_ptr<room>> _rooms;std::unordered_map<int, int> _user_2_room;
};

5.session管理模块

http是一个无状态短链接的协议,它不会保存用户的信息,每一次请求都需要重新建立连接。目前,通过cookie和session技术,来解决http无状态的特性。

而在本项目中,用户登录了之后,跳转到游戏大厅,因为无状态,所以此时还需要重新登录,为了避免这个问题,我们设计session模块,用来为登录的用户创建一个会话,来设置用户id以及会话状态。为了避免会话,被长时间滥用,这里还设置了会话过期自动销毁。

虽然websocket是有状态的,但我们依旧需要会话管理来实现一些合法性检验等操作:

WebSocket 连接本身不会自动携带用户身份信息。通过会话管理,服务器在建立 WebSocket 连接时,可以验证客户端发送的会话 ID(通常通过 HTTP 登录时设置的 Cookie 携带),从而确认该连接是由合法登录的用户发起的。例如,在 user_login 函数中,服务器为登录成功的用户创建会话并设置会话 ID,存储在 Cookie 中返回给客户端。当客户端建立 WebSocket 连接时,服务器可以从请求头中获取 Cookie 并解析出会话 ID,然后通过 session_manager 的 get_session 方法验证会话的有效性。

并且用户可能会在游戏大厅和游戏房间之间进行页面切换,在切换的过程中,websocket就需要重新建立,此时有了session存在,在重新建立的过程中,服务器就可以根据会话id来确认用户身份,并为其提供操作。

#pragma once
#include "online.hpp"
#include <unordered_map>
#define SESSION_FOREVER -1typedef enum{UNLOGIN, LOGIN} ss_state;
class session
{
public:session(int session_id):_session_id(session_id) { debug("%s -> %d", "session已经被创建", _session_id); }~session(){ debug("%s -> %d", "session已经被销毁", _session_id); }int session_id() { return _session_id; }void set_user(int uid) { _user_id = uid; }int get_user() { return _user_id; }void set_state(ss_state state) { _state = state; }bool is_login(){ return (_state == LOGIN); }void set_timer(const ws_server_t::timer_ptr &tp) { _tp = tp; }ws_server_t::timer_ptr &get_timer() { return _tp; }
private:int _session_id;int _user_id;ss_state _state;ws_server_t::timer_ptr _tp;
};class session_manager
{
public:session_manager(ws_server_t *server):_server(server) { debug("%s", "session_manager create!!"); }~session_manager() { debug("%s", "session_manager destroy!!"); }std::shared_ptr<session> create_session(int uid, ss_state state) {std::unique_lock<std::mutex> lock(_mutex);std::shared_ptr<session> s = std::make_shared<session>(_session_count);s->set_state(state);s->set_user(uid);_sessions.emplace(_session_count, s);_session_count++;return s;}void append(const std::shared_ptr<session> &ssp) {std::unique_lock<std::mutex> lock(_mutex);_sessions.emplace(ssp->session_id(), ssp);}std::shared_ptr<session> get_session(int session_id) {std::unique_lock<std::mutex> lock(_mutex);auto iter = _sessions.find(session_id);if(iter == _sessions.end()) { return std::shared_ptr<session>(); }return iter->second;}void remove_session(int session_id) {std::unique_lock<std::mutex> lock(_mutex);if(_sessions.count(session_id) == 0) return ;_sessions.erase(session_id);}void set_expired_time(int session_id, int ms) {std::shared_ptr<session> ssp = get_session(session_id); // 获取session_id对应的sessionif(ssp.get() == nullptr) { return;} /*对应session不存在,直接返回*/ws_server_t::timer_ptr tmp_t = ssp->get_timer();if(tmp_t.get() == nullptr && ms == SESSION_FOREVER) {// 没有定时任务,并且我们要设置为永久存在return;}else if(tmp_t.get() == nullptr && ms != SESSION_FOREVER) {// 没有定时任务,但是我们要设置定时任务ws_server_t::timer_ptr tm = _server->set_timer(ms, std::bind(&session_manager::remove_session, this, session_id));ssp->set_timer(tm);}else if(tmp_t.get() != nullptr && ms == SESSION_FOREVER) {// 存在定时任务,但是我们要将其设置为永久存在// 取消定时任务tmp_t->cancel(); // 取消定时任务会导致该任务被立刻执行,即该session会删除ssp->set_timer(ws_server_t::timer_ptr()); // 给一个空的定时任务,表示永久// 但是取消定时任务这步操作并不会立即被执行,为了避免直接重新添加后,但cancel还没有被执行导致添加失败// 所以这里为了避免重新添加失败,在设置一个定时任务,用来重新添加session_server->set_timer(0, std::bind(&session_manager::append, this, ssp));}else if(tmp_t.get() != nullptr && ms != SESSION_FOREVER) {// 存在定时任务,但是我们要重新设置// 1.取消之前的定时任务tmp_t->cancel();ssp->set_timer(ws_server_t::timer_ptr());_server->set_timer(0, std::bind(&session_manager::append, this, ssp));// 2.重新设置定时任务 ws_server_t::timer_ptr tm = _server->set_timer(ms, std::bind(&session_manager::remove_session, this, ssp->session_id()));ssp->set_timer(tm);}}
private:int _session_count;std::mutex _mutex;std::unordered_map<int, std::shared_ptr<session>> _sessions;ws_server_t *_server;
};

6.匹配模块设计

因为在匹配过程中,受段位影响,相同段位的玩家才会匹配到一块。所以实现匹配的思想非常简单,为不同的段位创建自己的匹配队列,当匹配队列中有2个以上的用户时,就将前两个的用户拿出来,为其创建房间,开始对战。如果没有两个用户则一致阻塞等待。

6.1匹配队列

#pragma once
#include "room.hpp"
#include <list>
#include <thread>
#include <mutex>
#include <condition_variable>template <typename T>
class match_queue
{
public:void push(const T &data){std::unique_lock<std::mutex> lock(_mutex);_list.emplace_back(data);_cond.notify_all();}bool pop(T &data){std::unique_lock<std::mutex> lock(_mutex);if(_list.empty()) return false;data = _list.front();_list.pop_front();return true;}void remove(T &data){std::unique_lock<std::mutex> lock(_mutex);_list.remove(data);}int size(){std::unique_lock<std::mutex> lock(_mutex);return _list.size();}bool empty(){std::unique_lock<std::mutex> lock(_mutex);return _list.empty();}void wait(){std::unique_lock<std::mutex> lock(_mutex);_cond.wait(lock);}
private:std::list<T> _list;std::mutex _mutex;std::condition_variable _cond;
};

6.2匹配管理

class matcher
{
public:matcher(room_manager *rm, User_Info_Table *tb, online_user_manager *on):_room(rm), _table(tb), _online(on){_threads.resize(9);_threads.emplace_back(&matcher::iron_thread_handle, this);_threads.emplace_back(&matcher::bronze_thread_handle, this);_threads.emplace_back(&matcher::silver_thread_handle, this);_threads.emplace_back(&matcher::gold_thread_handle, this);_threads.emplace_back(&matcher::platinum_thread_handle, this);_threads.emplace_back(&matcher::diamond_thread_handle, this);_threads.emplace_back(&matcher::ascendant_thread_handle, this);_threads.emplace_back(&matcher::immortal_thread_handle, this);_threads.emplace_back(&matcher::radiant_thread_handle, this);}~matcher() {for(auto &thread : _threads) {if(thread.joinable()) thread.join();}}bool add(int uid){// 1.根据玩家段位添加到不同的匹配队列中// 利用数据库句柄,根据id查询该用户的段位信息Json::Value user;if(!_table->select_by_id(uid, user)) return false;int ladder = user["ladder"].asInt();// 2.根据段位进行玩家分类switch(ladder) {case 1:_iron.push(uid);break;case 2:_bronze.push(uid);break;case 3:_silver.push(uid);break;case 4:_gold.push(uid);break;case 5:_platinum.push(uid);break;case 6:_diamond.push(uid);break;case 7:_ascendant.push(uid);break;case 8:_immortal.push(uid);break;case 9:_radiant.push(uid);break;default:_iron.push(uid);break;}return true;}bool del(int uid){// 1.根据玩家段位添加到不同的匹配队列中// 利用数据库句柄,根据id查询该用户的段位信息Json::Value user;if(!_table->select_by_id(uid, user)) return false;int ladder = user["ladder"].asInt();// 2.根据段位进行玩家分类switch(ladder) {case 1:_iron.remove(uid);break;case 2:_bronze.remove(uid);break;case 3:_silver.remove(uid);break;case 4:_gold.remove(uid);break;case 5:_platinum.remove(uid);break;case 6:_diamond.remove(uid);break;case 7:_ascendant.remove(uid);break;case 8:_immortal.remove(uid);break;case 9:_radiant.remove(uid);break;}return true;}
private:void thread_handle(match_queue<int>& q){while(1) {// 1.判断队列中是否有两个以上的人,没有则阻塞,有则继续向下判断while(q.size() < 2){ q.wait();}// 2.队列人数大于2,取出前两个int uid1 = 0, uid2 = 0; if(!q.pop(uid1)) { /*debug("uid1获取失败");*/continue; }if(!q.pop(uid2)) {// 如果出第二个时出错了,此时需要将第一个重新添加回队列中add(uid1);continue;}debug("匹配成功:uid1->%d, uid2->%d", uid1, uid2);// 3.都取出来了,判断两个用户连接是否正常auto conn1 = _online->get_conn_from_hall(uid1);if(conn1.get() == nullptr) { /*debug("uid1 conn获取失败");*/ add(uid2); continue; }auto conn2 = _online->get_conn_from_hall(uid2);if(conn2.get() == nullptr) { /*debug("uid2 conn获取失败");*/add(uid1); continue; }// 4.连接都正常,此时给这两个用户创建房间std::shared_ptr<room> rp = _room->create_room(uid1, uid2);if(rp.get() == nullptr) {add(uid1);add(uid2);continue;}//5. 创建房间成功,此时给两个用户发送响应Json::Value resp;std::string json_str;resp["optype"] = "match_success";resp["result"] = true;json_util::serialize(resp, json_str);printf("coun1: %p\n", conn1.get());printf("coun2: %p\n", conn2.get());conn1->send(json_str);conn2->send(json_str);debug("响应已发送 %s",json_str.c_str());}}
private:void iron_thread_handle() { thread_handle(std::ref(_iron)); }void bronze_thread_handle() { thread_handle(std::ref(_bronze)); }void silver_thread_handle() { thread_handle(std::ref(_silver)); }void gold_thread_handle() { thread_handle(std::ref(_gold)); }void platinum_thread_handle() { thread_handle(std::ref(_platinum)); }void diamond_thread_handle() { thread_handle(std::ref(_diamond)); }void ascendant_thread_handle() { thread_handle(std::ref(_ascendant)); }void immortal_thread_handle() { thread_handle(std::ref(_immortal)); }void radiant_thread_handle() { thread_handle(std::ref(_radiant)); }
private:// 不同段位的匹配队列,只有处于同一个段位的人才能匹配到一块match_queue<int> _iron;match_queue<int> _bronze;match_queue<int> _silver;match_queue<int> _gold;match_queue<int> _platinum;match_queue<int> _diamond;match_queue<int> _ascendant;match_queue<int> _immortal;match_queue<int> _radiant;std::vector<std::thread> _threads; // 不同匹配队列的线程room_manager *_room; // 房间管理句柄,用于为匹配成功的两个人创建房间User_Info_Table *_table; // 数据库句柄,需要访问指定用户的信息,才能将其放到指定的队列中online_user_manager *_online; // 房间匹配成功后,需要给两个用户发送匹配成功消息
};

7.服务器模块设计

服务器模块主要是对以上所有的模块进行整合,搭建出一个WebSocket服务器。

  • 管理的数据:
  • web根目录
  • WebSocket服务器
  • session句柄
  • 数据库句柄
  • 在线用户管理句柄
  • 房间管理句柄
  • 匹配管理句柄
  • 超时检测线程:配合心跳机制,检测用户是否断线离开

该服务器显示提供的接口就构造、析构和start。构造用来初始化服务器,设置日志输出,设置回调函数等操作;析构用来回收线程资源;start函数则用来设置监听端口,开始获取新连接,以及启动服务器。

7.1服务器初始化

#pragma once
#include "db.hpp"
#include "online.hpp"
#include "session.hpp"
#include "room.hpp"
#include "matcher.hpp"
#include "util.hpp"
#include <chrono>#define WWWROOT "./wwwroot"
class gobang_server
{
public:gobang_server(const std::string &host, uint16_t port, const std::string &user,const std::string &password, const std::string &dbname, const std::string &webroot = WWWROOT):_webroot(webroot),_table(host, port, user, password, dbname),_room(&_table, &_online),_match(&_room, &_table, &_online),_session(&_wsvr),_check_thread(std::thread(&gobang_server::checkInactiveUsers, this)){_wsvr.set_access_channels(websocketpp::log::alevel::none);_wsvr.init_asio();_wsvr.set_reuse_addr(true);_wsvr.set_http_handler(std::bind(&gobang_server::http_handler, this, std::placeholders::_1));_wsvr.set_open_handler(std::bind(&gobang_server::ws_open_handler, this, std::placeholders::_1));_wsvr.set_close_handler(std::bind(&gobang_server::ws_close_handler, this, std::placeholders::_1));_wsvr.set_message_handler(std::bind(&gobang_server::ws_msg_handler, this, std::placeholders::_1, std::placeholders::_2));// 在程序运行起来之前,将所有的用户的状态的设置为离线_table.set_all_offline();}~gobang_server(){_check_thread.join();}void start(int port){_wsvr.listen(port);_wsvr.start_accept();_wsvr.run();}
private:std::string _webroot; // web资源根目录ws_server_t _wsvr; // websocket服务器session_manager _session; //session句柄User_Info_Table _table; // 数据库句柄online_user_manager _online; // 在线用户句柄room_manager _room; //房间管理句柄matcher _match; // 匹配句柄std::thread _check_thread;
};

7.2实现回调函数

四种回调函数,分别为http请求的回调函数,建立WebSocket长连接后的回调函数,WebSocket长连接断开的回调函数,以及WebSocket长连接请求的回调函数。

对于http请求来说:既有静态资源的获取,也有动态资源的获取。动态资源包括注册,登录,以及获取用户信息等请求,除此之外都是静态请求。静态请求就是返回html文件即可,而对于动态资源其实就是需要与用户进行交互的。我们用客户端请求的资源路径来区分其访问的是动态资源还是静态资源。

对于WebSocket建立的回调函数来说:首先,游戏大厅和游戏房间都是websocket长连接,所以我们要区分客户端访问的是那个资源创建的长连接。

对于长连接的关闭以及收到的请求来说都一样,需要判断是游戏大厅的请求还是游戏房间的请求。

private:// http请求void http_handler(websocketpp::connection_hdl hdl){ws_server_t::connection_ptr conn = _wsvr.get_con_from_hdl(hdl);websocketpp::http::parser::request req = conn->get_request();std::string method = req.get_method();std::string uri = req.get_uri();if(method == "POST" && uri == "/reg") {return user_register(conn); }else if(method == "POST" && uri == "/login") {return user_login(conn);}else if(method == "GET" && uri == "/info") {return user_info(conn);}else {return static_resource(conn);}}// websocket长连接建立之后的处理函数void ws_open_handler(websocketpp::connection_hdl hdl){// 根据uri路径的不同,判断处于哪种长连接ws_server_t::connection_ptr conn = _wsvr.get_con_from_hdl(hdl);websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();if(uri == "/hall") {// 游戏大厅长连接return ws_open_game_hall(conn);}else if(uri == "/room") {// 游戏房间长连接return ws_open_game_room(conn);}}// websocket长连接断开之前的处理函数void ws_close_handler(websocketpp::connection_hdl hdl){// 根据uri路径的不同,判断处于哪种长连接ws_server_t::connection_ptr conn = _wsvr.get_con_from_hdl(hdl);websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();if(uri == "/hall") {// 游戏大厅长连接return ws_close_game_hall(conn);}else if(uri == "/room") {// 游戏房间长连接return ws_close_game_room(conn);}}// websocket长连接请求处理函数void ws_msg_handler(websocketpp::connection_hdl hdl, ws_server_t::message_ptr msg){// 根据uri路径的不同,判断处于哪种长连接ws_server_t::connection_ptr conn = _wsvr.get_con_from_hdl(hdl);websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();if(uri == "/hall") {// 游戏大厅长连接return ws_msg_game_hall(conn, msg);}else if(uri == "/room") {// 游戏房间长连接return ws_msg_game_room(conn, msg);}}

7.3 http请求处理函数

http请求分为静态和动态,而动态又分为登录,注册,获取用户信息这三种。

对于静态资源来说,只需要将该静态资源返回给客户端即可。

void static_resource(ws_server_t::connection_ptr &conn)//静态资源获取{// 1.获取客户端访问的uri资源websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();std::string realpath = _webroot;// 2.判断访问资源if(uri == "/") {// 客户端访问时没有指定访问路径,我们返回主页index.html给客户端realpath = realpath + uri + "index.html";}else {realpath = realpath + uri;}// 3.读取文件内容Json::Value resp;std::string body;bool ret = file_util::read(realpath, body);if(!ret) {// 如果文件读取失败,则返回404页面file_util::read("./wwwroot/404.html", body);conn->set_status(websocketpp::http::status_code::not_found);conn->set_body(body);return;}conn->set_status(websocketpp::http::status_code::ok);conn->set_body(body);}

对于注册请求来说,我们服务器收到的请求报文以及响应如下: 

POST /reg HTTP/1.1
Content-Type: application/json
Content-Length: 32{"username":"xiaobai", "password":"123123"}#成功时的响应
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 15{"result":true}#失败时的响应 
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 43{"result":false, "reason": "⽤⼾名已经被占⽤"}

服务器收到http请求,根据资源路径定位到用户注册请求。用户注册逻辑就是获取正文内容,进行反序列化,接着判断用户名和密码是否为空,不为空则判断用户名是否存在,不存在则新建,存在则返回注册失败。

    void user_register(ws_server_t::connection_ptr &conn){// 用户注册请求功能的处理// 1.获取请求正文std::string json_string = conn->get_request_body();// 2.对请求正文进行反序列化,拿到username和passwordJson::Value register_info;bool ret = json_util::deserialize(json_string, register_info);	if(ret == false) {// 反序列化失败debug("%s", "反序列化失败");return http_response(conn, false, "请求正文格式错误", websocketpp::http::status_code::bad_request);}// 3.进行数据库新增操作先判断用户名密码是否为空if(register_info["username"].isNull() || register_info["password"].isNull()) {debug("%s", "用户名/密码为空");return http_response(conn, false, "请输入用户名/密码", websocketpp::http::status_code::bad_request);}ret = _table.insert(register_info);if(ret == false) {debug("%s", "用户名已经存在");return http_response(conn, false, "用户名已经被占用", websocketpp::http::status_code::bad_request);}// 4.注册用户成功return http_response(conn, true, "注册成功", websocketpp::http::status_code::ok);}

对于用户登录请求,收到的请求报文如下: 

POST /login HTTP/1.1
Content-Type: application/json
Content-Length: 32{"username":"xiaobai", "password":"123123"}#成功时的响应
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 15{"result":true}#失败时的响应 
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 43{"result":false, "reason": "⽤⼾名或密码错误"}

服务器收到登录请求,逻辑与注册类似,判断反序列化,拿出用户名和密码,判断是否为空,不为空则使用数据库句柄查看该用户是否存在,并判断密码是否一致。根据查询结果判断是否登录成功,登录成功后,则为该用户创建会话,设置过期时间以及设置响应报文Set-Cookie:ssid

    void user_login(ws_server_t::connection_ptr &conn){//用户登录请求功能的实现// 1.获取请求正文std::string json_str = conn->get_request_body();// 2.对请求正文进行反序列化,拿出username和passwordJson::Value login_info;bool ret = json_util::deserialize(json_str, login_info);if(ret == false) {debug("%s", "反序列化失败");return http_response(conn, false, "请求正文格式错误", websocketpp::http::status_code::bad_request);}// 3.判断用户名/密码是否为空if(login_info["username"].isNull() || login_info["password"].isNull()) {debug("%s", "用户名/密码为空");return http_response(conn, false, "请输入用户名/密码", websocketpp::http::status_code::bad_request);}// 3.在数据库中进行查询,判断用户名密码是否存在ret = _table.login(login_info);if(ret == false) {debug("%s", "用户名/密码错误");return http_response(conn, false, "用户名/密码错误", websocketpp::http::status_code::bad_request);}// 4.为该用户创建一个seesionint uid = login_info["id"].asInt();std::shared_ptr<session> ssp = _session.create_session(uid, LOGIN);if(ssp.get() == nullptr) {debug("%s", "创建会话失败");return http_response(conn, false, "创建会话失败", websocketpp::http::status_code::bad_request);}// 设置session过期时间_session.set_expired_time(ssp->session_id(), 30000);// 5.响应,并设置cookiestd::string cookie = "SSID=" + std::to_string(ssp->session_id());conn->append_header("Set-Cookie", cookie);return http_response(conn, true, "登录成功", websocketpp::http::status_code::ok);}

 登录成功后,我们需要获取用户信息,以在游戏大厅中显示,获取用户信息的请求报文和响应如下:

GET /userinfo HTTP/1.1
Content-Type: application/json
Content-Length: 0#成功时的响应
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 58{"id":1, "username":"xiaobai", "ladder" : 1, "rank" : 1,"score":1000, "total_count":4, "win_count":2"last_active" : xxx,"status" : "离线"
}#失败时的响应 
HTTP/1.1 401 Unauthorized
Content-Type: application/json
Content-Length: 43{"result":false, "reason": "⽤⼾还未登录"}
    void user_info(ws_server_t::connection_ptr &conn){//获取用户信息请求功能// 1.判断请求中是否携带cookie信息std::string cookie_str = conn->get_request_header("Cookie");if(cookie_str.empty()) {return http_response(conn, false, "找不到cookie信息,请重新登录", websocketpp::http::status_code::bad_request);}// 2.通过cookie中ssid找到对应的sessionstd::string ssid;bool ret = get_cookie_value(cookie_str, "SSID", ssid);if(ret == false) {return http_response(conn, false, "找不到ssid,请重新登录", websocketpp::http::status_code::bad_request);}std::shared_ptr<session> ssp = _session.get_session(std::stoi(ssid));if(ssp.get() == nullptr) {return http_response(conn, false, "登录过期,请重新登录", websocketpp::http::status_code::bad_request);}// 3.从数据库取出该用户的信息int uid = ssp->get_user();Json::Value info;std::string body;ret = _table.select_by_id(uid, info); if(ret == false) {return http_response(conn, false, "找不到用户信息,请重新登录", websocketpp::http::status_code::bad_request);}json_util::serialize(info, body);conn->set_status(websocketpp::http::status_code::ok);conn->set_body(body);conn->append_header("Content-Type", "application/json");// 4.刷新cookie的过期时间_session.set_expired_time(ssp->session_id(), 30000);}

 http响应:因为这个部分多次出现在了上述函数中,所以将其抽离出来

    void http_response(ws_server_t::connection_ptr &conn, bool result, const std::string &reason, websocketpp::http::status_code::value code){std::string body;Json::Value resp;resp["result"] = result;resp["reason"] = reason;json_util::serialize(resp, body);conn->set_status(code);conn->set_body(body);conn->append_header("Content-Type", "application/json");}

7.4游戏大厅长连接处理函数

游戏大厅长脸建立的处理函数:

首先根据请求报文中的cookie信息,获取该用户的session信息,通过该session信息来判断用户是否已经登录,如果没有则将用户信息添加到在线用户管理句柄中,接着将session设置为永久。接着就是发送friend_starus_change响应到前端,让其重新渲染好友列表。

    void ws_open_game_hall(ws_server_t::connection_ptr &conn){Json::Value err_resp;//1-2 获取当前用户的会话信息std::shared_ptr<session> ssp = get_session_by_cookie(conn);// 3.登录成功之后,判断该用户是否重复登录// 判断方法:看在 在线用户模块中 是否已经在该用户id,hall/roomif(_online.is_in_game_hall(ssp->get_user()) || _online.is_in_game_room(ssp->get_user())) {err_resp["optype"] = "hall_ready";err_resp["result"] = false;err_resp["reason"] = "重复登录";return websocket_response(conn, err_resp);}// 4.登录成功,并且没有重复登录,现在将该用户添加到在线用户管理句柄中_online.enter_game_hall(ssp->get_user(), conn);err_resp["optype"] = "hall_ready";err_resp["result"] = true;websocket_response(conn, err_resp);// 5.添加到游戏大厅后,将该用户的session信息更新为永久debug("进入游戏大厅,session被设为永久->%d", ssp->session_id());_session.set_expired_time(ssp->session_id(), SESSION_FOREVER);// 6.通知其所有的好友,我上线了// 填写自己的信息Json::Value up_status, info;up_status["optype"] = "friend_status_change";up_status["result"] = true;up_status["data"] = Json::Value(Json::arrayValue); _table.select_by_id(ssp->get_user(), info);up_status["data"].append(info);// 获取所有好友idstd::vector<int> ids;_table.select_friend_id_by_id(ssp->get_user(), ids);for(auto id : ids) {if(_online.is_in_game_hall(id)) {auto conn = _online.get_conn_from_hall(id);websocket_response(conn, up_status);}                }return;}

游戏大厅长连接关闭的处理函数:

只需要将用户从房间管理中移除,并且将session设置为临时的即可

    void ws_close_game_hall(ws_server_t::connection_ptr &conn){// 1.首先获取当前连接的会话信息std::shared_ptr<session> ssp = get_session_by_cookie(conn);// 2.将该连接对应的用户从在线用户模块中移除// 这里要传入user_id_online.exit_game_hall(ssp->get_user());// 3.重新设置sesion的声明周期debug("退出游戏大厅,session被设为临时->%d", ssp->session_id());_session.set_expired_time(ssp->session_id(), 30000);}

游戏大厅收到消息的处理函数:

收到请求后,我们需要根据请求的类型,来执行不同的操作:

  • match_start:开始匹配请求,添加到匹配队列中即可
  • match_stop:停止匹配请求,从匹配队列中移除
  • get_friend_list:获取好友列表请求,用来渲染游戏大厅的好友列表
  • add_friend:添加好友请求,判断请求的人是否存在,如果存在则转发给他。
  • add_friend_yes:同意添加好友,前端同意之后,发送该请求到服务器,服务器此时建立两者双向的好友关系
  • add_friend_no:拒绝添加好友,前端拒绝之后,服务器收到该请求,给发起方返回对方拒绝响应
  • logout:退出登录请求,用于更新用户的在线状态,同时发送friend_status_change响应,让前端重新渲染好友列表
  • send_challenge:发起对战邀请请求,将该消息转发给目的方
  • challenge_response:邀请对战回复请求,如果同意则为两人创建房间,不同意则返回拒绝响应给发送方
  • heatbeat:心跳检测,前端定时向后端发送心跳包,服务器收到后发送heartbeat_ack响应,更新last_active字段,主要用于客户端因超时断线。
  • 除此之外,其他请求默认不处理,直接响应,result=false
    void ws_msg_game_hall(ws_server_t::connection_ptr &conn, ws_server_t::message_ptr &msg){// 1.获取当前连接的会话信息std::shared_ptr<session> ssp = get_session_by_cookie(conn);if(ssp.get() == nullptr) return;// 2.获取请求std::string req_str = msg->get_payload();Json::Value req_json, resp_json;bool ret = json_util::deserialize(req_str, req_json);if(ret == false) {resp_json["result"] = false;resp_json["reason"] = "请求解析失败";return websocket_response(conn, resp_json);}// 3.对请求进行处理if(!req_json["optype"].isNull() && req_json["optype"].asString() == "match_start") {// 开始匹配请求_match.add(ssp->get_user());resp_json["optype"] = "match_start";resp_json["result"] = true;printf("conn: %p\n", conn.get());return websocket_response(conn, resp_json);}else if(!req_json["optype"].isNull() && req_json["optype"].asString() == "match_stop") {// 停止匹配请求_match.del(ssp->get_user());resp_json["optype"] = "match_stop";resp_json["result"] = true;return websocket_response(conn, resp_json);}else if(!req_json["optype"].isNull() && req_json["optype"].asString() == "get_friend_list") {get_friend_list(ssp->get_user(), resp_json);return websocket_response(conn, resp_json);}else if(!req_json["optype"].isNull() && req_json["optype"].asString() == "add_friend") {// 收到添加好友请求,应该将这条消息转发给被请求方int dst_id = std::stoi(req_json["dst_id"].asString());std::string dst_username = req_json["dst_username"].asString();// 1.先判断该用户是否存在if(_table.is_exists(dst_username)) {// 2.判断用户在大厅中还是在游戏中,还是离线if(_online.is_in_game_hall(dst_id)) {// 在大厅中,直接向该用户发送请求resp_json = req_json;resp_json["result"] = true;ws_server_t::connection_ptr dst_conn = _online.get_conn_from_hall(dst_id);// 获取对方连接return websocket_response(dst_conn, resp_json);}else if(_online.is_in_game_room(dst_id)) {// 正在游戏中 -----TODO}}resp_json["result"] = false;resp_json["reason"] = "The user does not exist";return websocket_response(conn, resp_json);}else if(!req_json["optype"].isNull() && req_json["optype"].asString() == "add_friend_yes") {// 同意添加好友// 调用底层数据库接口,在好友表中,建立信息resp_json["optype"] = "add_friend_yes";resp_json["result"] = true;int dst = std::stoi(req_json["dst_id"].asString());int src = std::stoi(req_json["src_id"].asString());_table.add_friend(dst, src);_table.add_friend(src, dst);auto dst_conn = _online.get_conn_from_hall(dst);auto src_conn = _online.get_conn_from_hall(src);// 给双方都发送关系建立成功的响应websocket_response(dst_conn, resp_json);websocket_response(src_conn, resp_json);// 还是得给客户端发送get_friend_listJson::Value up1, up2;get_friend_list(dst, up1);get_friend_list(src, up2);websocket_response(dst_conn, up1);return websocket_response(src_conn, up2);}else if(!req_json["optype"].isNull() && req_json["optype"].asString() == "add_friend_no") {// 拒绝添加好友resp_json["optype"] = "add_friend_no";resp_json["result"] = true;resp_json["reason"] = "对方拒绝添加你为好友";int src = std::stoi(req_json["src_id"].asString());auto src_conn = _online.get_conn_from_hall(src);return websocket_response(src_conn, resp_json);}else if(!req_json["optype"].isNull() && req_json["optype"].asString() == "logout") {// 退出登录resp_json["optype"] = "logout";resp_json["result"] = true;_table.logout(ssp->get_user());websocket_response(conn, resp_json);// 除了退出登录外,还需要给他的所有所有好友发送离线通知// 填写自己的信息Json::Value up_status, info;up_status["optype"] = "friend_status_change";up_status["result"] = true;up_status["data"] = Json::Value(Json::arrayValue); _table.select_by_id(ssp->get_user(), info);up_status["data"].append(info);// 获取所有好友idstd::vector<int> ids;_table.select_friend_id_by_id(ssp->get_user(), ids);for(auto id : ids) {if(_online.is_in_game_hall(id)) {auto conn = _online.get_conn_from_hall(id);websocket_response(conn, up_status);}                }return ;}else if(!req_json["optype"].isNull() && req_json["optype"].asString() == "send_challenge") {// 将该邀请消息转发给dstresp_json = req_json;resp_json["result"] = true;int dst = std::stoi(req_json["dst_id"].asString());auto dst_conn = _online.get_conn_from_hall(dst);return websocket_response(dst_conn, resp_json);}else if(!req_json["optype"].isNull() && req_json["optype"].asString() == "challenge_response") {resp_json = req_json;resp_json["result"] = true;int dst = std::stoi(req_json["dst_id"].asString());int src = std::stoi(req_json["src_id"].asString());auto src_conn = _online.get_conn_from_hall(src);if(req_json["ans"] == true) {// 如果同意了,则为两个人创建房间_room.create_room(dst, src);}return websocket_response(src_conn, resp_json);}else if(!req_json["optype"].isNull() && req_json["optype"].asString() == "heartbeat") {// 心跳检测resp_json["optype"] = "heartbeat_ack";resp_json["result"] = true;int id = std::stoi(req_json["id"].asString());// 更新最后活跃时间_table.alert_last_active(id);return websocket_response(conn, resp_json);}resp_json["optype"] = "unknow";resp_json["result"] = false;return websocket_response(conn, resp_json);}

 7.5游戏房间长连接处理函数

进入游戏房间,需要重新建立websocket连接,此时进入该处理函数,先判断用户是重复登录,紧接着为它们创建房间,将用户添加到在线房间管理中,重新设置session为永久,并返回响应。

void ws_open_game_room(ws_server_t::connection_ptr &conn){Json::Value err_resp;// 1.获取用户session信息std::shared_ptr<session> ssp = get_session_by_cookie(conn);// 内部已经做了session为空的处理// 2.判断用户是否已经在游戏大厅/游戏房间中,因为如果要加入游戏房间,说明该用户已经从游戏大厅中被移除了// 如果在游戏房间中,就说明该用户已经在进入了房间,不需要重新进入if(_online.is_in_game_hall(ssp->get_user()) || _online.is_in_game_room(ssp->get_user())) {err_resp["optype"] = "room_ready";err_resp["result"] = false;err_resp["reason"] = "重复登录";return websocket_response(conn, err_resp);}// 3.获取该用户的游戏房间信息,因为能进入游戏房间说明已经匹配成功,为两个用户创建好了房间std::shared_ptr<room> room_ptr = _room.get_room_by_user_id(ssp->get_user());if(room_ptr.get() == nullptr) {err_resp["optype"] = "room_ready";err_resp["result"] = false;err_resp["reason"] = "房间信息不存在";return websocket_response(conn, err_resp);}// 4.将该用户添加到在线游戏房间长连接中_online.enter_game_room(ssp->get_user(), conn);// 5.设置session为永久debug("进入游戏房间,session被设为永久->%d", ssp->session_id());_session.set_expired_time(ssp->session_id(), SESSION_FOREVER);_room.player_enter(ssp->get_user()); // 用户进入房间开始游戏// 6.响应err_resp["optype"] = "room_ready";err_resp["result"] = true;err_resp["room_id"] = room_ptr->room_id();err_resp["uid"] = ssp->get_user();err_resp["white_id"] = room_ptr->white_id();err_resp["black_id"] = room_ptr->black_id();return websocket_response(conn, err_resp);}

游戏房间关闭时,调用游戏房间关闭处理函数:将用户从房间中移除,从在线房间中移除,设置状态为在线,session设置为临时。

    void ws_close_game_room(ws_server_t::connection_ptr &conn){// 1.获取会话信息std::shared_ptr<session> ssp = get_session_by_cookie(conn);// 内部已经做了session为空的处理// 2.从在线用户房间中移除_online.exit_game_room(ssp->get_user());// 3.从游戏房间中移除_room.remove_room_user(ssp->get_user());// 4.设置session为定时销毁debug("退出游戏房间,session被设为临时->%d", ssp->session_id());_session.set_expired_time(ssp->session_id(), 30000);_room.player_exit(ssp->get_user()); // 用户进入房间开始游戏}

游戏房间中收到请求后的处理函数,依旧根据请求类型,进行不同的操作:但其实游戏房间中的操作无非两种下棋和聊天。而这两个操作我们当是已经在游戏房间中就已经实现了。所以,我们这里的逻辑就是通过用户id获取房间,然后调用该房间内部的handle_request函数。

    void ws_msg_game_room(ws_server_t::connection_ptr &conn, ws_server_t::message_ptr &msg){// 1.获取会话信息Json::Value err_resp;std::shared_ptr<session> ssp = get_session_by_cookie(conn);// 内部已经做了session为空的处理// 2.获取房间信息std::shared_ptr<room> room_ptr = _room.get_room_by_user_id(ssp->get_user());if(room_ptr.get() == nullptr) {err_resp["optype"] = "unknow";err_resp["result"] = false;err_resp["reason"] = "房间信息不存在";return websocket_response(conn, err_resp);}// 3.对请求进行反序列化Json::Value req_json;std::string payload = msg->get_payload();bool ret = json_util::deserialize(payload, req_json);if(ret == false) {err_resp["optype"] = "unknow";err_resp["result"] = false;err_resp["reason"] = "反序列化请求失败";}// 4.调用房间请求处理函数return room_ptr->handle_request(req_json);}

7.6边缘函数实现

    void http_response(ws_server_t::connection_ptr &conn, bool result, const std::string &reason, websocketpp::http::status_code::value code){std::string body;Json::Value resp;resp["result"] = result;resp["reason"] = reason;json_util::serialize(resp, body);conn->set_status(code);conn->set_body(body);conn->append_header("Content-Type", "application/json");}bool get_cookie_value(const std::string &cookie, const std::string &key, std::string &value){// Cookie:SSID=xxx; username=xxx; password=xxx; // 1.先根据分号空格,将所有的key=value存到一个数组中std::vector<std::string> kv;string_util::split(cookie, "; ", kv);// 2.在这个数组中,再以=作为分隔符,找出指定的key,返回对应的value即可for(auto str : kv) {std::vector<std::string> tmp;string_util::split(str, "=", tmp);if(tmp.size() != 2) continue;if(tmp[0] == key) {value = tmp[1];return true;}}return false;}void websocket_response(ws_server_t::connection_ptr &conn, Json::Value &resp){std::string body;json_util::serialize(resp, body);conn->send(body);}// 通过cookie获取会话信息std::shared_ptr<session> get_session_by_cookie(ws_server_t::connection_ptr &conn){// 1.首先获取当前连接的会话信息// 1.判断请求中是否携带cookie信息Json::Value err_resp;std::string cookie_str = conn->get_request_header("Cookie");if(cookie_str.empty()) {err_resp["optype"] = "hall_ready";err_resp["result"] = false;err_resp["reason"] = "找不到cookie信息,请重新登录";websocket_response(conn, err_resp);return std::shared_ptr<session>();}// 2.通过cookie中ssid找到对应的sessionstd::string ssid;bool ret = get_cookie_value(cookie_str, "SSID", ssid);if(ret == false) {err_resp["optype"] = "hall_ready";err_resp["result"] = false;err_resp["reason"] = "找不到ssid信息,请重新登录";websocket_response(conn, err_resp);return std::shared_ptr<session>();}std::shared_ptr<session> ssp = _session.get_session(std::stoi(ssid));if(ssp.get() == nullptr) {err_resp["optype"] = "hall_ready";err_resp["result"] = false;err_resp["reason"] = "登录过期,请重新登录";websocket_response(conn, err_resp);return std::shared_ptr<session>();}return ssp;}void get_friend_list(int uid, Json::Value &resp_json){// 请求好友列表resp_json["optype"] = "get_friend_list";resp_json["result"] = true;resp_json["data"] = Json::Value(Json::arrayValue); // 获取所有好友idstd::vector<int> ids;_table.select_friend_id_by_id(uid, ids);// 获取所有好友id的具体信息Json::Value info;for(auto id : ids) {_table.select_by_id(id, info);resp_json["data"].append(info);}}

7.8心跳检测

该线程,每隔10s会检测所有在线用户的最后活跃时间,如果最后活跃时间已经超过了60s,则我们判断该用户已经离线了,通知他的好友,并改变其状态为离线。

    void checkInactiveUsers(){while(true) {// 每10秒检查一次std::this_thread::sleep_for(std::chrono::seconds(10));std::mutex _mutex;std::unique_lock<std::mutex> lock(_mutex);// 获取当前时间auto now = std::chrono::system_clock::now();// 遍历所有的status为在线的用户,判断其last_active与当前时间是否大于60s// 如果大于60s则表示该用户已经连接超时,此时将该用户的状态改为离线// 并且通知他的所有好友// 现在的问题在于:怎么遍历所有的大厅用户std::vector<int> hall_usrid = _online.get_hall_usrid();for(auto id : hall_usrid) {if(_table.online(id)) {// 如果在线,则进行超时判断std::string time = _table.last_active(id);auto last = stringToTimePoint(time);auto inactiveTime = std::chrono::duration_cast<std::chrono::seconds>(now - last/*id的last_active*/).count();if(inactiveTime > 60) {_table.logout(id); // 状态改为离线// 通知所有好友// 填写自己的信息Json::Value up_status, info;up_status["optype"] = "friend_status_change";up_status["result"] = true;up_status["data"] = Json::Value(Json::arrayValue); _table.select_by_id(id, info);up_status["data"].append(info);// 获取所有好友idstd::vector<int> ids;_table.select_friend_id_by_id(id, ids);for(auto id : ids) {if(_online.is_in_game_hall(id)) {auto conn = _online.get_conn_from_hall(id);websocket_response(conn, up_status);}                }}}}}}// 将时间戳字符串转换为system_clock::time_pointstd::chrono::time_point<std::chrono::system_clock> stringToTimePoint(const std::string& timestampStr, const std::string& format = "%Y-%m-%d %H:%M:%S") {// 解析字符串到tm结构std::tm tm = {};std::istringstream ss(timestampStr);ss >> std::get_time(&tm, format.c_str());if (ss.fail()) {throw std::invalid_argument("Failed to parse timestamp: " + timestampStr);}// 将tm转换为time_tstd::time_t time = std::mktime(&tm);// 转换为system_clock::time_pointreturn std::chrono::system_clock::from_time_t(time);}

8.客户端开发

8.1主页面

<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>五子棋对战平台</title><style>body {font-family: Arial, sans-serif;background-color: #1e293b;color: #f8fafc;margin: 0;padding: 0;display: flex;flex-direction: column;align-items: center;justify-content: center;height: 100vh;}.container {text-align: center;padding: 20px;}h1 {color: #3b82f6;margin-bottom: 20px;}.buttons {display: flex;flex-direction: column;gap: 15px;width: 100%;max-width: 300px;}.btn {padding: 12px 20px;border: none;border-radius: 8px;font-size: 16px;cursor: pointer;text-decoration: none;display: block;}.login-btn {background-color: #3b82f6;color: white;}.register-btn {background-color: #10b981;color: white;}</style>
</head>
<body><div class="container"><h1>五子棋对战平台</h1><div class="buttons"><a href="login.html" class="btn login-btn">登录</a><a href="register.html" class="btn register-btn">注册</a></div></div>
</body>
</html>

8.2注册页面

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>注册</title><link rel="stylesheet" href="./css/common.css"><link rel="stylesheet" href="./css/login.css">
</head>
<body><div class="nav">网络五子棋对战游戏</div><div class="login-container"><!-- 登录界面的对话框 --><div class="login-dialog"><!-- 提示信息 --><h3>注册</h3><!-- 这个表示一行 --><div class="row"><span>用户名</span><input type="text" id="user_name" name="username"></div><!-- 这是另一行 --><div class="row"><span>密码</span><input type="password" id="password" name="password"></div><!-- 提交按钮 --><div class="row"><button id="submit" onclick="reg()">提交</button></div></div></div> <script src="js/jquery.min.js"></script><script>//1. 给按钮添加点击事件,调用注册函数//2. 封装实现注册函数function reg() {//  1. 获取两个输入框空间中的数据,组织成为一个json串var reg_info = {username: document.getElementById("user_name").value,password: document.getElementById("password").value};console.log(JSON.stringify(reg_info));//  2. 通过ajax向后台发送用户注册请求$.ajax({url : "/reg",type : "post",data : JSON.stringify(reg_info),success : function(res) {if (res.result == false) {//  4. 如果请求失败,则清空两个输入框内容,并提示错误原因document.getElementById("user_name").value = "";document.getElementById("password").value = "";alert(res.reason);}else {//  3. 如果请求成功,则跳转的登录页面alert(res.reason);window.location.assign("/login.html");}},error : function(xhr) {document.getElementById("user_name").value = "";document.getElementById("password").value = "";alert(JSON.stringify(xhr));}})}</script>
</body>
</html>

8.3登录页面

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>登录</title><link rel="stylesheet" href="./css/common.css"><link rel="stylesheet" href="./css/login.css"><style>body {background: linear-gradient(to-br, #1D2129, #121418); /* 与游戏大厅完全一致的背景 */}</style>
</head>
<body><div class="nav">网络五子棋对战游戏</div><div class="login-container"><!-- 登录界面的对话框 --><div class="login-dialog"><!-- 提示信息 --><h3>登录</h3><!-- 这个表示一行 --><div class="row"><span>用户名</span><input type="text" id="user_name"></div><!-- 这是另一行 --><div class="row"><span>密码</span><input type="password" id="password"></div><!-- 提交按钮 --><div class="row"><button id="submit" onclick="login()">提交</button></div></div></div><script src="./js/jquery.min.js"></script><script>//1. 给按钮添加点击事件,调用登录请求函数//2. 封装登录请求函数function login() {//  1. 获取输入框中的用户名和密码,并组织json对象var login_info = {username: document.getElementById("user_name").value,password: document.getElementById("password").value};//  2. 通过ajax向后台发送登录验证请求$.ajax({url: "/login",type: "post",data: JSON.stringify(login_info),success: function(result) {//  3. 如果验证通过,则跳转游戏大厅页面alert("登录成功");window.location.assign("/game_hall.html");},error: function(xhr) {//  4. 如果验证失败,则提示错误信息,并清空输入框alert(JSON.stringify(xhr));document.getElementById("user_name").value = "";document.getElementById("password").value = "";}})}</script>
</body>
</html>

8.4游戏大厅页面

<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>游戏大厅</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css"><script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"></script><script src="https://cdn.tailwindcss.com"></script><link rel="stylesheet" href="./css/game_hall.css"><script>tailwind.config = {theme: {extend: {colors: {primary: '#165DFF',secondary: '#FF7D00',neutral: '#1D2129','iron': '#858D96','bronze': '#CD7F32','silver': '#C0C0C0','gold': '#FFD700','platinum': '#E5E4E2','diamond': '#B9F2FF','超凡': '#FF00FF','神话': '#FF4500','赋能': '#00FF00',},fontFamily: {sans: ['Inter', 'system-ui', 'sans-serif'],},}}}</script><style type="text/tailwindcss">@layer utilities {.shutdown-button {@apply bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-all duration-300 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-red-500/50 flex items-center z-50;}/* 小屏幕设备调整 */@screen sm {.shutdown-button {@apply right-2 py-1 px-3 text-sm;}}.content-auto {content-visibility: auto;}.rank-card {@apply rounded-xl p-6 shadow-lg transition-all duration-300;}.rank-icon {@apply text-4xl mr-2;}.stats-grid {@apply grid grid-cols-1 md:grid-cols-3 gap-4 mt-4;}.stat-card {@apply bg-neutral/50 rounded-lg p-3 text-center;}.match-btn {@apply bg-primary hover:bg-primary/80 text-white font-bold py-3 px-8 rounded-full text-lg shadow-lg transition-all duration-300 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary/50 flex items-center justify-center mx-auto;}.matching-btn {@apply bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-8 rounded-full text-lg shadow-lg transition-all duration-300 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-red-400/50 flex items-center justify-center mx-auto;}.unranked-badge {@apply bg-gray-600 text-white rounded-full px-4 py-2 text-center inline-block;}.online-user-list {@apply fixed right-0 top-0 h-full bg-neutral/90 backdrop-blur-md shadow-2xl w-64 border-l border-gray-700 overflow-y-auto z-40 flex flex-col;}.user-item {@apply flex items-center p-3 hover:bg-gray-800 transition-colors;}.user-avatar {@apply w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center overflow-hidden mr-3 relative;}.status-dot {@apply w-3 h-3 rounded-full absolute bottom-0 right-0 border-2 border-neutral;}.section-header {@apply text-xs font-semibold text-gray-400 uppercase tracking-wider px-3 py-2 bg-gray-800;}.search-box {@apply p-3 border-t border-gray-700;}.search-input {@apply w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary focus:border-transparent;}.search-button {@apply mt-2 w-full bg-primary hover:bg-primary/80 text-white font-medium py-2 px-4 rounded-lg transition-colors;}.friends-scroll-container {@apply flex-1 overflow-y-auto;}@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');:root {--sidebar-width: 16rem; /* 64 */}body {font-family: 'Inter', 'system-ui', sans-serif;background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);}.split-input-container {display: grid;grid-template-columns: 1fr auto 1fr;gap: 0.5rem;align-items: center;}.split-input {width: 100%;background-color: rgba(31, 41, 55, 0.8);border: 1px solid rgba(55, 65, 81, 0.7);border-radius: 0.5rem;padding: 0.625rem 0.75rem;font-size: 0.875rem;color: white;transition: all 0.2s ease;}.split-input:focus {outline: none;border-color: #3b82f6;box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);}.split-divider {color: #9ca3af;font-weight: bold;padding: 0 0.25rem;user-select: none;}.search-button {background-color: #165DFF;color: white;font-weight: 500;padding: 0.5rem 1rem;border-radius: 0.5rem;width: 100%;transition: background-color 0.2s ease;margin-top: 0.75rem;display: flex;justify-content: center;align-items: center;}.search-button:hover {background-color: #0e4cd9;}.online-user-list {width: var(--sidebar-width);position: fixed;right: 0;top: 0;height: 100vh;background: rgba(29, 33, 41, 0.9);backdrop-filter: blur(12px);box-shadow: -4px 0 15px rgba(0, 0, 0, 0.3);border-left: 1px solid rgba(55, 65, 81, 0.7);overflow-y: auto;z-index: 40;display: flex;flex-direction: column;}.user-item {display: flex;align-items: center;padding: 0.75rem;transition: background-color 0.2s;cursor: pointer;}.user-item:hover {background-color: rgba(55, 65, 81, 0.5);}.section-header {font-size: 0.75rem;font-weight: 600;color: #9ca3af;text-transform: uppercase;letter-spacing: 0.05em;padding: 0.5rem 0.75rem;background-color: rgba(31, 41, 55, 0.7);}.search-box {padding: 0.75rem;border-top: 1px solid rgba(55, 65, 81, 0.7);background-color: rgba(29, 33, 41, 0.8);}/* 其他样式保持不变 */.friends-scroll-container {flex: 1;overflow-y: auto;}}</style>
</head><body class="bg-gradient-to-br from-gray-900 to-gray-800 min-h-screen text-white font-sans"><!-- 在线用户列表侧边栏 --><div class="online-user-list"><div class="p-4 border-b border-gray-700"><h3 class="text-lg font-bold flex items-center"><i class="fa fa-users mr-2 text-primary"></i>好友列表 <span id="online-count" class="ml-2 text-sm bg-primary/30 px-2 py-0.5 rounded-full">0</span></h3></div><!-- 好友列表滚动容器 --><div class="friends-scroll-container"><!-- 在线好友部分 --><div class="section-header">在线好友</div><div id="online-friends-container" class="p-2"><!-- 在线好友将通过JS动态生成 --><div class="text-center text-gray-400 py-5"><p>加载中...</p></div></div><!-- 离线好友部分 --><div class="section-header">离线好友</div><div id="offline-friends-container" class="p-2"><!-- 离线好友将通过JS动态生成 --><div class="text-center text-gray-400 py-5"><p>加载中...</p></div></div></div><!-- 移动搜索框到好友列表底部 --><div class="border-t border-gray-700 bg-gray-900/80 backdrop-blur-sm"><div class="section-header">添加好友</div><div class="search-box"><!-- 优化后的两个输入框 --><div class="split-input-container"><input type="text" id="add-friend-username" class="split-input" placeholder="用户名" aria-label="用户名"><span class="split-divider">#</span><input type="text" id="add-friend-id" class="split-input" placeholder="用户ID" aria-label="用户ID"></div><button id="add-friend-button" class="search-button"><i class="fa fa-paper-plane mr-2"></i> 发送好友请求</button><div id="search-result" class="mt-2 text-sm hidden"><!--搜索结果将在这里显示--></div></div></div></div><!-- 导航栏 --><nav class="bg-neutral/80 backdrop-blur-md shadow-lg sticky top-0 z-50 relative"><div class="container mx-auto px-4 py-3 flex justify-center items-center"><div class="text-2xl font-bold text-primary flex items-center"><i class="fa fa-gamepad mr-2"></i><span>网络五子棋对战游戏</span></div></div><!-- 新增:绝对定位的关机按钮 --><button id="shutdown-btn" class="absolute right-4 top-1/2 transform -translate-y-1/2 shutdown-button"><i class="fa fa-power-off mr-1"></i> 退出</button></nav><!-- 主要内容区 --><main class="container mx-auto px-4 py-8 max-w-5xl"><!-- 页面标题 --><div class="text-center mb-10"><h1 class="text-[clamp(1.8rem,5vw,3rem)] font-bold mb-2">游戏大厅</h1><p class="text-gray-400 text-lg">欢迎回来,<span id="username-display" class="text-primary"></span></p></div><!-- 用户信息卡片 --><div class="max-w-3xl mx-auto mb-12"><div id="user-info-card" class="rank-card bg-neutral/50 border border-gray-700"><div class="flex flex-col md:flex-row items-center justify-between"><!-- 用户基本信息 --><div class="flex flex-col md:flex-row items-center mb-6 md:mb-0"><div class="mb-4 md:mb-0 mr-0 md:mr-6 flex-shrink-0"><div id="avatar-container"class="w-20 h-20 rounded-full bg-neutral flex items-center justify-center border-4 relative"><i class="fa fa-user text-4xl text-gray-400"></i><span class="status-dot bg-green-500"></span></div></div><div class="text-center md:text-left"><!-- 用户名后面会通过JS动态添加#ID --><h2 id="display-name" class="text-2xl font-bold mb-1"></h2><div class="flex items-center justify-center md:justify-start mb-2"><span id="win-rate" class="text-gray-300">胜率: 68%</span></div><div id="rank-display"class="flex items-center justify-center md:justify-start text-xl font-semibold"><i class="fa fa-diamond mr-2 text-gold"></i><span>黄金 III 25胜点</span></div></div></div><!-- 统计信息 --><div id="stats-container" class="stats-grid w-full md:w-auto mt-4 md:mt-0"><div class="stat-card"><div class="text-gray-400 text-sm mb-1">总对局</div><div id="total-games" class="text-xl font-bold">128</div></div><div class="stat-card"><div class="text-gray-400 text-sm mb-1">胜场</div><div id="win-games" class="text-xl font-bold text-green-500">87</div></div><div class="stat-card"><div class="text-gray-400 text-sm mb-1">段位积分</div><div id="rank-points" class="text-xl font-bold">1250</div></div></div></div></div><!-- 未参加排位赛提示 --><div id="unranked-message" class="hidden"><div class="bg-neutral/50 rounded-xl p-6 text-center"><div class="text-4xl mb-4"><i class="fa fa-meh-o text-gray-400"></i></div><h3 class="text-xl font-bold mb-2">还没参加排位赛</h3><p class="text-gray-400 mb-4">点击下方按钮开始你的第一场排位赛,展现你的实力!</p><div class="text-sm text-gray-500">完成1场排位赛后,将获得初始段位</div></div></div></div><!-- 匹配区域 --><div class="max-w-lg mx-auto text-center"><div class="mb-8"><h2 class="text-2xl font-bold mb-4">寻找对手</h2><p class="text-gray-400 mb-6">点击下方按钮开始匹配对手,我们将为您寻找实力相当的玩家</p><button id="match-button" class="match-btn"><i class="fa fa-search mr-2"></i>开始匹配</button><div id="matching-status" class="mt-6 hidden"><div class="inline-block p-4 rounded-full bg-neutral/50"><divclass="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4"></div><p class="text-lg">正在寻找对手中...</p><p class="text-gray-400 text-sm mt-1">匹配队列中: <span id="matching-time">0</span> 秒</p></div></div></div><!-- 段位说明 --><div id="rank-guide" class="bg-neutral/30 rounded-xl p-6 mb-8"><h3 class="text-xl font-bold mb-4 flex items-center"><i class="fa fa-info-circle mr-2 text-primary"></i>段位说明</h3><div class="grid grid-cols-3 md:grid-cols-4 gap-4"><div class="text-center"><div class="text-iron mb-1"><i class="fa fa-circle"></i></div><div class="text-sm">黑铁</div></div><div class="text-center"><div class="text-bronze mb-1"><i class="fa fa-circle"></i></div><div class="text-sm">青铜</div></div><div class="text-center"><div class="text-silver mb-1"><i class="fa fa-circle"></i></div><div class="text-sm">白银</div></div><div class="text-center"><div class="text-gold mb-1"><i class="fa fa-diamond"></i></div><div class="text-sm">黄金</div></div><div class="text-center"><div class="text-platinum mb-1"><i class="fa fa-star"></i></div><div class="text-sm">铂金</div></div><div class="text-center"><div class="text-diamond mb-1"><i class="fa fa-certificate"></i></div><div class="text-sm">钻石</div></div><div class="text-center"><div class="text-超凡 mb-1"><i class="fa fa-trophy"></i></div><div class="text-sm">超凡</div></div><div class="text-center"><div class="text-神话 mb-1"><i class="fa fa-diamond"></i></div><div class="text-sm">神话</div></div><div class="text-center col-span-3 md:col-span-1"><div class="text-赋能 mb-1"><i class="fa fa-rocket"></i></div><div class="text-sm">赋能</div></div></div><div class="mt-4 text-sm text-gray-400 text-center"><p>每个段位分为 I-IV 四个小段位,I 为最高小段位</p></div></div></div></main><!-- 页脚 --><footer class="bg-neutral/80 backdrop-blur-md py-6 mt-12"><div class="container mx-auto px-4 text-center text-gray-400 text-sm"><p>© 2025 网络五子棋对战游戏 版权所有</p></div></footer><script src="./js/jquery.min.js"></script><script>// 段位映射表const ladderMap = {1: { name: '黑铁', color: 'iron', icon: 'fa-circle' },2: { name: '青铜', color: 'bronze', icon: 'fa-circle' },3: { name: '白银', color: 'silver', icon: 'fa-circle' },4: { name: '黄金', color: 'gold', icon: 'fa-diamond' },5: { name: '铂金', color: 'platinum', icon: 'fa-star' },6: { name: '钻石', color: 'diamond', icon: 'fa-certificate' },7: { name: '超凡', color: '超凡', icon: 'fa-trophy' },8: { name: '神话', color: '神话', icon: 'fa-diamond' },9: { name: '赋能', color: '赋能', icon: 'fa-rocket' }};// 小段位数字转罗马数字(反转映射)const rankToRoman = {1: 'IV',2: 'III',3: 'II',4: 'I'};// 按钮状态let buttonFlag = "stop";let wsHdl = null;let matchingTimer = null;let matchingSeconds = 0;let currentUserId = null; // 当前用户ID,用于排除自己let currentUsername = null; // 当前用户名let heartbeat_interval = 30000; // 心跳包30s发送一次let heartbeat_timer = 0;// 页面加载完成后执行document.addEventListener('DOMContentLoaded', function () {// 绑定匹配按钮点击事件const matchButton = document.getElementById('match-button');matchButton.addEventListener('click', handleMatchButtonClick);// 绑定添加好友按钮点击事件const addFriendButton = document.getElementById('add-friend-button');addFriendButton.addEventListener('click', handleAddFriend);// 绑定回车键添加好友const addFriendIdInput = document.getElementById('add-friend-id');addFriendIdInput.addEventListener('keyup', function (event) {if (event.key === 'Enter') {handleAddFriend();}});// 用户名输入框Tab键自动跳到ID输入框const addFriendUsernameInput = document.getElementById('add-friend-username');addFriendUsernameInput.addEventListener('keydown', function (event) {if (event.key === 'Tab') {event.preventDefault();document.getElementById('add-friend-id').focus();}});// 绑定关机按钮点击事件const shutdownBtn = document.getElementById('shutdown-btn');shutdownBtn.addEventListener('click', handleShutdown);});// 获取并显示用户信息function getUserInfo() {$.ajax({url: "/info",type: "get",success: function (res) {// 保存当前用户IDcurrentUserId = res.id;currentUsername = res.username;// 计算胜率const winRate = res.total_count > 0 ? Math.round((res.win_count / res.total_count) * 100) : 0;const username = res.username || '玩家';const userDisplay = `${username}#${res.id}`; // 关键:用户名+#ID格式document.getElementById('username-display').textContent = userDisplay;document.getElementById('display-name').textContent = userDisplay; // 这里会在用户信息卡片显示带ID的用户名// 处理未参加排位赛的情况if (res.ladder === 0) {document.getElementById('user-info-card').classList.add('hidden');document.getElementById('unranked-message').classList.remove('hidden');} else {// 正常显示段位信息document.getElementById('user-info-card').classList.remove('hidden');document.getElementById('unranked-message').classList.add('hidden');// 获取段位信息const ladderInfo = ladderMap[res.ladder] || ladderMap[1];const romanRank = rankToRoman[res.rank] || 'I';// 更新页面元素document.getElementById('win-rate').textContent = `胜率: ${winRate}%`;document.getElementById('total-games').textContent = res.total_count;document.getElementById('win-games').textContent = res.win_count;document.getElementById('rank-points').textContent = res.score;// 更新段位显示const rankDisplay = document.getElementById('rank-display');rankDisplay.innerHTML = `<i class="fa ${ladderInfo.icon} mr-2 text-${ladderInfo.color}"></i><span>${ladderInfo.name} ${romanRank} ${res.score}胜点</span>`;// 设置段位徽章颜色document.querySelector('.w-20.h-20.rounded-full.border-4').className += ` border-${ladderInfo.color}`;}// 确保段位说明始终显示document.getElementById('rank-guide').classList.remove('hidden');// 初始化WebSocket连接initWebSocket();},error: function (xhr) {alert(JSON.stringify(xhr));location.replace("/login.html");}});}// 初始化WebSocket连接function initWebSocket() {try {const wsUrl = "ws://" + location.host + "/hall";wsHdl = new WebSocket(wsUrl);wsHdl.onopen = function () {console.log("WebSocket连接已建立");// 连接成功后请求好友列表requestFriendList();// 开始心跳startHeartBeat();};wsHdl.onmessage = function (evt) {console.log(evt.data);const rspJson = JSON.parse(evt.data);if (rspJson.result === false) {alert(evt.data);location.replace("/login.html");return;}if (rspJson["optype"] == "hall_ready") {console.log("游戏大厅连接建立成功!");} else if (rspJson["optype"] == "match_success") {// 对战匹配成功,清除计时器stopMatchingTimer();alert("对战匹配成功,进入游戏房间!");location.replace("/game_room.html");} else if (rspJson["optype"] == "match_start") {console.log("玩家已经加入匹配队列");buttonFlag = "start";updateMatchButton();document.getElementById('matching-status').classList.remove('hidden');//开始计时startMatchingTimer();} else if (rspJson["optype"] == "match_stop") {console.log("玩家已经移除匹配队列");buttonFlag = "stop";updateMatchButton();document.getElementById('matching-status').classList.add('hidden');// 停止计时stopMatchingTimer();} else if (rspJson["optype"] == "get_friend_list") {// 接收好友列表renderFriendList(rspJson.data);} else if (rspJson["optype"] == "friend_status_change") {// 好友状态变化updateFriendStatus(rspJson.data);} else if (rspJson["optype"] == "send_challenge") {challenge(rspJson);} else if (rspJson["optype"] == "challenge_response") {// 处理对战邀请响应handleInviteResponse(rspJson);} else if (rspJson["optype"] == "add_friend") {// 处理添加好友结果console.log("开始处理好友请求");handleAddFriendResult(rspJson);} else if (rspJson["optype"] == "add_friend_yes") {alert("添加好友成功");} else if (rspJson["optype"] == "add_friend_no") {alert(rspJson.reason);} else if (rspJson["optype"] == "logout") {console.log("登出");} else if (rspJson["optype"] == "heartbeat_ack") {// 心跳成功console.log("收到心跳检测");}else {alert("未知请求类型");console.log("收到未知消息:", rspJson);}};wsHdl.onclose = function () {console.log("WebSocket连接已关闭");// 暂停心跳stopHeartBeat();//// 显示连接关闭提示//document.getElementById('online-friends-container').innerHTML = `//    <div class="text-center text-gray-400 py-5">//        <p>连接已断开,正在重连...</p>//    </div>//`;//document.getElementById('offline-friends-container').innerHTML = `//    <div class="text-center text-gray-400 py-5">//        <p>连接已断开,正在重连...</p>//    </div>//`;//// 尝试重连//setTimeout(initWebSocket, 3000);};wsHdl.onerror = function (error) {console.error("WebSocket错误:", error);alert("连接服务器失败,请稍后再试");};} catch (e) {console.error("创建WebSocket连接失败:", e);alert("连接服务器失败,请稍后再试");}}function sendHeartBeat() {// 发送心跳包if (wsHdl && wsHdl.readyState === WebSocket.OPEN) {const reqJson = {optype: "heartbeat",id: currentUserId};wsHdl.send(JSON.stringify(reqJson));console.log("发送心跳包");console.log(JSON.stringify(reqJson));}}function startHeartBeat() {// 开始心跳clearInterval(heartbeat_timer); // 先清除旧的定时器,避免重复heartbeat_timer = setInterval(sendHeartBeat, heartbeat_interval);// 设置定时器,每30秒发送心跳包}function stopHeartBeat() {// 停止心跳clearInterval(heartbeat_timer); // 清除定时器}// 请求好友列表function requestFriendList() {if (wsHdl && wsHdl.readyState === WebSocket.OPEN) {const reqJson = {optype: "get_friend_list"};wsHdl.send(JSON.stringify(reqJson));}}// 渲染好友列表function renderFriendList(friends) {const onlineFriends = friends.filter(friend => friend.status === '在线');const gameFriends = friends.filter(friend => friend.status === '游戏中');const offlineFriends = friends.filter(friend => friend.status === '离线');// 更新在线用户数量document.getElementById('online-count').textContent = `${onlineFriends.length + gameFriends.length}/${friends.length}`;// 渲染在线好友const onlineContainer = document.getElementById('online-friends-container');if (onlineFriends.length === 0 && gameFriends.length === 0) {onlineContainer.innerHTML = `<div class="text-center text-gray-400 py-5"><p>没有在线或游戏中的好友</p></div>`;} else {let html = '';const allOnlineAndGameFriends = [...onlineFriends, ...gameFriends];allOnlineAndGameFriends.forEach(friend => {let rankInfoHtml = '';// 处理段位信息if (friend.ladder === 0) {rankInfoHtml = `<span class="text-xs text-gray-400">未参加定位赛</span>`;} else {const ladderInfo = ladderMap[friend.ladder] || ladderMap[1];const romanRank = rankToRoman[friend.rank] || 'I';rankInfoHtml = `<span class="text-xs text-gray-400">${ladderInfo.name} ${romanRank} ${friend.score}胜点</span>`;}let statusText = '在线';let statusDotClass = 'bg-green-500';let inviteBtnDisabled = false;if (friend.status === '游戏中') {statusText = '游戏中';statusDotClass = 'bg-yellow-500';inviteBtnDisabled = true;}html += `<div class="user-item" data-id="${friend.id}"><div class="user-avatar"><i class="fa fa-user text-xl text-gray-400"></i><span class="status-dot ${statusDotClass}"></span></div><div class="flex-1 min-w-0"><div class="flex items-center justify-between"><h4 class="font-medium text-sm truncate">${friend.username}#${friend.id}</h4><span class="text-xs text-gray-400">${statusText}</span></div><div class="flex items-center justify-between">${rankInfoHtml}<button class="invite-btn ml-2 text-primary text-sm ${inviteBtnDisabled ? 'opacity-50 cursor-not-allowed' : 'opacity-75 hover:opacity-100 transition-opacity'}"${inviteBtnDisabled ? 'disabled' : `onclick="sendChallenge(${friend.id}, '${friend.username}')"`}><i class="fa fa-gamepad"></i> 邀请</button></div></div></div>`;});onlineContainer.innerHTML = html;}// 渲染离线好友const offlineContainer = document.getElementById('offline-friends-container');if (offlineFriends.length === 0) {offlineContainer.innerHTML = `<div class="text-center text-gray-400 py-5"><p>没有离线的好友</p></div>`;} else {let html = '';offlineFriends.forEach(friend => {let rankInfoHtml = '';// 处理段位信息if (friend.ladder === 0) {rankInfoHtml = `<span class="text-xs text-gray-400">未参加定位赛</span>`;} else {const ladderInfo = ladderMap[friend.ladder] || ladderMap[1];const romanRank = rankToRoman[friend.rank] || 'I';rankInfoHtml = `<span class="text-xs text-gray-400">${ladderInfo.name} ${romanRank} ${friend.score}胜点</span>`;}html += `<div class="user-item" data-id="${friend.id}"><div class="user-avatar"><i class="fa fa-user text-xl text-gray-400"></i><span class="status-dot bg-gray-500"></span></div><div class="flex-1 min-w-0"><div class="flex items-center justify-between"><h4 class="font-medium text-sm truncate">${friend.username}#${friend.id}</h4><span class="text-xs text-gray-400">离线</span></div><div class="flex items-center justify-between">${rankInfoHtml}<button class="invite-btn ml-2 text-primary text-sm opacity-50 cursor-not-allowed" disabled><i class="fa fa-gamepad"></i> 邀请</button></div></div></div>`;});offlineContainer.innerHTML = html;}}// 更新好友状态function updateFriendStatus(friend) {// 重新请求好友列表并刷新显示requestFriendList();}// 发送对战邀请function sendChallenge(userId, username) {if (!wsHdl || wsHdl.readyState !== WebSocket.OPEN) {alert("与服务器的连接已断开,请刷新页面重试");return;}if (buttonFlag === "start") {alert("您正在匹配队列中,无法发送邀请");return;}const confirmSend = confirm(`确定要向 ${username} 发送对战邀请吗?`);if (confirmSend) {const reqJson = {optype: "send_challenge",dst_id: userId,src_id: currentUserId,src_username: currentUsername};console.log(JSON.stringify(reqJson));wsHdl.send(JSON.stringify(reqJson));}}// 收到对战邀请function challenge(data) {const who = data.src_username;console.log(data);if (confirm(`${who} 想邀请你进行对战,是否接受?`)) {// 用户点击"确定",接受const reqJson = {optype: "challenge_response",dst_id: data.dst_id,src_id: data.src_id,src_username: data.src_username,ans: true};alert("您接受了对方的对战邀请,进入游戏房间!");wsHdl.send(JSON.stringify(reqJson));location.replace("/game_room.html");} else {// 用户点击"取消",拒绝const reqJson = {optype: "challenge_response",dst_id: data.dst_id,src_id: data.src_id,src_username: data.src_username,ans: false};wsHdl.send(JSON.stringify(reqJson));}}// 处理对战邀请响应function handleInviteResponse(data) {if (data.ans) {// 对方接受邀请,进入游戏房间alert("对方接受了您的对战邀请,进入游戏房间!");location.replace("/game_room.html");} else {// 对方拒绝邀请alert("对方拒绝了您的对战邀请");}}// 处理匹配按钮点击function handleMatchButtonClick() {if (!wsHdl || wsHdl.readyState !== WebSocket.OPEN) {alert("与服务器的连接已断开,请刷新页面重试");return;}if (buttonFlag == "stop") {// 开始匹配const reqJson = {optype: "match_start"};wsHdl.send(JSON.stringify(reqJson));} else {// 停止匹配const reqJson = {optype: "match_stop"};wsHdl.send(JSON.stringify(reqJson));}}// 开始匹配计时器function startMatchingTimer() {// 重置计时matchingSeconds = 0;document.getElementById('matching-time').textContent = matchingSeconds;// 启动计时器,每秒更新一次matchingTimer = setInterval(function () {matchingSeconds++;document.getElementById('matching-time').textContent = matchingSeconds;}, 1000);}// 停止匹配计时器function stopMatchingTimer() {if (matchingTimer) {clearInterval(matchingTimer);matchingTimer = null;matchingSeconds = 0;document.getElementById('matching-time').textContent = matchingSeconds;}}// 更新匹配按钮状态function updateMatchButton() {const matchButton = document.getElementById('match-button');if (buttonFlag == "start") {matchButton.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i>匹配中...点击停止';matchButton.className = 'matching-btn';} else {matchButton.innerHTML = '<i class="fa fa-search mr-2"></i>开始匹配';matchButton.className = 'match-btn';}}// 处理添加好友function handleAddFriend() {// 获取输入框的内容,value表示获取内容,trim表示去除收尾的空格const username = document.getElementById('add-friend-username').value.trim();const userId = document.getElementById('add-friend-id').value.trim();if (!username || !userId) {alert('请输入用户名和ID');return;}if (isNaN(userId)) {alert('用户ID必须是数字');return;}const reqJson = {optype: "add_friend",dst_username: username,dst_id: userId,src_username: currentUsername,src_id: currentUserId};wsHdl.send(JSON.stringify(reqJson));alert('好友请求发送成功');//alert(JSON.stringify(reqJson));// 清空输入框document.getElementById('add-friend-username').value = '';document.getElementById('add-friend-id').value = '';}// 收到添加好友的请求function handleAddFriendResult(resp) {//const {who} = resp;const who = resp.src_username;const id = resp.src_id;console.log(resp);if (confirm(`${who} 想添加你为好友,是否接受?`)) {// 用户点击"确定",接受好友请求acceptFriendRequest(resp);} else {// 用户点击"取消",拒绝好友请求rejectFriendRequest(resp);}}function acceptFriendRequest(resp) {// 确认添加,则发送add_friend_yes,由后端在数据库中建立好友关系const reqJson = {optype: "add_friend_yes",dst_username: resp.dst_username,dst_id: resp.dst_id,src_username: resp.src_username,src_id: resp.src_id}wsHdl.send(JSON.stringify(reqJson));console.log(JSON.stringify(reqJson));}function rejectFriendRequest(resp) {const reqJson = {optype: "add_friend_no",dst_username: resp.dst_username,dst_id: resp.dst_id,src_username: resp.src_username,src_id: resp.src_id}wsHdl.send(JSON.stringify(reqJson));}// 关机按钮处理函数function handleShutdown() {if (confirm('确定要退出游戏大厅吗?')) {// 1. 向服务器发送离线状态通知if (wsHdl && wsHdl.readyState === WebSocket.OPEN) {const offlineMsg = {optype: "logout",user_id: currentUserId,username: currentUsername};// 同步发送离线消息(确保发送完成后再关闭连接)wsHdl.send(JSON.stringify(offlineMsg));// 短暂延迟确保消息发送成功setTimeout(() => {wsHdl.close();}, 2000);}// 2. 关闭当前页面if (window.opener) {// 如果是通过window.open打开的页面,直接关闭window.close();} else {// 普通页面提示手动关闭(部分浏览器限制window.close())//alert('已为您切换至离线状态,请手动关闭本页面');// 可选:跳转到登录页alert('您已退出登录,将为你跳转到首页, 您也可以直接关闭该窗口');location.replace("/");}}}// 页面卸载前关闭WebSocket连接window.onbeforeunload = function () {if (wsHdl) {// 页面卸载前,向后端发送logout信号const offlineMsg = {optype: "logout",user_id: currentUserId,username: currentUsername};// 同步发送离线消息(确保发送完成后再关闭连接)wsHdl.send(JSON.stringify(offlineMsg));// 短暂延迟确保消息发送成功setTimeout(() => {wsHdl.close();}, 2000);wsHdl.close();}stopMatchingTimer();};getUserInfo();</script>
</body></html>

8.5游戏房间页面

<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>五子棋对战房间</title><style>/* 全局样式重置与基础设置 */* {margin: 0;padding: 0;box-sizing: border-box;font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;}body {background-color: #f0f2f5;background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z' fill='%239C92AC' fill-opacity='0.05' fill-rule='evenodd'/%3E%3C/svg%3E");min-height: 100vh;padding-bottom: 50px;}/* 导航栏样式 */.nav {background-color: #2c3e50;color: #ecf0f1;text-align: center;padding: 15px 0;font-size: 1.5rem;font-weight: bold;box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);margin-bottom: 30px;}/* 主容器样式 */.container {display: flex;justify-content: center;align-items: flex-start;gap: 30px;max-width: 1200px;margin: 0 auto;padding: 0 20px;}/* 棋盘区域样式 */#chess_area {display: flex;flex-direction: column;align-items: center;gap: 15px;}#chess {border: 8px solid #8B4513;border-radius: 5px;box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);background-color: #F5DEB3;transition: transform 0.3s ease;}#chess:hover {transform: translateY(-3px);box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);}#screen {background-color: rgba(255, 255, 255, 0.8);padding: 10px 20px;border-radius: 20px;font-size: 1.1rem;font-weight: 500;color: #2c3e50;box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);min-width: 200px;text-align: center;}/* 聊天区域样式 */#chat_area {width: 350px;background-color: white;border-radius: 10px;box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);overflow: hidden;display: flex;flex-direction: column;}#chat_show {height: 400px;padding: 15px;overflow-y: auto;flex-grow: 1;background-color: #f9f9f9;}#chat_show p {margin-bottom: 10px;max-width: 80%;padding: 8px 12px;border-radius: 18px;line-height: 1.4;word-wrap: break-word;}#self_msg {background-color: #dcf8c6;margin-left: auto;text-align: right;}#peer_msg {background-color: #ffffff;border: 1px solid #e0e0e0;margin-right: auto;}#msg_show {display: flex;padding: 10px;border-top: 1px solid #eee;}#chat_input {flex-grow: 1;padding: 10px 15px;border: 1px solid #ddd;border-radius: 20px;outline: none;font-size: 1rem;transition: border-color 0.3s;}#chat_input:focus {border-color: #6495ed;}#chat_button {margin-left: 10px;padding: 0 20px;background-color: #6495ed;color: white;border: none;border-radius: 20px;cursor: pointer;font-size: 1rem;transition: background-color 0.3s, transform 0.2s;}#chat_button:hover {background-color: #4169e1;transform: scale(1.05);}#chat_button:active {transform: scale(0.98);}/* 返回大厅按钮样式 */#chess_area div[onclick] {margin-top: 15px;padding: 8px 20px;background-color: #e74c3c;color: white;border-radius: 20px;cursor: pointer;font-weight: 500;transition: background-color 0.3s;}#chess_area div[onclick]:hover {background-color: #c0392b;}/* 响应式设计 */@media (max-width: 850px) {.container {flex-direction: column;align-items: center;}#chat_area {width: 100%;max-width: 450px;}}@media (max-width: 500px) {#chess {width: 100%;height: auto;max-width: 300px;max-height: 300px;}.nav {font-size: 1.2rem;padding: 12px 0;}#chat_area {width: 90%;}}</style>
</head><body><div class="nav">网络五子棋对战游戏</div><div class="container"><div id="chess_area"><!-- 棋盘区域, 需要基于 canvas 进行实现 --><canvas id="chess" width="450px" height="450px"></canvas><!-- 显示区域 --><div id="screen"> 等待玩家连接中... </div></div><div id="chat_area"><div id="chat_show"><p id="self_msg">你好!</p><p id="peer_msg">你好!</p></div><div id="msg_show"><input type="text" id="chat_input" placeholder="输入消息..."><button id="chat_button">发送</button></div></div></div><script>// 原有JavaScript代码保持不变let chessBoard = [];let BOARD_ROW_AND_COL = 15;let chess = document.getElementById('chess');let context = chess.getContext('2d');//获取chess控件的2d画布var ws_url = "ws://" + location.host + "/room";var ws_hdl = new WebSocket(ws_url);var room_info = null;//用于保存房间信息 var is_me;function initGame() {initBoard();context.strokeStyle = "#5D4037"; // 加深棋盘线条颜色,更清晰// 背景图片 - 可以考虑更换为更贴合棋盘的背景let logo = new Image();logo.src = "image/sky.jpeg";logo.onload = function () {// 绘制图片context.drawImage(logo, 0, 0, 450, 450);// 绘制棋盘drawChessBoard();}}function initBoard() {for (let i = 0; i < BOARD_ROW_AND_COL; i++) {chessBoard[i] = [];for (let j = 0; j < BOARD_ROW_AND_COL; j++) {chessBoard[i][j] = 0;}}}// 绘制棋盘网格线function drawChessBoard() {for (let i = 0; i < BOARD_ROW_AND_COL; i++) {context.moveTo(15 + i * 30, 15);context.lineTo(15 + i * 30, 430); //横向的线条context.stroke();context.moveTo(15, 15 + i * 30);context.lineTo(435, 15 + i * 30); //纵向的线条context.stroke();}// 绘制棋盘上的五个关键点drawDot(3, 3);drawDot(3, 11);drawDot(7, 7);drawDot(11, 3);drawDot(11, 11);}// 绘制棋盘上的天元和星位function drawDot(x, y) {context.beginPath();context.arc(15 + x * 30, 15 + y * 30, 5, 0, 2 * Math.PI);context.closePath();context.fillStyle = "#000";context.fill();}//绘制棋子function oneStep(i, j, isWhite) {if (i < 0 || j < 0) return;context.beginPath();context.arc(15 + i * 30, 15 + j * 30, 13, 0, 2 * Math.PI);context.closePath();var gradient = context.createRadialGradient(15 + i * 30 + 2, 15 + j * 30 - 2, 13, 15 + i * 30 + 2, 15 + j * 30 - 2, 0);// 区分黑白子if (!isWhite) {gradient.addColorStop(0, "#0A0A0A");gradient.addColorStop(1, "#636766");} else {gradient.addColorStop(0, "#D1D1D1");gradient.addColorStop(1, "#F9F9F9");}context.fillStyle = gradient;context.fill();// 棋子阴影效果,增强立体感context.shadowColor = "rgba(0, 0, 0, 0.3)";context.shadowBlur = 5;context.shadowOffsetX = 2;context.shadowOffsetY = 2;}//棋盘区域的点击事件chess.onclick = function (e) {//  1. 获取下棋位置,判断当前下棋操作是否正常//      1. 当前是否轮到自己走棋了//      2. 当前位置是否已经被占用//  2. 向服务器发送走棋请求if (!is_me) {// 替换alert为更友好的提示方式document.getElementById('screen').textContent = "⏳ 等待对方走棋....";return;}let x = e.offsetX;let y = e.offsetY;// 注意, 横坐标是列, 纵坐标是行// 这里是为了让点击操作能够对应到网格线上let col = Math.floor(x / 30);let row = Math.floor(y / 30);if (chessBoard[row][col] != 0) {document.getElementById('screen').textContent = "❌ 当前位置已有棋子!";return;}//向服务器发送走棋请求,收到响应后,再绘制棋子send_chess(row, col);}function send_chess(r, c) {var chess_info = {optype: "put_chess",room_id: room_info.room_id,uid: room_info.uid,row: r,col: c};ws_hdl.send(JSON.stringify(chess_info));console.log("click:" + JSON.stringify(chess_info));}window.onbeforeunload = function () {ws_hdl.close();}ws_hdl.onopen = function () {console.log("房间长连接建立成功");}ws_hdl.onclose = function () {alert("房间长链接断开")console.log("房间长连接断开");}ws_hdl.onerror = function () {console.log("房间长连接出错");}function set_screen(me) {var screen_div = document.getElementById("screen");if (me) {screen_div.innerHTML = "✅ 轮到己方走棋...";} else {screen_div.innerHTML = "⏳ 轮到对方走棋...";}}ws_hdl.onmessage = function (evt) {//alert(evt.data);//1. 在收到room_ready之后进行房间的初始化//  1. 将房间信息保存起来var info = JSON.parse(evt.data);console.log(JSON.stringify(info));if (info.optype == "room_ready") {room_info = info;is_me = room_info.uid == room_info.white_id ? true : false;set_screen(is_me);initGame();} else if (info.optype == "put_chess") {console.log("put_chess" + evt.data);//2. 走棋操作//  3. 收到走棋消息,进行棋子绘制if (info.result == false) {document.getElementById('screen').textContent = "❌ " + info.reason;return;}//当前走棋的用户id,与我自己的用户id相同,就是我自己走棋,走棋之后,就轮到对方了is_me = info.uid == room_info.uid ? false : true;//绘制棋子的颜色,应该根据当前下棋角色的颜色确定isWhite = info.uid == room_info.white_id ? true : false;//绘制棋子if (info.row != -1 && info.col != -1) {oneStep(info.col, info.row, isWhite);//设置棋盘信息chessBoard[info.row][info.col] = 1;}//是否有胜利者if (info.winner == 0) {set_screen(is_me);return;}var screen_div = document.getElementById("screen");if (room_info.uid == info.winner) {screen_div.innerHTML = "🎉 " + info.reason;} else {screen_div.innerHTML = "💔 很遗憾,你输了";}var chess_area_div = document.getElementById("chess_area");var button_div = document.createElement("div");button_div.innerHTML = "返回大厅";button_div.onclick = function () {ws_hdl.close();location.replace("/game_hall.html");}chess_area_div.appendChild(button_div);} else if (info.optype == "chat") {//收到一条消息,判断result,如果为true则渲染一条消息到显示框中if (info.result == false) {alert(info.reason);return;}var msg_div = document.createElement("p");msg_div.innerHTML = info.message;if (info.uid == room_info.uid) {msg_div.setAttribute("id", "self_msg");} else {msg_div.setAttribute("id", "peer_msg");}var msg_show_div = document.getElementById("chat_show");msg_show_div.appendChild(msg_div);// 自动滚动到底部msg_show_div.scrollTop = msg_show_div.scrollHeight;document.getElementById("chat_input").value = "";}}//3. 聊天动作//  1. 捕捉聊天输入框消息//  2. 给发送按钮添加点击事件,点击俺就的时候,获取到输入框消息,发送给服务器var cb_div = document.getElementById("chat_button");cb_div.onclick = function () {var input = document.getElementById("chat_input");var message = input.value.trim();if (!message) return; // 空消息不发送var send_msg = {optype: "chat",room_id: room_info.room_id,uid: room_info.uid,message: message};ws_hdl.send(JSON.stringify(send_msg));}// 支持回车键发送消息document.getElementById("chat_input").addEventListener("keypress", function (e) {if (e.key === "Enter") {document.getElementById("chat_button").click();}});</script>
</body></html>

9.守护进程化 

让程序在后台持续运行,不受终端关闭的影响,并且在系统启动时可以自动启动。守护进程化要求该进程不能是进程组的组长。

#pragma once #include <iostream>
#include <string>
#include <signal.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>const std::string cwd = "/";
const std::string dev = "/dev/null";// 让网络服务器守护进程化
// 让启动的进程,独立成为一个进程组,并且占有独立的会话
// 守护进程化要求该进程不能是进程组的组长
void Daemon(int nochdir, int noclose)
{// 1. 守护进程忽略IO,以及子进程退出等相关信号signal(SIGPIPE, SIG_IGN);signal(SIGCHLD, SIG_IGN);// 2.必须得是一个进程组的组员,不能是组长if(fork() > 0) exit(0);// 下面就是子进程,也就是组员进程// 2.5 守护进程话setsid();// 3.守护进程执行时需要将其当前的工作目录改为/根目录if(nochdir == 0) chdir(cwd.c_str());// 4.守护进程本质上是一个孤儿进程,后台进程// 要避免其使用0,1,2与终端交互// 所以,将0,1,2重定向到/dev/null文件,该文件会将写入的内容丢弃,从该文件读,会读到nullptrif(noclose == 0){int fd = open(dev.c_str(), O_RDWR); // 以读写方式打开// 重定向// int dup2(int oldfd, int newfd); // dup2会使用oldfd,覆盖newfddup2(fd, 0);dup2(fd, 1);dup2(fd, 2);close(fd);}
}
#include <iostream>
#include "server.hpp"#define HOST "127.0.0.1"
#define PORT 3306
#define USER "root"
#define PASSWORD "xiasicheng"int main()
{// 守护进程化Daemon(0, 0);gobang_server gobang(HOST, PORT, USER, PASSWORD, "gobang");gobang.start(8080);return 0;
}

五.项目总结

在本项目中,涉及到多种技术协调配合,在实现过程中,遇到了很多问题:

  1. 点击匹配,匹配成功的玩家不会进入游戏房间,原因是在添加用户到在线管理模块时,误使用了session id
  2. 每次服务器启动,同意玩家只能进行一次游戏,无法进行第二次匹配。问题在于:获取连接出错,从游戏大厅出来时并没有将连接从游戏大厅管理中删除。

在本项目中,我了解到了WebSocket协议以及MySQL、json、gdb等的基本使用。了解了网络开发的基本流程,并且简单了解了怎么调式多线程以及网络代码。

http://www.lryc.cn/news/598353.html

相关文章:

  • pyarmor加密源代码
  • 闲庭信步使用图像验证平台加速FPGA的开发:第三十三课——车牌识别的FPGA实现(5)车牌字符的识别
  • OpenCV —— contours_matrix_()_[]
  • 删除排序数组中的重复项
  • 微服务的编程测评系统6-管理员登录前端-前端路由优化
  • 一文说清楚Hive中常用的聚合函数[collect_list]
  • 亿级流量短剧平台架构演进:高并发场景下的微服务设计与性能调优
  • Matplotlib详细教程(基础介绍,参数调整,绘图教程)
  • IO密集型、CPU密集型、负载、负载均衡
  • 校园英语杂志《校园英语》杂志社校园英语编辑部2025年第15期目录
  • 考研初试专业分146!上岸新疆大学!信号与系统考研经验,通信考研小马哥。
  • GitHub Actions打包容器,推送 AWS ECR 并使 EKS 自动拉取以完成发版部署
  • Redis数据类型与内部编码
  • Webpack配置原理
  • MongoDB 和 Elasticsearch(ES)区别
  • Windows 下配置 GPU 用于深度学习(PyTorch)的完整流程
  • matrix-breakout-2-morpheus靶场通过
  • 基于深度学习的胸部 X 光图像肺炎分类系统(二)
  • 小架构step系列24:功能模块
  • Android中compileSdk,minSdk,targetSdk的含义和区别
  • M3295NL专为千兆以太网设计,支持100/1000Mbps全双工通信M3295支持4对5类UTP电缆
  • SparkSQL 子查询 IN/NOT IN 对 NULL 值的处理
  • 数据结构 堆(3)---堆排序
  • 在 Windows 上安装设置 MongoDB及常见问题
  • 多源信息融合智能投资【“图神经网络+强化学习“的融合架构】【低配显卡正常运行】
  • 如何清理电脑c盘内存 详细操作步骤
  • dify 变量聚合器-聚合分组问题
  • 【Java工程师面试全攻略】Day12:系统安全与高可用设计
  • 再生基因总结
  • 腾势N9再进化:智能加buff,豪华更对味