目录
- [基于 C++ 的网页五子棋对战项目实战](#基于 C++ 的网页五子棋对战项目实战)
- 环境搭建
- 知识点代码用例
- 项目结构设计
- 数据管理模块:
- 网络通信模块
- 会话管理与身份识别
- 匹配与房间对战
- 前后端交互
- 结语
基于 C++ 的网页五子棋对战项目实战
这篇文章记录一个网页版五子棋对战项目的实现过程。项目以后端 C++ 为主,前端使用 HTML/CSS/JS,通信层同时使用 HTTP 和 WebSocket:HTTP 负责注册、登录、用户信息获取和静态资源访问,WebSocket 负责大厅匹配、房间对战和实时聊天。本文会结合项目中的关键代码,梳理环境搭建、模块划分以及对战流程是如何真正跑通的。
项目概述
本项目主要实现一个网页版的五子棋对战游戏,核心功能包括:
- 用户管理:支持用户注册、登录、用户信息获取以及积分、对局场次和胜场数统计。
- 匹配对战:支持玩家根据积分进入匹配队列,匹配成功后进入房间完成实时对战。
- 实时聊天:支持双方在房间对战过程中进行实时消息交流。
开发环境
- Linux(CentOS 7.6 / Ubuntu 22.04)
- VSCode / Vim
- g++ / gdb
- Makefile
核心技术
- HTTP / WebSocket
- WebSocket++
- JsonCpp
- MySQL
- C++11
- HTML / CSS / JS / AJAX
环境搭建
本文档用于在 Ubuntu 22.04 上配置五子棋课程项目的开发环境。
build-essential:编译 C++
make:跑 Makefile
gdb:调试
libjsoncpp-dev:处理 JSON 请求/响应
libwebsocketpp-dev:实时对战和聊天
mysql-server:存用户和战绩
libmysqlclient-dev:C++ 连接 MySQL
bash
sudo apt update
sudo apt install -y build-essential gdb make cmake git wget vim
sudo apt install -y libjsoncpp-dev libwebsocketpp-dev
sudo apt install -y mysql-server default-libmysqlclient-dev
sudo apt install -y pkg-config libssl-dev libboost-all-dev
sudo systemctl start mysql
sudo systemctl enable mysql
sudo systemctl restart mysql
验证环境
执行以下命令,如果都能正常输出版本信息,说明环境基本配置完成:
bash
g++ --version
gdb --version
make --version
cmake --version
git --version
wget --version
mysql --version
知识点代码用例
Websocketpp
WebSocket是从HTML5开始⽀持的⼀种⽹⻚端和服务端保持⻓连接的消息推送机制。
- 传统的web程序的服务端是不会给客户端发送信息的,这就造成了客户端要不断向服务端发送请求
- 像⽹⻚即时聊天或者我们做的五⼦棋游戏这样的程序都是⾮常依赖"消息推送"的,即需要服务器主动推动消息到客⼾端。如果只是使⽤原⽣的HTTP协议,要想实现消息推送⼀般需要通过"轮询"的⽅式实现,⽽轮询的成本⽐较⾼并且也不能及时的获取到消息的响应。
基于上述两个问题,就产⽣了WebSocket协议。WebSocket更接近于TCP这种级别的通信⽅式,⼀旦连接建⽴完成客⼾端或者服务器都可以主动的向对⽅发送数据。
具体代码案例->websocketpp库搭建服务器

josoncpp库实现反序列化
cpp
#include <iostream>
#include <sstream>
#include <vector>
#include <string>
#include <jsoncpp/json/json.h>
//使用jsoncpp库进行多个数据对象的序列化
std::string serialize()
{
//1. 将需要进行序列化的数据,存储在Json::Value 对象中
Json::Value root;
root["姓名"] = "小明";
root["年龄"] = 18;
root["成绩"].append(98);
root["成绩"].append(88.5);
root["成绩"].append(78.5);
//2. 实例化一个StreamWriterBuilder工厂对象
Json::StreamWriterBuilder swb;
//3. 通过StreamWriterbuilder工厂类对象生产一个StreamWriter对象
Json::StreamWriter *sw = swb.newStreamWriter();
//4. 使用StreamWriter对象,对Json::Value中存储的数据进行序列化
std::stringstream ss;
int ret = sw->write(root, &ss);
if (ret != 0){
std::cout << "json serialize failed!!" << std::endl;
return "";
}
std::cout << ss.str() << std::endl;
delete sw;
return ss.str();
}
void unserialize(const std::string &str)
{
//1. 实例化一个CharReadereBuilder工厂对象
Json::CharReaderBuilder crb;
//2. 使用CharReaderBuilder工厂类生产一个CharReadeer
Json::CharReader *cr = crb.newCharReader();
//3. 定义一个Json::Value对象存储解析后的数据
Json::Value root;
std::string err;
//4. 使用CharReader对象进行json格式字符串str的反序列化
//parse(char * start, char *end, Json::Value *val, string *err);
bool ret = cr->parse(str.c_str(), str.c_str() + str.size(), &root, &err);
if(ret == false)
{
std::cout << "json unserialize faliled: " << err << std::endl;
return ;
}
//5. 逐个元素去访问Json::Value中的数据
std::cout << "姓名:" << root["姓名"].asString() << std::endl;
std::cout << "年龄:" << root["年龄"].asInt() << std::endl;
int sz = root["成绩"].size();
for(int i = 0; i < sz; i++){
std::cout << "成绩: " << root["成绩"][i].asFloat() << std::endl;
}
}
int main()
{
std::string str = serialize();
unserialize(str);
return 0;
}
mysqclient库用例
cpp
#include <stdio.h>
#include <string.h>
#include <mysql/mysql.h>
#define HOST "127.0.0.1"
#define PORT 3306
#define USER "root"
#define PASS "123456"
#define DBNAME "gobang"
int main()
{
//1. 初始化mysql句柄
MYSQL *mysql = mysql_init(NULL);
if(mysql == NULL){
printf("mysql init failed!\n");
return -1;
}
//2. 连接服务器
//MYSQL *mysql_real_connect(mysql,host,user,pass,dbname,por,unix_socket,flag)
if(mysql_real_connect(mysql, HOST, USER, PASS, DBNAME, PORT, NULL, 0) == NULL)
{
printf("connect mysql server failed : %s\n", mysql_error(mysql));
mysql_close(mysql);
return -1;
}
//3. 设置客户端字符集
//int mysql_set_character_set(mysql,"utf8");
if(mysql_set_character_set(mysql,"utf8") != 0)
{
printf("set client character failed : %s\n", mysql_error(mysql));
mysql_close(mysql);
return -1;
}
//4. 选择要操作的数据库
//int mysql_select_db(mysql, dbname)
//mysql_select_db(mysql, DBNAME);
//5. 执行sql语句
//int mysql_query(MYSQL *(mysql, char *sql);
// const char *sql = "insert stu values(null, '小明', 18, 52, 68, 87);";
const char *sql = "select * from stu;";
int ret = mysql_query(mysql, sql);
if(ret != 0)
{
printf("%s\n",sql);
printf("mysql stu failed : %s\n", mysql_error(mysql));
mysql_close(mysql);
return -1;
}
//6. 如果sql语句是查询语句,则需要保存结果到本地
//MYSQL_RES *mysql_store_result(MYSQL *mysql)
MYSQL_RES *res = mysql_store_result(mysql);
if(res == NULL){
mysql_close(mysql);
return -1;
}
//7. 获取结果集中的结果跳出
//int mysql_fetch_rows(MYSQL_RES *res)
//int mysql_fetch_fields(MYSQL_RES *res)
int num_row = mysql_num_rows(res);
int num_col = mysql_num_fields(res);
//8. 遍历保存到本地的结果集
for(int i = 0; i < num_row; i++){
MYSQL_ROW row = mysql_fetch_row(res);
for(int i = 0; i < num_col; i++){
printf("%s\t", row[i]);
}
printf("\n");
}
//9. 释放结果集
mysql_free_result(res);
//10. 关闭连接,释放句柄
mysql_close(mysql);
return 0;
}
项目结构设计
项目模块划分说明
- 数据管理模块:基于Mysql数据库进行用户数据的管理
- 前端界面模块:基于JS实现前端页面(注册,登录,游戏大厅,游戏房间)的动态控制以及与服务器的通信。
- 业务处理模块:搭建WebSocket服务器与客户端进行通信,接收请求并进行业务处理。
业务处理模块的子模块划分:
- 网络通信模块:基于websocketpp库实现Http&WebSocket服务器的搭建,提供网络通信功能。
- 会话管理模块:对客户端的连接进行cookie&session管理,实现http短连接时客户端身份识别功能。
- 在线管理模块:对进入游戏大厅与游戏房间中用户进行管理,提供用户是否在线以及获取用户连接的功能。
- 房间管理模块:为匹配成功的用户创建对战房间,提供实时的五子棋对战与聊天业务功能。
- 用户匹配模块:根据天梯分数不同进行不同层次的玩家匹配,为匹配成功的玩家创建房间并加入房间。
项目实现
实用工具模块
- 日志宏:实现程序日志打印
- mysql_util: 数据库的连接和初始化,句柄的销毁,语句的执行
- json_util:封装实现json的序列化和反序列化
- string_util:主要是封装实现字符串分割的功能
- file_util:主要封装了文件数据的读取功能(对于html文件数据读取相应)
数据管理模块:
这个项目的数据表设计比较简单,核心只有一个 user 表,保存用户名、密码、积分、总场次和胜场数。注册时插入新用户,登录时校验账号密码,对局结束后更新双方战绩。数据层统一放在 user_table 中,业务层只管调用接口,不直接拼接查询逻辑,结构上比较清楚。
下面这段代码就是登录和战绩更新的核心实现:
cpp
bool login(Json::Value &user){
#define LOGIN_USER "select id, score, total_count, win_count from user where username='%s' and password='%s';"
char sql[4096] = {0};
sprintf(sql, LOGIN_USER, user["username"].asCString(), user["password"].asCString());
MYSQL_RES *res = NULL;
{
std::unique_lock<std::mutex> lock(_mutex);
bool ret = mysql_util::mysql_exec(_mysql, sql);
if (ret == false) {
return false;
}
res = mysql_store_result(_mysql);
if (res == NULL) {
return false;
}
}
int row_num = mysql_num_rows(res);
if (row_num != 1) {
return false;
}
MYSQL_ROW row = mysql_fetch_row(res);
user["id"] = (Json::UInt64)std::stol(row[0]);
user["score"] = (Json::UInt64)std::stol(row[1]);
user["total_count"] = std::stoi(row[2]);
user["win_count"] = std::stoi(row[3]);
mysql_free_result(res);
return true;
}
bool win(uint64_t id){
#define USER_WIN "update user set score=score+30, total_count=total_count+1, win_count=win_count+1 where id=%lu;"
char sql[4096] = {0};
sprintf(sql, USER_WIN, id);
return mysql_util::mysql_exec(_mysql, sql);
}
网络通信模块
项目同时使用了 HTTP 和 WebSocket。HTTP 负责注册、登录、获取用户信息以及静态页面访问;WebSocket 负责大厅匹配、房间对战和聊天。这样拆分之后,短请求和实时通信各走各的通道,职责比较明确。
服务端的请求分发集中在 gobang_server 中。HTTP 侧根据请求方法和 URI 分发到不同业务函数,WebSocket 侧则根据连接路径区分大厅和房间:
cpp
void http_callback(websocketpp::connection_hdl hdl){
wsserver_t::connection_ptr conn = _wssrv.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 reg(conn);
}else if(method == "POST" && uri == "/login"){
return login(conn);
}else if(method == "GET" && uri == "/info"){
return info(conn);
}else{
return file_handler(conn);
}
}
void wsopen_callback(websocketpp::connection_hdl hdl){
wsserver_t::connection_ptr conn = _wssrv.get_con_from_hdl(hdl);
websocketpp::http::parser::request req = conn->get_request();
std::string uri = req.get_uri();
if(uri == "/hall"){
return wsopen_game_hall(conn);
}else if(uri == "/room"){
return wsopen_game_room(conn);
}
}
会话管理与身份识别
登录成功后,服务端不会只返回一句"登录成功",而是会额外创建一个 session,并通过 Cookie 把 SSID 发给浏览器。之后用户访问 /info,或者建立大厅、房间 WebSocket 连接时,服务端都可以根据 Cookie 找到当前用户,从而完成身份识别。
这部分代码如下:
cpp
void login(wsserver_t::connection_ptr &conn){
std::string req_body = conn->get_request_body();
Json::Value login_info;
bool ret = json_util::unserialize(req_body, login_info);
if(ret == false){
return http_resp(conn, false, websocketpp::http::status_code::bad_request, "请求的正文格式错误");
}
ret = _ut.login(login_info);
if(ret == false){
return http_resp(conn, false, websocketpp::http::status_code::bad_request, "用户名密码错误");
}
uint64_t uid = login_info["id"].asInt64();
session_ptr ssp = _sm.create_session(uid, LOGIN);
_sm.set_session_expire_time(ssp->ssid(), SESSION_TIMEOUT);
std::string cookie_ssid = "SSID=" + std::to_string(ssp->ssid());
conn->append_header("Set-Cookie", cookie_ssid);
return http_resp(conn, true, websocketpp::http::status_code::ok, "登陆成功");
}
session_ptr get_session_by_cookie(wsserver_t::connection_ptr conn) {
std::string cookie_str = conn->get_request_header("Cookie");
std::string ssid_str;
bool ret = get_cookie_val(cookie_str, "SSID", ssid_str);
if (ret == false) {
return session_ptr();
}
return _sm.get_session_by_ssid(std::stol(ssid_str));
}
匹配与房间对战
匹配模块按玩家积分把用户放进不同队列,队列人数足够后再取出两名玩家尝试建房。建房成功后,服务端向双方发送 match_success,前端收到消息后再跳转房间页面。这里的关键点不在于"匹配算法有多复杂",而在于先确认两名玩家仍然在线,再创建房间,避免房间刚建好就出现空房的情况。
cpp
void handle_match(match_queue<uint64_t> &mq) {
while(1) {
while (mq.size() < 2) {
mq.wait();
}
uint64_t uid1, uid2;
bool ret = mq.pop(uid1);
if (ret == false) continue;
ret = mq.pop(uid2);
if (ret == false) {
this->add(uid1);
continue;
}
wsserver_t::connection_ptr conn1 = _om->get_conn_from_hall(uid1);
wsserver_t::connection_ptr conn2 = _om->get_conn_from_hall(uid2);
if (conn1.get() == nullptr || conn2.get() == nullptr) {
if (conn1.get() != nullptr) this->add(uid1);
if (conn2.get() != nullptr) this->add(uid2);
continue;
}
room_ptr rp = _rm->create_room(uid1, uid2);
if (rp.get() == nullptr) {
this->add(uid1);
this->add(uid2);
continue;
}
Json::Value resp;
resp["optype"] = "match_success";
resp["result"] = true;
std::string body;
json_util::serialize(resp, body);
conn1->send(body);
conn2->send(body);
}
}
真正的对战逻辑放在房间模块里。前端点击棋盘后,会把坐标、房间号和用户 id 发给服务端;服务端检查位置是否合法,更新棋盘,再判断这一手是否已经形成五子连珠。胜负判断通过四个方向连续计数完成,只要任意一个方向达到 5,就返回赢家 id。
cpp
bool five(int row, int col, int row_off, int col_off, int color){
int count = 1;
int search_row = row + row_off;
int search_col = col + col_off;
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_off;
search_col += col_off;
}
search_row = row - row_off;
search_col = col - col_off;
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_off;
search_col -= col_off;
}
return (count >= 5);
}
uint64_t check_win(int row, int col, int color){
if (five(row, col, 0, 1, color) ||
five(row, col, 1, 0, color) ||
five(row, col, -1, 1, color) ||
five(row, col, -1, -1, color)) {
return color == CHESS_WHITE ? _white_id : _black_id;
}
return 0;
}
前后端交互
前端和后端的配合思路比较直接。用户登录时,前端通过 AJAX 向 /login 发送账号密码;进入大厅后建立 /hall 的 WebSocket 长连接;匹配成功后跳转到房间页,再建立 /room 长连接。下棋时前端不会直接把棋子画死,而是先把数据发给后端,等后端广播结果后再渲染到棋盘上。这样可以保证房间状态以后端为准,避免前端自己改状态导致双方棋盘不一致。
前端发送落子请求的代码如下:
javascript
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));
}
ws_hdl.onmessage = function(evt) {
var info = JSON.parse(evt.data);
if (info.optype == "put_chess"){
if (info.result == false) {
alert(info.reason);
return;
}
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;
}
}
}
结语
这个项目的难点并不在棋盘绘制本身,而在于把登录鉴权、状态维护、在线匹配、房间管理和实时通信这些环节真正串成一条完整链路。实现下来之后,会更直观地感受到网络项目和普通本地程序的差异:很多时候问题不在单个函数怎么写,而在于连接状态是否一致、消息流转是否闭合、服务端是否掌握最终状态。
从结果上看,这个项目已经完成了一个双人对战系统的基本闭环。用户可以注册登录、进入大厅、发起匹配、进入房间、实时下棋与聊天,并在对局结束后回写战绩。虽然在数据库安全、密码加密、异常恢复和并发稳定性方面还有继续完善的空间,但作为一个 C++ 网络项目练手案例,它已经把核心流程完整跑通,也让我对 HTTP、WebSocket、Session、房间广播和服务端状态管理有了更具体的理解。