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

C++标准项目---在线五子棋对战

1. 项目介绍

这个项目是一个网页版的在线五子棋对战游戏。

玩家完成注册登录并点击“开始匹配”之后,系统会根据玩家的积分,自动为玩家匹配同一分段的玩家进行实时对战与聊天

对局结束之后,根据对局结果修改玩家的积分。

该项目的核心是后端开发,前端主要是编写与玩家及服务器进行交互的逻辑,涉及到的知识并不复杂,即使对前端一窍不通也可尝试跟着这篇文章进行必要知识的学习(博主其实也不太会前端)。

项目演示当中的前端页面是由deepseek对页面样式进行了优化后的效果。

如果你是在是懒得做前端,也可以把博主源码中的前端页面拿来用,能结合下文给出的协议看懂就行。

1.1 项目演示

 目前你就可以试试访问:http://110.41.32.137:8888/

  1. 首页:用户在浏览器搜索框内输入服务器的ip地址与端口号之后,会跳转到游戏的首页。
  2. 登录页面:点击“开始游戏”,自动跳转到登录页面。
  3. 注册页面:如果还没有账号,可以跳转到注册页面进行注册。
  4. 游戏大厅页面:这里会显示玩家的信息,玩家可以在这个页面进行匹配。两位玩家匹配成功会自动跳转到游戏房间页面。
  5. 游戏房间页面:两位玩家可以在这里对弈并聊天,若有玩家中途退出,则另一方胜利。

注:这里的棋盘背景图是《大爱仙尊》中的商心慈,来自b站up主:Osot-酒保。

1.2 项目架构

1.3 项目源码

https://gitee.com/da-guan-mu-lao-sheng/linux-c/tree/master/%E5%9C%A8%E7%BA%BF%E4%BA%94%E5%AD%90%E6%A3%8B%E5%AF%B9%E6%88%98/source_2

2. 项目的前期工作

2.1 依赖的库

2.1.1 WebSocket++

WebSocket++ 是一个轻量级、跨平台的 C++ 库,核心用途是帮助开发者在应用中快速实现WebSocket 协议的客户端与服务器端通信,无需手动处理协议细节。

安装方式:

  1. 更新软件源:
    sudo apt update
  2. 安装 WebSocket++ 核心包(含头文件):
    sudo apt install libwebsocketpp-dev
  3. (可选)安装依赖(Boost/OpenSSL):
    # 安装 Boost(异步 IO 必需)
    sudo apt install libboost-system-dev libboost-thread-dev
    # 安装 OpenSSL(TLS 加密必需)
    sudo apt install libssl-dev

该项目要用到的相关头文件有两个:

#include <websocketpp/server.hpp>
#include <websocketpp/config/asio_no_tls.hpp>

在编译时可不带额外的链接选项。

2.1.1.1 引入该库的原因

WebSocket 协议和 HTTP 协议都是应用层的协议,也都是用于网页服务。那么,我们为什么需要 WebSocket 协议呢?

  • Http 协议:是 “请求 - 响应” 的短连接 / 半双工协议。即,服务端不能主动给客户端发送消息
  • WebSocket 协议:WebSocket 是 “双向实时” 的长连接 / 全双工协议。即,服务端也可以主动给客户端发送消息

在该项目中,有两处地方需要用到 WebSocket 协议的全双工特特性:

  1. 游戏大厅,客户端发起开始匹配的请求之后,服务端需要在匹配成功时主动通知客户端。
  2. 游戏房间当中,用户做出的任何成功的行为(落子,聊天,中途退出等)都需要同步到另一个用户的网页上,此时服务器就需要主动通知另一个用户的客户端。
2.1.1.2 如何建立 WebSocket 连接

显然,要建立连接,首先就要绕开连接进行通信。所以,在建立 WebSocket 连接之前,我们需要先建立 HTTP 连接,然后再将 HTTP 连接切换为WebSocket连接

