🌸 前言
个人博客:小北北北北的秘密小窝
本文链接:https://www.seny.xyz/archives/gobang
Github:https://github.com/senyucci/Gobang
最近闲来无事优化了一下上学期的 C++ 课程设计,在原来的本地五子棋对战的基础上结合之前的网络编程扩展出联机对战,联机五子棋主要的难点在于对战双方的 数据通信
,这些在本文后面都会有所讲解。
该程序分为 服务端 与 客户端,客户端采用 QT 编写提供基本的图形化界面,服务端采用 C++ 编写同时架设在 Linux 服务器上。联机功能的实现依赖于一个外网服务器,市面上的头部服务器供应商像 阿里云 跟 腾讯云 都 OK,并且他们都有学生优惠一个月十块钱不到的样子。
详细源码提供于文末链接,有兴趣的小伙伴可以下载下来作为参考,写的不好有意见或者疑问可以在评论区提出!一起进步~
🌸 程序预览
QT 版本为 5.9.0 ,该程序运行结果预览如下:
本地对局
支持 AI 对战与本地双人对战,支持局时与悔棋功能。
联机对局
支持大部分功能如:悔棋、认输、求和等,支持实时信息交流。
服务端
服务端实时日志如下。
主菜单
(请忽视这丑陋的 UI,真尽力了😢)
🌸 程序框架
本地对局与联机对局均提供了 棋盘类
(ChessBoard) 与 对局控制类
(GameControl),其中本地对局额外提供一个 AI 类
供本地人机对战。
- 棋盘类 :负责落子、悔棋、胜负判定等主要游戏逻辑,是游戏的主体。
- 对局控制类 :负责对局的控制,包括游戏开始、游戏结束等对局信息控制。
- AI 类 :一个基于策略表的简单对局 AI,提供给本地人机对局使用。
理清程序框架对于程序编写会更加得心应手,以下是一个基本的 程序框图:
由上述框图可见,除了以上三个实现类以外,要想实现联机对局还需要定义一种客户端与服务端的 数据通信格式与协议 ,这部分在后续的联机对局中会详细地进行阐述。
接下来将从 客户端
与 服务端
的角度来实现一个联机五子棋
🌸 Gobang 客户端
客户端采用 QT 开发,提供了一些 UI 界面,编写该程序需要学习一定的 QT 知识,对于 QT 信号与槽 机制需要一定的了解。对于 棋盘类、对局控制类 与 AI 类 来说整体的逻辑在联机对局与本地对局中相差并不大。
二者的主要区别在于本地的数据通信是 实时 的,而联机的数据通信需要在服务器中进行 中转(为了简化工作量,所有联机对局的判定操作等仍在本地客户端中进行,服务端仅作为一个消息中转站,若有小伙伴想要更进一步优化的话,可以将判定等操作在服务端中进行)
除了主要逻辑之外还额外基于 QT 做了一些 UI 的美化(虽然也很丑),但不是本文的重点便不加以赘述。
#define BOARD_COL 15 // 棋盘列数
#define BOARD_ROW 15 // 棋盘行数
#define BLACK_PIECE 0 // 棋子标志:黑子
#define WHITE_PIECE 1 // 棋子标志:白子
#define NO_PIECE 2 // 棋子标志:无子
#define BLACK_PLAYER 0 // 棋手标志:黑方
#define WHITE_PLAYER 1 // 棋手标志:白方
#define DRAW 2 // 和棋标志
#define HUMUN_MODE 1
#define AI_MODE 2
以上是客户端中一些基本数据的定义
一、本地对局
本地对局 分为本地双人对局与本地人机对局,均由上述的三个基本控件实现,下面介绍的是上述的三个基本控件:
1. 本地棋盘(ChessBoard)
本地棋盘 负责游戏的主要逻辑 如落子、胜负判定、切换黑白方落子、限制落子方等,同时负责渲染棋盘上的各个部分,以下是棋盘类中定义的一些变量:
...int board[BOARD_COL][BOARD_ROW]; // 棋盘中棋子的位置信息bool isGameOver; // 游戏的结束标志int nextPlayer; // 标志下一位棋手QPoint mousePos; // 保存鼠标当前位置QVector<QPoint> winPiecePos; // 保存获胜方五个棋子的位置QSet<int> boardReceivePlayer; // 保存棋盘可接受的下棋方QStack<QPoint> dropedPieces; // 保存落子的顺序信息...
接下来介绍的是棋盘类负责的一些主要部分:
棋盘渲染
游戏界面通过重写 QT 的 paintEvent(QPaintEvent *event)
来进行绘图事件。paintEvent(QPaintEvent *event) 函数是 QWidget 类中的一个虚函数,多用于 UI 的绘制,会在多种情况下被其他函数自动调用,例如 update()。
当棋盘上的出现任何变化时均会调用 update() 函数进行游戏界面的重绘来体现界面的变化。
棋盘类主体需要渲染的部分:
- 棋盘主体:棋盘格、背景与边框等
- 棋子:双边落子的棋子
- 天元与星:棋盘上五个固定的小黑点
- 红色选框:落子前随鼠标移动的红色方形选框
- 最新落子:最后一次落子时棋子上的红色十字
- 最终五子:五子连珠的五颗棋子
落子
在介绍落子前需要先介绍 SetBoardReceivePlayer()
函数:该函数用于设置当前的棋盘类对象接受几个下棋方,在本地双人对局中棋盘接受黑白方落子、AI 对局中玩家默认执白棋盘仅接受白方落子。在联机对局中,当玩家连接服务器后服务器会返回对应的棋子,棋盘会接收服务器返回的棋子作为当前棋盘的下棋方。
落子时调用 QT 的 mouseReleaseEvent(QMouseEvent *event)
鼠标点击事件获取鼠标点击的位置,并在指定位置调用 SetPiece(int x,int y) 函数进行落子,在进行相应判定后(边界判定、无子判定)将当前位置的点集进行保存后重绘游戏界面,进行胜负判定(场上是否存在五子),当判断尚未结束游戏时会进行选手切换。
悔棋
棋盘类维护了一个栈用于保存落子的顺序信息 QStack<QPoint> dropedPieces
, 当 Undo() 函数被调用时从栈中 push 出最近的棋子置空即可。
2. 本地对局控件(GameControl)
本地对局控件 负责用户的交互与游戏进展的控制。
本地对局控件维护了一个定时器用于记录局时,同时也在协调 AI 与 人人 对局,为上层的 Menu 类提供接口。
3. 游戏 AI(GameAI)
五子棋 AI 并不是本文的重点,在网上找了个简单的策略表算法,采用一个简单的策略表实现,在这里不多赘述,有兴趣的小伙伴们自行查阅相关资料。
二、联机对局
联机对局 为联机双人对局,与本地对局有所区别的地方在于:需要规定客户端与服务端之间的 数据格式与协议
。
1. 数据格式(Data)
在具体实现时规定了一个数据通信的结构体 Data
struct Data
{int dataType; // 消息类型int piece_color; // 棋手颜色int piece_x; // 棋子坐标int piece_y; // 棋子坐标std::string temp; // 信息位
};
Data 数据包规定了该条信息的消息类型与发送方,信息位用于接收部分指令的二级指令。
2. 数据类型(DataType)
在实现具体功能时需要界定不同的 Data 指令类型,如建立连接(CONNECT)、断开连接(DISCONNECT)、落子(SETPIECE)、消息(MESSAGE)、悔棋(UNDO)与 求和(TIE)等,具体如下:
// 消息类型
#define CONNECT 110
#define CONNECT_SUCCESS 120
#define CONNECTIONS 130
#define SETPIECE 140#define UNDO 150
#define UNDO_REQUEST 151
#define UNDO_YES 152
#define UNDO_NO 153#define GAMEOVER 160
#define DISCONNECT 170
#define MESSAGE 180
#define SURRENDER 190#define TIE 200
#define TIE_REQUEST 201
#define TIE_YES 202
#define TIE_NO 203
在涉及需要对方确认的请求时会通过 二级指令 来确认请求,今后若需要扩展功能的话可以进行更多指令的扩展。
3. 序列化与反序列化(Serialize)
由于远程数据通信时使用 socket 进行数据传输,通常会使用 char* 进行消息的发送与接收。要使 Data 数据包完好无损的在网络数据传输中发送至另一端,序列化就是一个绕不开的话题,但本文篇幅有限便不具体展开讲。
通俗的来说,序列化就是将具体的对象数据(此处是广义上的对象,内置类型或者用户自定义类型)变成 char*,即单个字节的数据方便传输。因为对象在内存中的存储并非简单的单个字符,所以我们需要将 Data 数据包先转换成 char* 发送至服务端,服务端接收到 char* 再将其转换为 Data 数据包
。
在本程序中采用的具体协议为:数据项与数据项之间采用分号隔开,以下提供一个例子:
// 创建 Data 数据包
string str;
Data data;
data.dataType = CONNECT;
data.piece_color = BLACK_PLAYER;// 序列化
DataToString(str,data);// 序列化后 (CONNECT 为 110,BLACK_PLAYER 为 1)
str = "110;1;;;;"
反序列化与序列化正好相反:将接收到的 char* 重新转换为 Data 格式,本文规定的协议核心为用 ';' 隔开各项数据。
有关序列化的实现细节本文就不多赘述,文末会为各位感兴趣的小伙伴提供程序源码供参考。
4. 联机棋盘(NetBoard)
在设计好上述三者之后就可以开始联机棋盘的构思,联机棋盘与本地棋盘的逻辑不尽相同,唯一的不同点在于 各种操作需要转换成 Data数据包后发送至服务器进行消息中转。
在联机棋盘中胜负等判定在棋盘类中移除,棋盘类中 仅进行消息发送而不进行任何判定
,判定操作由联机对局控件中的 消息处理器 (Handler)进行处理。
5. 联机对局控件(NetGame)
在联机对局中,对局控件维护着客户端与服务端之间的 socket 连接,同时接收服务器传来的消息并进行实时处理请求如 落子(SETPIECE)、消息(MESSAGE)、悔棋(UNDO)与 求和(TIE)等:
除此之外联机对局控件的其余部分与本地对局控件基本一致。
🌸 Gobang 服务端
Linux 服务端逻辑较为简单,通过相关 API 与客户端建立连接后维护连接即可,由于连接较少所以采用 C/S 架构。后续若要进行多人多房间对局的话可以改用 Reactor 架构进行结构优化。
编写服务端需要提前了解 socket 编程
与 C++ 多线程
的一些相关知识,同时服务端需要与客户端的数据格式进行同步,所以相关的序列化操作与客户端相差不大便不在本文中赘述。
...
// 数据通信相关
int Server_fd; // 服务器 socket
Data data[MAX_CLIENT];
pthread_t tid[MAX_CLIENT]; // 线程集合
int client_fd[MAX_CLIENT]; // 客户端 socket 集合
Locker locker; // 封装的锁对象
int connections; // 当前已连接人数// 棋盘相关
bool player[MAX_CLIENT]; // 已准备的用户
int nextPlayer; // 下一位落子的用户
...
以上是服务端维护的一些相关变量
一、初始化连接
服务端的初始化连接步骤依次为:
- 初始化服务器 Socket:socket()、bind()、listen()
- 初始化相关变量
- 等待客户端连接
详细代码如下:
二、连接校验
为了确保连接的 有效性,在接收到 socket 的连接时并不会直接接受响应,而是会进行连接有效性的校验:当客户端进行连接的发起后,会发送一个数据类型为 CONNECT
的 Data 数据包,当服务器接收到该数据包时才会为该 socket 建立一个新线程用以维护,否则将直接关闭该连接。
三、消息处理器(线程函数)
当客户端的 socket 连接通过合法性校验后,主线程会创建一个新线程用以维护该 socket 连接,新线程的主要作用为接收客户端信息并进行响应。具体实现过于繁杂便不在本文中展开,有兴趣的小伙伴可以通过文末连接下载源码进行参考,以下是 Handler 的一个缩略代码。
🌸 小结
本文到这里就结束啦,通过这次联机五子棋的编写巩固了对于网络编程的理解,同时也学到了不少新的理念与模式,像序列化、协议规定等对于真正的项目工程来说想必也是不可或缺的,与此同时 QT 也是一个编写 C++ 图形化界面程序十分完美的工具,可视化的界面编写大大降低了编程的复杂性。
你们的支持就是我的动力,如果你对本文有什么建议或疑惑,可以及时在评论区评论留言,看到了会及时回复。如果本文对你有起到一定的帮助,不如动手点点赞,多多转发给身边的三五好友 ~
噢对差点忘了,文末链接在这 ↓
提取链接:https://pan.seny.xyz/s/jbFa
提取密码:seny
个人博客:https://www.seny.xyz