ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++网络编程实战:从零实现联机五子棋

2026/9/8 1:48:24 拓冰建站 浏览量
C++网络编程实战:从零实现联机五子棋 简介一套基于C与QT实现的双端网络五子棋小游戏完整源码面向学习C网络编程、QT界面开发及Socket通信的初学者与高校学生。项目明确拆分为客户端与服务端客户端使用QT框架构建界面包括菜单、棋盘、对局窗口及多张png素材网络层基于Windows下的C socket编写服务端部署于Linux利用Linux socket实现连接管理与数据转发支持公网联机对战能够完整体现从界面交互到跨平台通信的工程架构。压缩包内共28个文件以cpp、h源文件为主辅以ui设计文件、pro工程文件、qrc资源索引、makefile及部分图片资源整体大小约559KB模块划分清楚便于按需阅读。资源已有1398人学习下载源码中不仅覆盖了QT信号槽、绘图事件、Socket API集成、多线程收发等实践要点还附带可直接编译运行的客户端与服务端完整程序非常适合作为网络编程课程设计、毕业设计或趣味项目二次开发的基础。 先说结论吧网络联机五子棋是我个人觉得C入门阶段性价比最高的一个练手项目。它同时踩中了三个关键点C语法基础、网络编程思想、以及一个完整小游戏的状态管理逻辑。很多人学完了类和STL之后不知道拿什么练手做个控制台版五子棋觉得不够过瘾直接去啃高并发服务器又啃不动网络联机五子棋恰好卡在中间难度适中又能实实在在地跑起来跟朋友对战源码也相当完整非常适合课设、简历项目或者单纯想验证自己C水平的朋友。这个项目最核心的价值在于它不是那种纯算法演示的玩具代码而是一个真正需要考虑“双方状态同步”“数据包格式”“服务器与客户端各自职责”的完整工程。你会被迫面对单机版从来不会碰到的问题用户A落了一颗子怎么让用户B的棋盘上也出现这颗子如果用户A断线了怎么办网络消息到达的顺序和人落子的顺序不一样怎么办这些问题每一个都值得琢磨而且都能在五子棋这个足够简单的场景里找到清晰的答案没有任何黑魔法。下面我按我实际写这套代码的思路从设计到实现再到排坑一条一条讲清楚。已经含了完整的核心源码思路你可以直接照着敲也可以把它理解透之后改成自己的东西。1. 项目拆解网络五子棋没那么神秘1.1 这个项目到底在做什么一句话描述服务器进程负责配对和转发数据两个客户端进程分别显示棋盘、响应玩家点击落子并通过socket连接完成“我落子你同步显示”的交互。核心功能拆开就四块15x15棋盘数据结构和显示黑子白子、光标移动落子合法性检查和胜负判定客户端与服务器之间的消息通信对局状态管理等待对手、轮到我走、对方走棋、游戏结束单机部分其实很多人两天就能写完难的是把这个“单机内核”从一个人手里抽出来拆成两个进程各自持有半边状态再通过网络把状态同步起来。1.2 核心技术栈与选型思路我使用的技术栈很简单没有引入任何第三方库纯C11 BSD socketWindows下就是WinsockAPI基本一致。语言C11只用了vector、thread、mutex这些基础组件没有花哨模板。网络模型TCP阻塞式socket select多路复用服务器端不用多线程一个线程管所有连接。编译环境Windows下用Visual Studio或MinGWLinux下直接用g。为什么选TCP而不是UDP五子棋这种回合制游戏每一步的落子消息都极其重要丢失一条消息会导致双方棋盘不一致后续根本没法继续。TCP自带重传和有序性保证哪怕丢包了也会自动重发且保证先发的数据先到这能省掉我们一堆精力。UDP更适合实时性要求极高的动作游戏在这里属于过度设计。为什么服务器用select而不是多线程因为我只需要同时管理两个客户端连接select的处理逻辑直观得多一个循环检测哪个socket有数据可读读出来处理没有数据就继续等。多线程当然也行但线程同步、死锁这些问题对新手极不友好没必要给自己挖坑。2. 核心设计棋盘、落子与胜负判定2.1 棋盘数据结构怎么定我推荐直接用二维数组而不是vector套vector。原因很简单15x15的棋盘无论你怎么折腾都塞不进性能瓶颈里vector的灵活性在这里没有用武之地而二维数组的索引访问最直白调试也最方便。const int BOARD_SIZE 15; const int EMPTY 0; // 空位 const int BLACK 1; // 黑子 const int WHITE 2; // 白子 int board[BOARD_SIZE][BOARD_SIZE] {0};棋盘显示这块如果走控制台路线用字符和中文全角符号来模拟棋子是最省事的黑子用“●”白子用“○”空位用“”坐标轴用字母A-O和数字1-15标注。实测下来在Windows控制台和Linux终端下都能正常显示前提是终端编码支持中文。如果想换图形界面可以在后续用Qt或者SFML替换显示层但核心的数据结构和判定逻辑完全不用动。这也是我为什么强烈建议把“逻辑”和“显示”分开写的原因换UI只动一个文件就好。2.2 胜负判定算法别用暴力扫全盘很多人第一次写五子棋判定会写一个“每次落子之后遍历整个棋盘对每个位置向四个方向数连续棋子”的版本。这种写法逻辑没错但存在两个问题一是做了大量无意义的重复计算二是代码不容易复用。我用的方案是“以当前落子点为起点向四个方向延伸统计”。每次只检查最后一步落下的这颗子在该方向上的两个相反方向上统计连续同色棋子的数量。如果总数达到5游戏结束。// 判断当前棋子 (row, col) 是否五连 bool checkWin(int row, int col, int role) { int dirs[4][2] { {0, 1}, // 水平方向 {1, 0}, // 垂直方向 {1, 1}, // 主对角线\ 方向 {1, -1} // 副对角线/ 方向 }; for (auto dir : dirs) { int count 1; // 正方向延伸 for (int step 1; step 5; step) { int nr row dir[0] * step; int nc col dir[1] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] ! role) break; count; } // 反方向延伸 for (int step 1; step 5; step) { int nr row - dir[0] * step; int nc col - dir[1] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] ! role) break; count; } if (count 5) return true; } return false; }这段代码的妙处在于任何一次落子之后只要检查这一个位置就够了。因为游戏规则是“先形成五连的赢”所以获胜的那一手一定是刚落下的这手棋棋盘上其他区域无论如何都不可能突然“凭空出现”一个五连。边界条件需要注意一点延伸的时候每次都要判断新坐标是否越界。数组越界是C里最隐蔽的bug来源你看着逻辑没问题运行时却可能踩到未定义行为然后在下一次落子时表现出一种让人摸不着头脑的随机性。2.3 落子校验与轮流逻辑落子合法性的判断很简单坐标必须在0-14范围内且该位置值为EMPTY。非法落子分两种情况处理本地玩家点击了已有棋子的位置给出提示并等待重新输入如果是网络对端发来的落子消息落到了非法位置那就是数据异常直接判定对方作弊或者断线处理。第二种情况看起来没必要但真写工程代码的时候必须考虑因为网络数据永远是不能完全信任的。轮流逻辑是回合制游戏的核心状态机。我用一个bool myTurn控制每个客户端自己维护这个变量我的回合等待玩家输入落子成功之后发坐标给对方然后myTurn false。对方回合阻塞等待服务器转发过来的落子坐标收到后更新棋盘myTurn true。这个二元状态机足够支撑整个对局过程。要避免的坑是“重复落子”和“顺序错乱”。顺序错乱的产生原因大多不是业务逻辑错了而是网络数据收发时机不对我们会在下一节详细讲。3. 网络联机协议就是最简单的对话3.1 架构选择客户端-服务器 vs 点对点早期我见过有人做“点对点”五子棋就是客户端A直接连客户端B双方既做客户端又做服务器代码里充斥着大量“同时监听和连接”的怪逻辑。这个思路不是不行但各种NAT穿透和边界情况会把你折磨疯掉而且同一套代码要处理两种角色心智负担翻倍。我的选择是标准的客户端-服务器架构一台机器跑服务器负责监听端口、接收两个客户端的连接、并双向转发数据。服务器本身不参与棋局逻辑它只做“传声筒”。这样做的好处极其明显客户端只需要理解“我连服务器我跟服务器说话”这一个模型服务器只管转发双方职责单一出bug了你一眼就能看出问题出在客户端还是服务器。3.2 自定义应用层协议两字节头消息体网络编程里最简单的消息协议是“两字节头 消息体”头里记录消息体长度接收方按照这个长度读取完整的消息。对于五子棋消息类型极少消息体长度固定因此可以做得更简单但为了你以后扩展比如加聊天、加悔棋、加认输我建议一开始就设计成通用格式。我的协议定义如下消息头1个字节表示命令类型。消息体变长由具体命令决定。命令类型定义命令值含义消息体MSG_MOVE1落子2字节row, colMSG_GAME_START2对局开始1字节你的棋子颜色MSG_GAME_OVER3对局结束1字节胜负结果MSG_OPPONENT_LEAVE4对手离开无落子消息体固定2字节横纵坐标各1字节所以包长固定为3字节1字节头 2字节体。发送端的封装很简单void sendMove(int sockfd, int row, int col) { char buf[3] {MSG_MOVE, (char)row, (char)col}; send(sockfd, buf, sizeof(buf), 0); }这里有个细节值得多说一句(char)row这种写法在高并发或跨平台项目里会有字节序大小端的坑但对五子棋这个场景完全无所谓因为坐标永远小于25515x15的棋盘用1字节足矣而且我们通信双方跑在同一类机器上。等到你真做跨架构的分布式系统时再操心字节序不迟过早设计就是浪费时间。3.3 粘包与半包处理新手最容易栽的坑TCP是字节流协议它只保证“你发送的字节序列”会按顺序到达目的地但不保证一次send对应对端一次recv。向网络里连续发两个消息接收方可能一次recv就收到两整个包也可能分好几次才收完一个包。我在写第一版的时候就在这个坑里挣扎过有时候对端只收到半条命令解析出来的坐标彻底乱掉棋盘上莫名其妙冒出一颗诡异的子。解决办法是我后来封装了一个“可靠读取N字节”的函数bool recvFixed(int sockfd, char* buf, int len) { int received 0; while (received len) { int ret recv(sockfd, buf received, len - received, 0); if (ret 0) return false; // 连接断开或出错 received ret; } return true; }然后所有消息都用这个函数读完整头部再用recvFixed读消息体。这样无论底层怎么粘包、半包应用层始终能稳定地取出一整条消息。这一步改造完成之后通信逻辑基本就不再出幺蛾子了。4. 实操从零拼出可跑的源码4.1 工程结构与模块划分我推荐拆成三个文件简单清晰适合学习和扩展board.h / board.cpp棋盘数据结构、落子、胜负判定。network.h / network.cppsocket封装、协议收发。client.cpp客户端主流程棋局状态机 用户交互。server.cpp服务器主流程连接管理 转发。逻辑和网络严格分开这样后面想加AI或者想把棋盘换成图形界面都只需要替换对应模块。4.2 服务器端核心实现服务器端的代码量非常小核心逻辑就是三步(1)创建监听socket(2)接受两个客户端连接(3)把一方收到的消息转发给另一方。使用select模型代码大致长这样// 伪代码只展示核心流程 int listen_fd socket(AF_INET, SOCK_STREAM, 0); bind(listen_fd, ...); listen(listen_fd, 2); int clients[2] {-1, -1}; int waiting 0; while (true) { fd_set read_fds; FD_ZERO(read_fds); FD_SET(listen_fd, read_fds); int max_fd listen_fd; for (int i 0; i waiting; i) { if (clients[i] 0) { FD_SET(clients[i], read_fds); max_fd max(max_fd, clients[i]); } } select(max_fd 1, read_fds, nullptr, nullptr, nullptr); // 处理新连接 if (FD_ISSET(listen_fd, read_fds) waiting 2) { clients[waiting] accept(listen_fd, nullptr, nullptr); // 人数凑齐后给双方发送 GAME_START并指定黑棋白棋 if (waiting 2) { sendGameStart(clients[0], BLACK); sendGameStart(clients[1], WHITE); } } // 转发数据 for (int i 0; i waiting; i) { if (clients[i] 0 FD_ISSET(clients[i], read_fds)) { char buf[3]; // 用 recvFixed 读取完整消息 if (recvFixed(clients[i], buf, 3)) { int peer i ^ 1; // 另一个客户端 send(clients[peer], buf, 3, 0); } else { // 对方断开通知对手 char bye MSG_OPPONENT_LEAVE; send(clients[peer], bye, 1, 0); } } } }我在服务器端只处理TCP连接不解析五子棋的业务逻辑。所有关于坐标合法性的判断都在客户端完成服务器只负责搬运字节。这是保持服务器逻辑“无状态”的关键也很适合后续扩展到三人、四人甚至更多人对局只要把转发目标从“另一个客户端”改成“广播给其他人”就行。4.3 客户端核心实现客户端的核心是主循环。我用的逻辑结构是连接服务器。等待MSG_GAME_START确定自己是黑还是白。进入循环如果是我的回合阻塞等待本地输入这里是标准输入或者控制台点击简单的用行列坐标输入即可。用户在终端输入“row col”先做本地合法性检查合法则落子并发送给服务器。非我回合阻塞等待服务器数据收到MSG_MOVE后直接在本地棋盘落子。收到MSG_GAME_OVER就知道输赢收到MSG_OPPONENT_LEAVE则提示对方离开。把“等待输入”和“等待网络”分开最好的方式是用两个线程一个线程等待键盘一个线程阻塞读socket。但线程同步又变复杂了。我在这里用的是一种更“土”但很稳的简化方案轮流判断不引入多线程。while (!game_over) { if (myTurn) { // 等待用户输入 cout 输入落子坐标: ; cin row col; // 简化实际要做合法性检查 applyMove(row, col, myColor); myTurn false; } else { // 等待对方消息 recvMoveFromServer(row, col); applyMove(row, col, opponentColor); myTurn true; } drawBoard(); }这个循环最大的好处是杜绝了“两个人同时落子”的竞态因为每个客户端在同一时刻只可能做一件事要么输入、要么收消息。坏处是一旦消息迟迟不来界面会卡在等待状态但对回合制游戏来说这恰恰是游戏规则本身。4.4 编译运行Windows/Linux环境差异网络代码的跨平台差异主要在头文件、库名和初始化上。Windows下需要winsock2.h并调用WSAStartupLinux下需要sys/socket.h、arpa/inet.h不需要额外初始化。Windows下链接时需要加-lws2_32Linux则不需要。一个能同时适配两个平台的初始化写法#ifdef _WIN32 #include winsock2.h #pragma comment(lib, ws2_32.lib) #else #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h #define SOCKET int #define INVALID_SOCKET -1 #endif void initNetwork() { #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), wsa); #endif }编译命令Linuxg -stdc11 server.cpp -o server g -stdc11 client.cpp board.cpp network.cpp -o clientWindows下用Visual Studio就是新建空项目然后逐个添加源文件或者用MinGW的g命令同样能过。先启动服务器再依次启动两个客户端两个客户端都连上后服务器会给双方发开局消息然后就可以愉快的对战了。5. 常见问题与排查实录5.1 bind失败、端口占用症状服务器启动时报bind failed端口被占用。原因上一个服务器进程没有退出干净或者端口被其他程序占用。解决Windows下用netstat -ano | findstr 8888查到占用进程PID再去任务管理器结束它Linux下用lsof -i:8888或fuser -k 8888/tcp杀掉占用的进程。更省心的做法是换一个高位端口比如8800、9000这种避开常见端口冲突。5.2 连接不上防火墙与IP地址症状服务器本机连接正常但另一台机器连不上。原因第一服务器没监听在0.0.0.0而是监听在127.0.0.1这样外部机器无法访问。第二操作系统的防火墙拦掉了入站连接。解决绑定地址时用INADDR_ANY它表示监听本机所有网卡地址。同时在Windows防火墙里放行对应端口或者把服务器的exe加入白名单。还有一个超级常见的问题直接写了connect到localhost或者127.0.0.1如果服务器和客户端在同一台机器上没毛病但一旦要跟同学联机客户端就必须填服务器的局域网IP可以通过ipconfigWindows或ifconfig/ip addrLinux查看。这个细节相当多人在第一次演示时吃亏。5.3 服务器同时只能连两个玩家第三个怎么处理我在代码里的做法是直接忽略更多的连接请求。但更好的做法是给第三个请求的客户端返回一个“服务器已满”的消息然后断开让用户明确知道不是程序卡了而是对局已经开始了。这个改进很容易做在accept之后判断waiting是否已经等于2等于就直接发个满员消息并close。5.4 网络延迟导致双方棋盘不一致如果你在局域网内测试延迟几乎感觉不到。但如果放到公网上你可能会发现某一方落子后另一方要等一秒钟才看到效果。这是正常的网络延迟不是bug。但对于回合制游戏来说这个延迟一般不影响对局体验只要你的收发逻辑没有混入并发竞态就行。有一个典型错误是客户端在自己的回合里连续点了好几次发送了多个落子消息服务器把这些消息全部转发给了对手对手收到时只能在第一个合法的位置落子其余的只能被拒绝。这也是为什么客户端必须在一次落子后立刻把myTurn置为false并且只在本地判定为合法后才发送。写在最后的一点经验我从这个项目里收获最大的一点是网络编程的难点从来不在“如何调socket API”而在于“网络是不可靠的但你设计的系统必须可靠”。你写的每个recv都可能是半截消息每个send都可能悄无声息地失败每个等待都可能等来永远不来的数据。把这些边界情况全考虑进代码里再回来看五子棋的逻辑你会发现它简直简单得像热身操。如果你照着这个思路写完第一版我非常建议做三件进一步的扩展测试第一把棋盘从15x15改成19x19测试所有代码改动量第二增加悔棋功能观察通信协议的扩展难度第三在一台机器上开服务器、在另外两台机器上开客户端真真切切体验一次局域网对战的完整流程。做完这三件事你对C网络编程、协议设计、状态管理的理解绝对会上一个台阶。这套源码的价值也正是在不断折腾的过程中展现出来的。本文还有配套的精品资源点击获取