基于 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短连接时客户端身份识别功能。
  • 在线管理模块:对进入游戏大厅与游戏房间中用户进行管理,提供用户是否在线以及获取用户连接的功能。
  • 房间管理模块:为匹配成功的用户创建对战房间,提供实时的五子棋对战与聊天业务功能。
  • 用户匹配模块:根据天梯分数不同进行不同层次的玩家匹配,为匹配成功的玩家创建房间并加入房间。

项目实现

实用工具模块

  1. 日志宏:实现程序日志打印
  2. mysql_util: 数据库的连接和初始化,句柄的销毁,语句的执行
  3. json_util:封装实现json的序列化和反序列化
  4. string_util:主要是封装实现字符串分割的功能
  5. 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、房间广播和服务端状态管理有了更具体的理解。

相关推荐
神の愛2 小时前
java日志功能
java·开发语言·前端
不会写DN2 小时前
如何设计应用层 ACK 来补充 TCP 的不足?
开发语言·网络·数据库·网络协议·tcp/ip·golang
REDcker2 小时前
Android Bionic Libc 原理与实现综述
android·c++·c·ndk·native·bionic
xyq20242 小时前
PHP MySQL 简介
开发语言
我能坚持多久2 小时前
利用Date类的实现对知识巩固与自省
开发语言·c++
Rabitebla2 小时前
C++ 入门基础:从 C 到 C++ 的第一步
c语言·开发语言·c++
Greedy Alg2 小时前
定长内存池学习记录
c++·后端
西魏陶渊明2 小时前
解决异步挑战:Reactor Context 实现响应式上下文传递
开发语言·python
小则又沐风a2 小时前
C++内存管理 C++模板
开发语言·c++