因此,建立 WebSocket 连接的核心是 “客户端发起 HTTP 协议升级请求 + 服务器验证响应”。无论哪种场景,连接建立的核心步骤一致,可概括为 3 步:

  1. 客户端初始化连接:指定 WebSocket 服务地址(ws:// 未加密 / wss:// 加密),发起 HTTP 升级请求。核心的请求头部有:
    Upgrade: websocket	        核心:告知服务器 “请求升级到 WebSocket 协议”
    Connection: Upgrade	        辅助:明确表示 “这是一个协议升级请求”(HTTP/1.1 要求必须配合 Upgrade)
    Sec-WebSocket-Key	        随机字符串(客户端生成),用于服务器验证(避免误升级,非加密用途)
    Sec-WebSocket-Version: 13	声明 WebSocket 版本(主流是 13,对应 RFC 6455,服务器需兼容该版本)
  2. 服务器验证响应:校验请求合法性(方法、版本、升级头),返回 101 状态码确认协议切换。核心的响应头部有:
    HTTP/1.1 101 Switching Protocols	状态码:告知客户端 “协议切换成功”
    Upgrade: websocket	                确认升级到 WebSocket 协议(与请求头一致)
    Connection: Upgrade	                确认是协议升级(与请求头一致)
    Sec-WebSocket-Accept	            服务器对 Sec-WebSocket-Key 的编码结果(客户端需校验该值,防止伪造)
  3. 连接成功:TCP 连接转为 WebSocket 连接,双方可双向发送消息(触发 onopen 回调)。

切换的关键是:复用初始 TCP 连接,通过 HTTP 头告知双方 “要切换到 WebSocket 协议”,验证通过后即切换完成。

整个过程不建立新的 TCP 连接,仅改变连接的 “协议规则”,从而避免 HTTP 反复握手的开销,实现低延迟双向通信。

2.1.1.3 使用方式

我们在客户端使用的是JavaScript,我们可以使用浏览器原生 WebSocket API,其用法与WebSocketpp相似。

2.1.1.3.1 JavaScript
  1. 实例化客户端对象:
    var ws_hdl = new WebSocket("ws://" + 服务端ip地址 + 要请求的资源);
  2. 设置客户端的回调函数:
    // 建立WebSocket连接时的回调函数(略)
    function ws_onopen();
    // 关闭WebSocket连接时的回调函数(略)
    function ws_onclose();
    // 连接出现错误时的回调函数(略)
    function ws_onerror();
    // 收到消息时的回调函数(核心)
    function ws_onmessage(evt);// 注: evt是服务器发来的消息,也叫做事件ws_hdl.onopen = ws_onopen;
    ws_hdl.onclose = ws_onclose;
    ws_hdl.onerror = ws_onerror;
    ws_hdl.onmessage = ws_onmessage;
2.1.1.3.2 C++
  1. 实例化服务器对象并初始化:
    websocketpp::server<websocketpp::config::asio_tls_server> _wssrv;
    // 设置日志等级
    _wssrv.set_access_channels(websocketpp::log::alevel::none);
    // 初始化asio调度器
    _wssrv.init_asio();
    _wssrv.set_reuse_addr(true);
  2. 设置服务器的回调函数:
    // http请求回调处理函数
    void HttpCallback(websocketpp::connection_hdl hdl);
    // websocket握手成功回调处理函数,客户端发起连接后,该函数就会被调用
    void OpenCallback(websocketpp::connection_hdl hdl);
    // websocket连接关闭回调处理函数
    void CloseCallback(websocketpp::connection_hdl hdl);
    // websocket消息回调处理函数
    void MessageCallback(websocketpp::connection_hdl hdl, ws_server_t::message_ptr msg)// 注: hdl是客户端句柄,msg是客户端发来的消息_wssrv.set_http_handler(HttpCallback);
    _wssrv.set_open_handler(OpenCallback);
    _wssrv.set_close_handler(CloseCallback);
    _wssrv.set_message_handler(MessageCallback);
  3. 启动服务器:

    // 开始监听
    _wssrv.listen(port);
    // 开始接受新连接
    _wssrv.start_accept();
    // 启动服务器
    _wssrv.run();

2.1.2 jsoncpp

JsonCpp 是一款用于C++ 语言的开源 JSON 数据解析与生成库,能帮助开发者在 C++ 项目中便捷地处理 JSON 格式数据。

安装方式:

# 更新软件源
sudo apt update
# 安装 JsonCpp 开发包(包含头文件和库文件)
sudo apt install libjsoncpp-dev

该项目所需头文件:

#include <jsoncpp/json/json.h>

编译时需要带上链接选项:

-ljsoncpp
序列化与反序列化

客户端与服务器之间使用Json格式的字符串交换结构化数据。

jsoncpp的具体使用方法这里就不多介绍了,我们要使用该库实现两个接口。在解析数据时需要反序列化,在发送数据时需要序列化

class Util_Json
{
public:// 序列化static bool Serialize(const Json::Value &root, std::string &json_str){Json::StreamWriterBuilder swb;// 启用UTF-8直接输出(不转义非ASCII字符)swb.settings_["emitUTF8"] = true;std::unique_ptr<Json::StreamWriter> sw(swb.newStreamWriter());std::stringstream ss;int ret = sw->write(root, &ss);if (ret != 0){LOG(LogLevel::ERROR) << "序列化失败!";return false;}json_str = ss.str();return true;}// 反序列化static bool Deserialize(const std::string &json_str, Json::Value &root){Json::CharReaderBuilder crb;std::unique_ptr<Json::CharReader> cr(crb.newCharReader());std::string err;bool ret = cr->parse(json_str.c_str(), json_str.c_str() + json_str.size(), &root, &err);if (!ret){LOG(LogLevel::ERROR) << "反序列化失败: " << err;return false;}return true;}
};

2.1.3 mysqlclient

详见:https://the-old.blog.csdn.net/article/details/152927880?spm=1011.2415.3001.5331

封装MySqlClient
#ifndef __MY_MYSQL_H__
#define __MY_MYSQL_H__
#include <mysql/mysql.h>
#include <string>
#include <stdexcept>
#include <memory>
#include <mutex>
#include "Log.hpp"
using namespace LogModule;// 自定义异常类
class MySqlClientException : public std::runtime_error
{
public:explicit MySqlClientException(const std::string &message): std::runtime_error("MySqlClientException: " + message) {}
};class MySqlResult
{
public:MySqlResult(MYSQL_RES *res = nullptr): _res(res){}size_t RowNum() const{if (!_res)throw MySqlClientException("MySqlResult::RowNum: 非法的结果集!");return mysql_num_rows(_res);}size_t FieldNum() const{if (!_res)throw MySqlClientException("MySqlResult::FieldNum: 非法的结果集!");return mysql_num_fields(_res);}MYSQL_ROW NextRow(){if (!_res)return nullptr; // 避免无效访问return mysql_fetch_row(_res);}MYSQL_FIELD *NextField(){if (!_res)return nullptr; // 避免无效访问return mysql_fetch_field(_res);}~MySqlResult(){if (_res != nullptr){mysql_free_result(_res);_res = nullptr;}}// 禁用拷贝构造和赋值运算符MySqlResult(const MySqlResult &) = delete;MySqlResult &operator=(const MySqlResult &) = delete;// 支持移动语义MySqlResult(MySqlResult &&other) noexcept : _res(other._res){other._res = nullptr;}MySqlResult &operator=(MySqlResult &&other) noexcept{if (this != &other){_res = other._res;other._res = nullptr;}return *this;}private:MYSQL_RES *_res = nullptr;
};class MySqlClient
{
public:MySqlClient(const std::string &host,const std::string &user,const std::string &passwd,const std::string &db,unsigned int port = 3306): _mysql(mysql_init(nullptr)){if (!_mysql){throw MySqlClientException("mysql句柄初始化失败!");}// 处理空字符串参数const char *host_ptr = host.empty() ? nullptr : host.c_str();const char *db_ptr = db.empty() ? nullptr : db.c_str();if (!mysql_real_connect(_mysql, host_ptr, user.c_str(), passwd.c_str(),db_ptr, port, nullptr, 0)){std::string err = mysql_error(_mysql);mysql_close(_mysql); // 连接失败时释放已初始化的_mysql_mysql = nullptr;throw MySqlClientException("连接到mysql服务器失败: " + err);}// 使用utf8mb4支持完整UTF-8if (mysql_set_character_set(_mysql, "utf8mb4") != 0){std::string err = mysql_error(_mysql);mysql_close(_mysql);_mysql = nullptr;throw MySqlClientException("设置字符集失败: " + err);}}// 执行SQL,返回:<影响行数(或结果集行数), 结果集(若有)>std::pair<size_t, std::shared_ptr<MySqlResult>> Query(const std::string &sql){if (!_mysql)throw MySqlClientException("Query: 非法的mysql客户端句柄!");// 查询的过程上锁,避免结果被其他线程的查询结果覆盖std::unique_lock<std::mutex> lockguard(_mtx);// 执行SQL,检查是否失败int query_ret = mysql_query(_mysql, sql.c_str());if (query_ret != 0){LOG(LogLevel::ERROR) << "Query: [" << sql << "]查询失败! " << mysql_error(_mysql);return std::make_pair<size_t, std::shared_ptr<MySqlResult>>(0, nullptr);}std::pair<size_t, std::shared_ptr<MySqlResult>> ret;// 判断是否有结果集(如SELECT、SHOW等)if (mysql_field_count(_mysql) != 0){MYSQL_RES *res = mysql_store_result(_mysql);if (!res){// 若预期有结果集但获取失败(如内存不足)throw MySqlClientException("获取结果集失败: " + std::string(mysql_error(_mysql)));}ret.second = std::make_shared<MySqlResult>(res);ret.first = ret.second->RowNum(); // 结果集行数}else{// 无结果集,返回影响行数ret.first = static_cast<size_t>(mysql_affected_rows(_mysql));ret.second = nullptr;}return ret;}~MySqlClient(){if (_mysql != nullptr) // 仅在有效时关闭{mysql_close(_mysql);_mysql = nullptr;}}// 禁用拷贝构造和赋值运算符MySqlClient(const MySqlClient &) = delete;MySqlClient &operator=(const MySqlClient &) = delete;// 支持移动语义(可选)MySqlClient(MySqlClient &&other) noexcept : _mysql(other._mysql){other._mysql = nullptr;}MySqlClient &operator=(MySqlClient &&other) noexcept{if (this != &other){_mysql = other._mysql;other._mysql = nullptr;}return *this;}private:MYSQL *_mysql = nullptr;std::mutex _mtx;
};#endif

2.1.4 sodium

Sodium 是一款 现代、安全、易用的加密库,源于著名的 NaCl(Networking and Cryptography library),核心目标是让开发者无需深入密码学细节,就能快速实现安全可靠的加密功能。

它封装了一系列经过密码学验证的强算法,避免了传统加密库(如 OpenSSL)API 复杂、易误用(如密钥长度错误、IV 生成不当)的问题,被誉为 “加密领域的瑞士军刀”。

安装方式:

# 更新软件源
sudo apt update
# 安装 Sodium 开发包(含头文件、静态库、动态库)
sudo apt install libsodium-dev

所需头文件:

#include <sodium.h>

编译时需要带上链接选项:

-lsodium

由于现在MySQL已经不支持PASSWD了,所以我们使用该库对用户的密码进行哈希与验证。

封装Util_Hash
class Util_Hash
{
public:static bool Init(){if (sodium_init() < 0){LOG(LogLevel::ERROR) << "libsodium初始化失败!";return false;}return true;}// 生成密码哈希(存储到数据库)static std::string Password(const std::string &password){// 哈希结果缓冲区(包含盐值和哈希值,可直接存储)std::vector<char> hash(crypto_pwhash_STRBYTES);// 加密参数(libsodium推荐的安全值,可根据性能调整)const unsigned long long opslimit = crypto_pwhash_OPSLIMIT_MODERATE; // 计算次数const size_t memlimit = crypto_pwhash_MEMLIMIT_MODERATE;             // 内存限制// 生成哈希(自动生成随机盐值,并存入hash中)if (crypto_pwhash_str(hash.data(),password.c_str(),password.size(),opslimit,memlimit) != 0){throw std::runtime_error("密码哈希失败(可能内存不足)");}return std::string(hash.data());}// 验证密码(对比用户输入与存储的哈希)static bool Verify(const std::string &password, const std::string &stored_hash){// 验证哈希是否匹配return crypto_pwhash_str_verify(stored_hash.c_str(),password.c_str(),password.size()) == 0;}
};

2.2 其他工具类

除了Util_Json和Util_Hash以外,我们还需要如下两个工具类:

  • Util_String:包含一个方法Split,用于将字符串按照指定的分隔符进行切分,在处理Cookie字段时用到。
    class Util_String
    {
    public:// 字符串分割static size_t Split(const std::string &str, const std::string &sep, std::vector<std::string> &res){res.clear(); // 清空原有内容,确保结果仅包含本次分割的元素if (sep.empty()){return 0;}const size_t sep_len = sep.size();const size_t str_len = str.size();size_t index = 0;while (index < str_len){size_t pos = str.find(sep, index);// 未找到分隔符,截取剩余部分if (pos == std::string::npos){res.emplace_back(str.substr(index));break;}// 当前位置就是分隔符,跳过if (pos == index){index += sep_len;continue;}// 截取[index, pos)区间的子串res.emplace_back(str.substr(index, pos - index));index = pos + sep_len;}return res.size();}
    };

2.3 前端的简单介绍

2.3.1 前端开发的核心技术(3 大基础)

这是前端开发的 “基石”,所有复杂界面和交互都基于这三者:

  • HTML(结构):相当于网页的 “骨架”,用来搭建页面的基础结构,比如标题、按钮、图片、文字段落等,决定页面有哪些内容。
  • CSS(样式):相当于网页的 “皮肤”,负责美化页面,比如设置颜色、字体、布局(比如左右分栏)、动画效果(比如按钮 hover 时变色),决定页面好不好看。
  • JavaScript(交互):相当于网页的 “大脑”,负责实现页面的动态交互,比如点击按钮弹出弹窗、下拉加载更多内容、表单提交验证,决定页面能不能 “动起来”。
<!-- HTML:定义按钮结构 -->
<button id="myBtn">点击我</button><!-- CSS:美化按钮样式, 在style标签中可嵌入css代码 -->
<style>
<!-- #ByBtn表示对id为myBtn的元素进行修饰 -->
#myBtn {background: blue;color: white;padding: 8px 16px;border: none;
}
</style><!-- JavaScript:实现按钮点击交互, 在script标签中可嵌入JavaScript代码 -->
<script>
// 设置点击事件, 等号后面是一个lambda表达式, 也可用已定义的函数
document.getElementById("myBtn").onclick = function() {alert("按钮被点击了!");
};
</script>

能看懂,在不符合需求时知道怎么改就行。

2.3.2 前端如何发起HTTP请求

在该项目当中,我们主要使用AJAX来帮助我们发起HTTP请求并处理响应。

AJAX(Asynchronous JavaScript and XML,异步 JavaScript 和 XML)是一种前端技术方案,核心作用是:在不刷新整个网页的前提下,与服务器异步交换数据并更新页面部分内容

简单说:传统网页要更新数据必须刷新整个页面(比如提交表单后跳转),而 AJAX 能实现 “局部刷新”(比如点击 “加载更多” 只加载新内容、搜索框实时联想提示),大幅提升用户体验。

使用方式也很简单,在某个按钮的点击事件当中内嵌上如下结构即可:

function onclick() {// ...// 使用ajax进行HTTP请求$.ajax({url: 要请求的资源,type: HTTP方法,data: 正文部分,success: function (res) {// 请求成功时的处理(响应状态码==ok)// ...},error: function (xhr) {// 请求失败时的处理(响应状态码!=ok)// ...}})// ...
}

2.4 数据库用户表的设计

2.4.1 用户表

-- 创建数据库
DROP DATABASE IF EXISTS online_Gomoku;
CREATE DATABASE IF NOT EXISTS online_Gomoku;
USE online_Gomoku;-- 创建用户表
CREATE TABLE IF NOT EXISTS user(id INT PRIMARY KEY AUTO_INCREMENT,    // 用户idname VARCHAR(21) UNIQUE KEY NOT NULL, // 用户名passwd VARCHAR(128) NOT NULL,         // 密码points INT UNSIGNED,                  // 积分total_count INT UNSIGNED,             // 总场次计数win_count INT UNSIGNED                // 胜场计数
);

2.4.2 对用户表进行操作

我们设计一个Table_User类来帮助我们进行表查询:

#ifndef __MY_DATABASE_H__
#define __MY_DATABASE_H__
#include "Log.hpp"
#include "MySqlClient.hpp"
#include <jsoncpp/json/json.h>
#include <cassert>using namespace LogModule;class Table_User
{
private:template<class ...ARGS>std::string MakeSql(const std::string format, ARGS... args){char sql[4096];sprintf(sql, format.c_str(), args...);return sql;}
public:Table_User(const std::string &host, const std::string &user, const std::string &passwd, const std::string &db): _mysql(host, user, passwd, db){// 初始化sodium库assert(Util_Hash::Init());}// 将用户信息注册到用户表bool Register(const std::string& name, const std::string& passwd);// 验证用户登录信息bool LoginCheck(Json::Value& login_info);// 根据用户名查找用户信息bool SelectById(const id_t id, Json::Value& user);// 根据用户id查找用户信息bool SelectByName(const std::string& name, Json::Value& user);// 计数胜利: 总场次++ 胜利场次++ 积分+30bool CountWin(const id_t id);// 计数失败bool CountLose(const id_t id);~Table_User(){}
private:MySqlClient _mysql;
};#endif

这些函数的内部逻辑都相似,就是定义一个查询语句的模板,然后使用MakeSql将参数带入并调用MySqlClient::Query,例如SelectById的实现:

// 根据用户名查找用户信息
bool SelectById(const id_t id, Json::Value &user)
{static const std::string select_by_id = "SELECT id, name, passwd, points, total_count, win_count FROM user WHERE id=%u;";std::string sql = MakeSql(select_by_id, id);auto ret = _mysql.Query(sql);if (ret.first == 0){LOG(LogLevel::ERROR) << "Table_User::SelectById: 用户id[%u]不存在!", id;return false;}MYSQL_ROW row = ret.second->NextRow();user["id"] = std::stoi(row[0]);user["name"] = row[1];user["passwd"] = row[2];user["points"] = std::stoi(row[3]);user["total_count"] = std::stoi(row[4]);user["win_count"] = std::stoi(row[5]);return true;
}

2.5 协议的定义

2.5.1 HTTP

客户端使用AJAX发送请求,直接通过状态码判断是否成功,result存放执行结果的提示。

后端通过HTTP回调函数处理客户端发来的消息,处理客户端的请求并设置状态码等。

注意:为了好看,下面正文部分Json串中的字段名都没有带引号,实际的Json串中字段名是有引号的。

2.5.1.1 注册

(1)客户端请求

url: "/register"
type: "POST"
data: 
{name: 用户名,passwd: 密码
}

(2)服务端响应

status: ok
Content-Type: "application/json"
data: 
{result: "注册成功"
}
2.5.1.2 登录

(1)客户端请求

url: "/login"
type: "POST"
data: 
{name: 用户名,passwd: 密码
}

(2)服务器响应

status: ok
Content-Type: "application/json"
Set-Cookie: "SSID=用户会话id"
data: 
{result: "登录成功"
}
2.5.1.3 获取用户信息

(1)客户端请求

url: "/info"
type: "GET"

这里不需要其他信息是因为用户已经登录成功,在发送到服务器的请求当中都会带上设置好的Cookie,而Cookie中的信息是用户的会话id。

根据这个id,我们就可以在会话管理模块找到用户的会话与用户id。

(2)服务器响应

status: ok
Content-Type: "application/json"
data: 
{// user表的全部字段// ...
}
2.5.1.4 失败的响应
status: 具体的状态码
Content-Type: "application/json"
data:
{result: 具体的错误原因
}

2.5.2 WebSocket

与 HTTP 协议不同,WebSocket在连接建立之后,报头采用二进制的极简报头。并且,我们无需关心或设置报头(API接口会自动设置),只需要设置正文部分即可

但是,如此一来会出现两个问题:

  1. 请求报头中不包含URI,需要在正文部分给出该请求的目的;
  2. 响应报头中不包含状态码,我们无法据此直接判断执行结果;
  3. 请求与响应不是一一对应的关系了,客户端不知道某个响应到底对应哪个/什么请求(所有响应的处理都集中在消息回调函数中处理)。

为了解决这一问题,我们在响应中新增一个字段"op_type",表示这是什么操作对应的请求/响应;当请求失败时,我们就将这个字段设置为"error",方便客户端统一处理。

除此之外,WebSocket还有一个特点,那就是当页面关闭或切换时当前页面的WebSocket连接就会关闭(因为WebSocket连接在代码层面上是一个页面内的对象)。

所以,我们每次进入游戏大厅或游戏房间时,都需要重新建立WebSocket连接。

2.5.2.1 建立游戏大厅WebSocket连接

(1)客户端请求

// 客户端实例化WebSocket对象时,自动向服务器发起切换协议的请求
ws_hdl = new WebSocket(ws_url);

(2)服务器响应

{op_type: "hall_ready",result: "游戏大厅长连接建立成功!"
}
2.5.2.2 建立游戏房间WebSocket连接

(1)客户端请求

ws_hdl = new WebSocket(ws_url);

(2)服务器响应

{op_type: "room_ready",room_id: 房间id,uid: 用户id,white_id: 白棋棋手id,black_id: 黑棋棋手id,white_name: 白棋棋手用户名,black_name: 黑棋棋手用户名,white_points: 白棋棋手积分,black_points: 黑棋棋手积分
}
2.5.2.3 游戏大厅
2.5.2.3.1 开始匹配

(1)客户端请求

{op_type: "match_start"
}

(2)服务器响应

{op_type: "match_start",result: "已开始匹配"
}
2.5.2.3.2 取消匹配

(1)客户端请求

{op_type: "match_stop"
}

(2)服务器响应

{op_type: "match_stop",result: "已取消匹配"
}
2.5.2.3.3 匹配成功

(1)客户端请求:无需请求。

(2)服务端响应

{op_type: "match_success"
}
2.5.2.4 游戏房间

游戏房间内的消息发送需要同步到两个客户端(两个玩家),所以请求与响应当中都包含用户id(uid),以确保客户端在收到消息时,能判断该操作是否是自己做的。

房间id(room_id)主要是一个验证作用,确保消息没有被错误转发。

2.5.2.4.1 聊天

(1)客户端请求

{op_type: "chat",room_id: 房间iduid: 用户id,message: 要发送的消息
}

(2)服务器响应:将请求正文原封不动地发送给两个客户端。

2.5.2.4.2 落子

(1)客户端请求

{op_type: "chess",room_id: 房间id,uid: 用户id,row: 行,col: 列
};

(2)服务器响应:将请求正文原封不动地发送给两个客户端。

2.5.2.4.3 游戏结束

(1)客户端请求:无需请求。

(2)服务器响应

{op_type: "game_over",winner: 赢家id,result: 胜利原因
}

2.5.3 Protocal.hpp

由于HTTP响应的很多信息都包含在响应头部,所以就不额外封装了,直接在HTTP回调函数中处理。

但是WebSocket响应的所有信息都是在Json串中定义的,因此我们可以进行一下简单封装:

class Response
{
public:// 落子请求的响应static Json::Value ChessSuccess(const Json::Value &req){// 请求中的信息都保留,包括操作类型"chess",下棋位置,用户id等Json::Value resq = req;return resq;}// 游戏结束的响应static Json::Value GameOver(id_t winner, const std::string &result){Json::Value resq;resq["op_type"] = "game_over";resq["winner"] = winner;resq["result"] = result;return resq;}// 聊天消息响应static Json::Value ChatSuccess(const Json::Value &req){// 包含op_type,uid,messageJson::Value resq = req;return resq;}// 一般WebSocket请求的响应消息static Json::Value WebSocket(const std::string &op_type, const std::string &result){Json::Value resq;resq["op_type"] = op_type;resq["result"] = result;return resq;}// 房间准备好的消息static Json::Value RoomReady(id_t uid, id_t room_id, id_t white_id, id_t black_id, Table_User* tb){Json::Value resq;resq["op_type"] = "room_ready";resq["room_id"] = room_id;resq["uid"] = uid;resq["white_id"] = white_id;resq["black_id"] = black_id;Json::Value tmp;tb->SelectById(white_id, tmp);resq["white_name"] = tmp["name"];resq["white_points"] = tmp["points"];tb->SelectById(black_id, tmp);resq["black_name"] = tmp["name"];resq["black_points"] = tmp["points"];return resq;}// 匹配成功的消息static Json::Value MatchSuccess(){Json::Value resq;resq["op_type"] = "match_success";return resq;}
};

3. 项目核心模块

3.1 在线用户管理

该模块定义了两个映射关系,分别存放游戏大厅/房间中玩家的连接:

std::unordered_map<id_t, ws_server_t::connection_ptr> _game_hall; // 游戏大厅
std::unordered_map<id_t, ws_server_t::connection_ptr> _game_room; // 游戏房间

该模块的主要作用有两个:

  1. 判断玩家状态:检查玩家是否在游戏大厅/房间中
  2. 获取指定用户的连接:在房间管理模块,很多消息需要同步给房间内的所有人,这时就需要通过该模块来查找连接了。
class OnlineManager
{
public:// 进入游戏大厅/房间void EnterGameHall(id_t uid, ws_server_t::connection_ptr conn);void EnterGameRoom(id_t uid, ws_server_t::connection_ptr conn);// 退出游戏大厅/房间void ExitGameHall(id_t uid);void ExitGameRoom(id_t uid);// 判断是否在大厅/房间内bool IsInGameHall(id_t uid);bool IsInGameRoom(id_t uid);// 从游戏大厅/房间获取WebSocket连接ws_server_t::connection_ptr GetConFromHall(id_t uid);ws_server_t::connection_ptr GetConFromRoom(id_t uid);private:std::unordered_map<id_t, ws_server_t::connection_ptr> _game_hall; // 游戏大厅std::unordered_map<id_t, ws_server_t::connection_ptr> _game_room; // 游戏房间std::mutex _mtx;
};

3.2 会话管理

为了识别用户的登录状态,我们需要设计一个会话管理模块。

当用户登录成功之后,我们需要为用户创建一个会话,并将会话id作为Cookie返回给客户端。

会话当中包含了用户的uid与一个websocketpp的计时器,用于定时销毁会话

class Session
{
public:// // Session状态// enum SessionStatus// {//     LoggedOut = 0,//     LoggedIn = 1// };// 网站的所有操作都是在登录的前提下完成的,所以决定把状态去掉Session(id_t ssid, id_t uid) : _ssid(ssid), _uid(uid){LOG(LogLevel::INFO) << "用户[uid=" << _uid << "]的会话创建成功!";}void SetUser(id_t uid) { _uid = uid; }id_t GetUser() { return _uid; }id_t GetId() { return _ssid; }void SetTimer(const ws_server_t::timer_ptr &tp) { _tp = tp; }ws_server_t::timer_ptr &GetTimer() { return _tp; }~Session(){LOG(LogLevel::INFO) << "用户[uid=" << _uid << "]的会话即将销毁!";}private:id_t _ssid; // 会话idid_t _uid;  // 该会话所属用户的uidws_server_t::timer_ptr _tp;
};

我们可以通过websocketpp::server<websocketpp::config::asio_tls_server>对象(也就是服务器句柄)的set_timer方法获取某个设置了定时任务的定时器。

当定时器的计时归零时,定时任务就会触发。我们在会话管理当中,可以设置定时删除某个会话的定时器,并交由对应的会话进行保管。会话在什么时候该删除呢?在未建立WebSocket连接的情况下长时间无操作,就需要删除会话。

也就是说,在用户离开游戏大厅/房间之后,到再次进入游戏大厅/房间之前,会话需要设置定时删除。

class SessionManager
{
public:SessionManager(ws_server_t *server): _server(server){LOG(LogLevel::INFO) << "会话管理器创建成功!";}// 新建会话SessionPtr CreateSession(id_t uid);// 添加会话void AppendSession(const SessionPtr &ssp);// 获取会话SessionPtr GetSession(id_t ssid);static const int TIMEOUT = 30000; // 默认删除时间static const int FOREVER = -1; // 永不删除// 为某个会话设置定时删除void SetSessionExpireTime(id_t ssid, int ms);// 删除某个会话void RemoveSession(id_t ssid);~SessionManager(){LOG(LogLevel::INFO) << "会话管理器即将销毁!";}private:id_t _next_ssid = 0;std::mutex _mtx;std::unordered_map<id_t, SessionPtr> _ssid_sp;ws_server_t *_server;
};

设置定时器使用的是服务器句柄的set_timer方法,但是这个接口不能实现设置永久不触发(因为这没有任何意义)

要实现永久不触发的办法就是不设置定时器,但是已经设置了的话该怎么办呢?

这种情况下,只能调用定时器的cancel方法来取消定时器。但是这个接口实在是太老实了,真的只取消了定时器,而任务没被取消。这就导致定时任务会被立即触发

所以,我们在取消掉原本的定时器之后,还需要将被删除的会话重新添加到会话集合(_ssid_sp)中。

这里,将会话添加回去的方法也有考究。定时任务和当前程序不是一个执行流的,这就意味着定时任务是立即触发,而不是立即被执行。

假如在调用cancel之后立即将会话添加回去的话,添加会话时其可能还未被删除。

所以,正确的做法就是在调用cancel之后,设置一个用于添加会话的立即执行的定时器

这样,在被触发任务队列当中,添加会话的任务就排在删除会话的任务之后,一定后执行了。

static const int TIMEOUT = 30000;static const int FOREVER = -1;void SetSessionExpireTime(id_t ssid, int ms){// 依赖于websocketpp的定时器来管理会话的生命周期// 登录之后,在SESSION_TIMEOUT时间内无通信则删除会话// 进入游戏大厅或房间之后,会话永久不过期// 退出游戏大厅或房间之后,会话会话需要设置过期时间SessionPtr ssp = GetSession(ssid);if (ssp.get() == nullptr){return;}ws_server_t::timer_ptr tp = ssp->GetTimer();if (tp.get() == nullptr && ms == FOREVER){// 1.为永久会话设置永久,无意义,直接返回return;}else if (tp.get() == nullptr && ms != FOREVER){// 2.为永久会话设置指定时间后删除ws_server_t::timer_ptr tp = _server->set_timer(ms, std::bind(&SessionManager::RemoveSession, this, ssid));ssp->SetTimer(tp);}else if (tp.get() != nullptr && ms == FOREVER){// 3.为临时会话设置永久// 该函数实际上是立即触发回调tp->cancel();// 将目标会话的定时器指针置空ssp->SetTimer(ws_server_t::timer_ptr());// tp->cancel()立即触发但不立即执行,直接将ssp添加回去可能被后执行的回调函数删除// 所以应该为添加ssp的任务设置一个立即触发的定时器,使其加入到回调函数的执行队列中,确保在删除后再添加_server->set_timer(0, std::bind(&SessionManager::AppendSession, this, ssp));}else{// 4.为临时会话重新设置定时器// 清理旧定时器(同3)tp->cancel();_server->set_timer(0, std::bind(&SessionManager::AppendSession, this, ssp));// 设置新定时器(同2)ws_server_t::timer_ptr tp = _server->set_timer(ms, std::bind(&SessionManager::RemoveSession, this, ssid));ssp->SetTimer(tp);}}

 3.3 匹配管理

两个玩家如何完成匹配呢?很简单,设计一个匹配队列:

  1. 当玩家发起开始匹配的请求时,就将玩家加入到匹配队列当中;
  2. 当队列当中的玩家数量>=2时,就取出两个玩家,为他们创建房间并告诉他们匹配成功。
// 匹配队列
template <typename T>
class MatchQueue
{
public:size_t Size(){std::unique_lock<std::mutex> lockguard(_mtx);return _list.size();}bool Empty(){std::unique_lock<std::mutex> lockguard(_mtx);return _list.empty();}void Push(const T &data){std::unique_lock<std::mutex> lockguard(_mtx);_list.emplace_back(data);if (_list.size() >= 2){_cond.notify_all();}}void Pop(T &data1, T &data2){std::unique_lock<std::mutex> lockguard(_mtx);while (_list.size() < 2){_cond.wait(lockguard);}data1 = _list.front();_list.pop_front();data2 = _list.front();_list.pop_front();}void Remove(const T &data){std::unique_lock<std::mutex> lockguard(_mtx);_list.remove(data);}private:std::mutex _mtx;std::list<T> _list; // 不直接使用queue,因为涉及到对队列中间元素的删除// 一个线程阻塞等待// 另一个线程向MatchQueue中放入数据,当元素个数>=2时唤醒第一个线程取出数据完成匹配std::condition_variable _cond; // 用于阻塞等待队列中的元素个数>=2
};

我们可以额外开一个线程来专门完成匹配这件事,以减小WebSocket服务器的压力。

当然,我们也可以设计多个匹配队列来处理不同分段的玩家匹配,并为每一个匹配队列专门设置一个线程

template <size_t N> // 匹配队列的个数
class Matcher
{
private:// 线程入口函数void ThreadEntry(uint32_t i){while (true){// 从匹配队列取出两个玩家id_t player1, player2;_queues[i].Pop(player1, player2);// 检查玩家是否掉线ws_server_t::connection_ptr conn1 = _om->GetConFromHall(player1);if (conn1.get() == nullptr){Add(player2);continue;}ws_server_t::connection_ptr conn2 = _om->GetConFromHall(player2);if (conn2.get() == nullptr){Add(player1);continue;}// 为两个玩家创建游戏房间RoomPtr room = _rm->CreateRoom(player1, player2);// LOG(LogLevel::DEBUG) << "为两位玩家创建房间: " << player1 << " " << player2;if (room.get() == nullptr){Add(player1);Add(player2);continue;}// 匹配成功,回复玩家Json::Value resp = Response::MatchSuccess();std::string body;Util_Json::Serialize(resp, body);conn1->send(body);conn2->send(body);}}public:Matcher(RoomManager *rm, OnlineManager *om, Table_User *tb): _queues(N), _rm(rm), _om(om), _tb(tb){_threads.reserve(N);for (int i = 0; i < N; i++){_threads.push_back(std::thread(&Matcher::ThreadEntry, this, i));}LOG(LogLevel::INFO) << "匹配管理模块创建成功";}// 将用户添加到匹配队列bool Add(id_t uid){Json::Value user;bool ret = _tb->SelectById(uid, user);if (!ret){LOG(LogLevel::ERROR) << "玩家[id=" << uid << "]信息查询失败";return false;}// 计算玩家属于哪一个匹配队列uint32_t points = std::max(base_points, user["points"].asUInt());uint32_t pos = std::min(N - 1, (size_t)(points - base_points) / gap_points);_queues[pos].Push(uid);return true;}// 将用户移出匹配队列bool Delete(id_t uid){Json::Value user;bool ret = _tb->SelectById(uid, user);if (!ret){LOG(LogLevel::ERROR) << "玩家[id=" << uid << "]信息查询失败";return false;}uint32_t points = std::max(base_points, user["points"].asUInt());uint32_t pos = std::min(N - 1, (size_t)(points - base_points) / gap_points);_queues[pos].Remove(uid);return true;}~Matcher(){for (auto &thread : _threads){thread.join();}LOG(LogLevel::INFO) << "匹配管理模块即将销毁";}private:// 不同段位的匹配队列std::vector<MatchQueue<id_t>> _queues;// 处理不同段位匹配的线程std::vector<std::thread> _threads;// 基准分数static const uint32_t base_points = 1200;// 段位间隔分数static const uint32_t gap_points = 600;RoomManager *_rm;OnlineManager *_om;Table_User *_tb;
};template <size_t N>
const uint32_t Matcher<N>::base_points;template <size_t N>
const uint32_t Matcher<N>::gap_points;

3.4 房间管理

3.4.1 棋盘状态与玩家id

显然,我们会用二维数组表示棋盘,但是棋盘的内容呢?我们可以定一个单格状态枚举量:

// 棋盘单格状态
enum BoardPattern
{SPACE = 0,WHITE = 1,BLACK = 2
};

我希望能将白棋与白棋棋手绑定,黑棋与黑棋棋手绑定,于是我们可以使用一个包含三个元素的数组来存储两位玩家:

std::vector<id_t> _player(3); // 1:白棋用户id - 2:黑棋用户id

为什么不把WHITEBLACK设置为0和1呢?这样数组不就可以少一个元素了吗?

这是因为我觉得0和!0是有质变的区别的(false/true),将SPACE设置为0而另外两个设置为!0也许会有什么好处,于是我就这样做了。

3.4.2 请求处理

针对于房间的请求,我们全部放到房间内来处理。首先需要开放给调用者一个同一的函数接口,然后再进行内部路由:

// 处理请求
void RequestHandler(const Json::Value &req)
{// 检查请求房间号与当前房间号是否匹配if (req["room_id"].asUInt() != _id){return Broadcast(Response::WebSocket("error", "房间号错误"));}std::string op_type = req["op_type"].asString();std::string body;if (op_type == "chess"){Json::Value resq = Chess(req);// 判断落子之后是否有玩家胜出if (resq["op_type"].asString() == "chess"){Broadcast(resq);BoardPattern winner;// 检查是否有玩家胜出if (winner = CheckWin(req["row"].asUInt(), req["col"].asUInt())){// 有玩家胜出,进行结算并追加一条游戏结束的消息GameSettlement(winner);Broadcast(Response::GameOver(_player[winner], "五子连珠"));}}}else if (op_type == "chat"){Broadcast(Chat(req));}else if (op_type == "exit"){id_t uid = req["uid"].asUInt();Broadcast(Exit(uid));}else{// 未知操作类型,什么也不做}
}

3.4.3 Room的查找

在实现RoomManager,我们定义了如下两个映射关系:

std::unordered_map<id_t, RoomPtr> _rid_room; // 房间id - 房间指针
std::unordered_map<id_t, id_t> _uid_rid;     // 用户id - 房间id

一个是根据房间id查找房间指针,一个是根据用户id查找房间id。这样一来,我们既可以通过房间id查找房间,也可以通过用户id查找房间。

那么,为什么不直接设计一个用户id到房间指针的映射呢?

这是因为,我们这里的房间指针本质上是智能指针

using RoomPtr = std::shared_ptr<Room>;

这样设计,就使得每个房间只有一个引用,我们要销毁掉一个房间就变得很方便。

3.5 服务器

前面各种模块都是为了这一阶段服务的。这一阶段的工作实际上并不复杂,总结起来就两点:

  1. 初始化WebSocket服务器;
  2. 填充WebSocket服务器句柄的四个回调函数。

为了简化代码,我么定义了如下两个类型:

// WebSocket服务器句柄
using ws_server_t = websocketpp::server<websocketpp::config::asio>;
// HTTP状态码枚举类型
using http_status_t = websocketpp::http::status_code::value;

3.5.1 hdl的使用方式

当然,这里指的是服务器回调函数的参数。WebSocket服务器的前四个回调函数的参数都包含hdl,这个实际上就是客户端的句柄。

那么我们如何用它获取请求并进行响应呢?

3.5.1.1 请求处理
// 获取连接
ws_server_t::connection_ptr conn = _wssrv.get_con_from_hdl(hdl);
// 获取请求
websocketpp::http::parser::request req = conn->get_request();
// 获取HTTP请求方法(只有HTTP回调函数应该调用)
std::string method = req.get_method();
// 获取uri
std::string uri = req.get_uri();
3.5.1.2 回复报文

(1)HTTP

// 设置响应状态码
// 成功设置ok,客户端请求有问题设置bad_request,服务器出问题设置internal_server_error
conn->set_status(http_status_t::ok);
// 添加头部字段
conn->append_header(字段名字符串, 字段值);
// 设置正文
conn->set_body(正文字符串);

(2)WebSocket

conn->send(body);

3.5.2 msg的使用方式

当然,这个msg指的是消息回调函数中的第二个参数。

我们如何使用msg获得消息(请求正文)呢?

std::string req_body = msg->get_payload();

其他的还请参考源码,这个模块的编写逻辑很清晰,也很好看懂,源码中注释也很清晰。

但是介绍起来的话就又没完没了了。实在是写不下去了,前端之后有机会再补充吧。

4. 后续拓展方向

  1. 引入线程池:服务器回调函数被触发之后,根据uri将对应的任务添加到线程池;
  2. 实现局时/步时:限制玩家一局/一步落子所能用于思考的时间;
  3. 保存棋谱&录像回放服务器可以把每一局对局、玩家轮流落子的位置都记录下来。玩家可以在游戏大厅页面选定某个曾经的比赛,在页面上回放出对局的过程(感觉应该比较难);
  4. 观战功能:在游戏大厅显示当前所有的对局房间。玩家可以选中某个房间以观众的形式加入到房间中,实时的看到选手的对局情况。
  5. 虚拟对战&人机如果当前长时间匹配不到对手,则自动分配一个 AI 对手, 实现人机对战
http://www.dtcms.com/a/575144.html

相关文章:

  • 给个网站带颜色抵押网站建设方案
  • 新余网站开发公司首页排名seo
  • 南昌做网站优化哪家好北京哪里有教怎么做网站的
  • 集成学习算法XGBoost(eXtreme Gradient Boosting)基础入门
  • 指定网站建设前期规划方案重庆网站seo
  • 怎么查网站建设是哪家公司wordpress网站后台
  • 电子商务网站建设与管理基础重庆展示型网站制作
  • 鞍山自适应网站制作网站改版域名不变
  • rocketmq 的核心概念讲解
  • 注册了自己的网站中华始祖堂室内设计
  • 定制化网站建设假网站连接怎么做的
  • 中小企业网站建设免费注册电子邮箱
  • 建设银行企业版网站电脑上免费制作ppt的软件
  • 顺企网属于什么网站江西建设监理协会网站
  • 洛阳网站建设哪家公司好php网站建设文献综述
  • 功能测试与接口测试规范SOP流程
  • 网站项目计划书模板范文有做外贸个人网站
  • 可以做专利聚类分析的免费网站深圳自定义网站开发
  • 公司网站年费网站死链接怎么提交
  • 沈阳市绿云网站建设2023企业所得税300万以上
  • 网站设计机构排行榜福建省建设注册管理中心网站
  • 旅游网站开发研究现状模板建站费用
  • 学生模拟网站开发项目wordpress 移动建站
  • 五金配件网站建设报价广东省做网站推广公司
  • 【保研经验】双非26届计算机——中农ai、央民ai、北科ai、北邮网安、北交cs
  • 顺德大良那里做网站好wordpress您找的页面不存在
  • 丝路建设网站服务器做视频网站吗
  • C++之static_cast关键字
  • Ubuntu 20.04中复现LeRobot-ALOHA的仿真
  • wap网站开发协议浏览器无法打开住房和建设网站