个人博客:小北北北北的秘密小窝
本文链接:https://www.seny.xyz/archives/gobang
闲来无事优化了一下上学期的 C++ 课程设计,在原来的本地五子棋对战的基础上结合之前的网络编程扩展出联机对战
该程序分为 服务端 与 客户端,客户端采用 QT 编写提供基本的图形化界面,服务端采用 C++ 编写同时架设在 Linux 服务器上。联机功能的实现依赖于一个外网服务器,市面上的头部服务器供应商像 阿里云 跟 腾讯云 都 OK,并且他们都有学生优惠一个月十块钱不到的样子。
QT 版本为 5.9.0 该程序运行结果预览如下:
支持 AI 对战与本地双人对战,支持局时与悔棋功能。
支持大部分功能如:悔棋、认输、求和等,支持实时信息交流。
服务端实时日志如下。
(请忽视这丑陋的 UI,真尽力了😢)
本地对局与联机对局均提供了 棋盘类(ChessBoard) 与 对局控制类(GameControl),其中本地对局额外提供一个 AI 类 供本地人机对战。
- 棋盘类 :负责落子、悔棋、胜负判定等主要游戏逻辑,是游戏的主体。
- 对局控制类 :负责对局的控制,包括游戏开始、游戏结束等对局信息控制。
- AI 类 :一个基于策略表的简单对局 AI,提供给本地人机对局使用。
理清程序框架对于程序编写会更加得心应手,以下是一个基本的 程序框图:
由上述框图可见,除了以上三个实现类以外,要想实现联机对局还需要定义一种客户端与服务端的 数据通信格式与协议
客户端采用 QT 开发,提供了一些 UI 界面,编写该程序需要学习一定的 QT 知识,对于 QT 信号与槽 机制需要一定的了解。对于 棋盘类、对局控制类 与 AI 类 来说整体的逻辑在联机对局与本地对局中相差并不大。
二者的主要区别在于本地的数据通信是 实时 的,而联机的数据通信需要在服务器中进行 中转(为了简化工作量,所有联机对局的判定操作等仍在本地客户端中进行,服务端仅作为一个消息中转站,若有小伙伴想要更进一步优化的话,可以将判定等操作在服务端中进行)
除了主要逻辑之外还额外基于 QT 做了一些 UI 的美化(虽然也很丑),但不是本文的重点便不加以赘述。
本地对局 分为本地双人对局与本地人机对局,均由上述的三个基本控件实现,下面介绍的是上述的三个基本控件:
本地棋盘 负责游戏的主要逻辑 如落子、胜负判定、切换黑白方落子、限制落子方等,同时负责渲染棋盘上的各个部分。
接下来介绍的是棋盘类负责的一些主要部分:
游戏界面通过重写 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 出最近的棋子置空即可。
本地对局控件 负责用户的交互与游戏进展的控制。
本地对局控件维护了一个定时器用于记录局时,同时也在协调 AI 与 人人 对局,为上层的 Menu 类提供接口。
五子棋 AI 并不是本文的重点,在网上找了个简单的策略表算法,采用一个简单的策略表实现,在这里不多赘述,有兴趣的小伙伴们自行查阅相关资料。
联机对局 为联机双人对局,与本地对局有所区别的地方在于:需要规定客户端与服务端之间的 数据格式与协议。
在具体实现时规定了一个数据通信的结构体 Data
struct Data
{
int dataType; // 消息类型
int piece_color; // 棋手颜色
int piece_x; // 棋子坐标
int piece_y; // 棋子坐标
std::string temp; // 信息位
};Data 数据包规定了该条信息的消息类型与发送方,信息位用于接收部分指令的二级指令。
在实现具体功能时需要界定不同的 Data 指令类型,如建立连接(CONNECT)、断开连接(DISCONNECT)、落子(SETPIECE)、消息(MESSAGE)、悔棋(UNDO)与 求和(TIE)等,具体见 command.h
在涉及需要对方确认的请求时会通过 二级指令 来确认请求,今后若需要扩展功能的话可以进行更多指令的扩展。
由于远程数据通信时使用 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 格式,本文规定的协议核心为用 ';' 隔开各项数据。
在设计好上述三者之后就可以开始联机棋盘的构思,联机棋盘与本地棋盘的逻辑不尽相同,唯一的不同点在于 各种操作需要转换成 Data数据包后发送至服务器进行消息中转。
在联机棋盘中胜负等判定在棋盘类中移除,棋盘类中 仅进行消息发送而不进行任何判定,判定操作由联机对局控件中的 消息处理器 (Handler)进行处理。
在联机对局中,对局控件维护着客户端与服务端之间的 socket 连接,同时接收服务器传来的消息并进行实时处理请求如 落子(SETPIECE)、消息(MESSAGE)、悔棋(UNDO)与 求和(TIE)等
除此之外联机对局控件的其余部分与本地对局控件基本一致。
Linux 服务端逻辑较为简单,通过相关 API 与客户端建立连接后维护连接即可,由于连接较少所以采用 C/S 架构。后续若要进行多人多房间对局的话可以改用 Reactor 架构进行结构优化。
编写服务端需要提前了解 socket 编程 与 C++ 多线程 的一些相关知识,同时服务端需要与客户端的数据格式进行同步,所以相关的序列化操作与客户端相差不大便不在本文中赘述。
服务端的初始化连接步骤依次为:
- 初始化服务器 Socket:socket()、bind()、listen()
- 初始化相关变量
- 等待客户端连接
为了确保连接的 有效性,在接收到 socket 的连接时并不会直接接受响应,而是会进行连接有效性的校验:当客户端进行连接的发起后,会发送一个数据类型为 CONNECT 的 Data 数据包,当服务器接收到该数据包时才会为该 socket 建立一个新线程用以维护,否则将直接关闭该连接。
当客户端的 socket 连接通过合法性校验后,主线程会创建一个新线程用以维护该 socket 连接,新线程的主要作用为接收客户端信息并进行响应。
以上便是程序的分部讲解,若是该程序对你起到帮助不如点点 Star 支持一下 ~




