ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

VC++联机五子棋实战:Winsock网络通信与MFC棋盘同步

VC++联机五子棋实战:Winsock网络通信与MFC棋盘同步 简介这是一份面向C与VC初学者、课程设计及毕业设计学生的五子棋游戏开发资料围绕在线联机对战与人机博弈两个核心场景展开帮助读者理解Windows窗口编程、鼠标交互与简单AI搜索算法的落地方式。压缩包共26个文件约1.7MB以cpp源文件与h头文件为主体辅以o编译中间文件、cbp工程配置、layout界面布局、docx设计报告及可直接运行的exe程序源码与可执行文件并存便于对照调试。功能上支持双人鼠标交替落子、人机对战中人类执黑先行、AI自动应对并在连成五子后判定胜负、返回菜单。已有336人学习下载读者可从中获取完整工程结构、棋型评估与搜索模块的拆分思路、设计报告参考以及编译运行排错经验适合作为C综合练习与游戏开发入门案例。1. 从单机棋盘到联机对局VC 五子棋项目到底在解决什么问题很多人第一次看到“基于VC的在线联机五子棋游戏设计与实现”这个标题脑子里浮现的是 MFC 画个棋盘、鼠标点两下、判断五子连珠就完事。真动手才发现单机五子棋和联机五子棋之间隔着一整套网络通信、状态同步和断线处理的工程问题。单机版你只需要一个二维数组、一个胜负判断函数、一个重绘逻辑联机版你要面对的是两台机器上的棋盘状态如何保持一致、谁先手、落子消息怎么发、对方掉线了怎么办、网络延迟导致的操作顺序错乱怎么兜底。这些才是这个项目真正的技术含量所在。这篇文章面向的是想用 VC 做一套能跑起来的联机五子棋的开发者——不管你是课程设计需要还是想拿一个完整项目练手 Windows 网络编程和图形界面。我会把技术选型、通信协议设计、棋盘渲染、胜负判定、联机同步、调试排错这条链路拆开讲清楚每一步给出可复现的做法和参数。你不需要事先精通 Winsock 或 MFC但需要有一点 C 基础和 Visual Studio 的使用经验。读完你应该能自己搭出一套局域网内可对战的双人五子棋并且知道哪些地方容易翻车、怎么提前规避。2. 技术选型与工程骨架VC 做联机五子棋用什么搭2.1 界面框架选 MFC 还是 Win32 裸写VC 做 Windows 桌面游戏界面常见的选择有三个MFC、Win32 API 裸写、以及 Qt。Qt 严格来说不算纯 VC 技术栈而且部署时要带一堆 DLL对于“VC 项目”这个定位来说不太对味。Win32 裸写窗口和消息循环代码量大新手容易在 WndProc 里绕晕。MFC 是折中方案——它封装了窗口创建、消息映射、GDI 绘图同时仍然是纯 VC 体系Visual Studio 直接支持编译出来不依赖额外运行时静态链接的话。我一般会选 MFC 的对话框程序Dialog-based而不是单文档/多文档。原因很直接五子棋界面就是一个固定大小的棋盘加几个按钮和状态栏不需要文档/视图架构那套东西。对话框程序建起来快控件拖拽方便消息映射清晰。在 VS2017 或更高版本里新建项目时选“MFC 应用程序”应用程序类型选“基于对话框”其余默认即可。有一点要注意VS2017 之后的版本默认可能没装 MFC 组件。如果你新建项目时找不到 MFC 模板打开 Visual Studio Installer修改当前版本在“单个组件”里勾选“适用于最新 v143 生成工具的 C MFC”。这个坑很常见尤其是用社区版的同学。2.2 网络通信选 TCP 还是 UDP五子棋是回合制游戏每一步落子都必须可靠到达对方不能丢。丢一步棋整个棋局就废了。所以传输层选 TCP不选 UDP。TCP 保证有序、可靠、面向连接正好匹配回合制对局的通信需求。UDP 的低延迟优势在五子棋场景里毫无意义——你落一子的延迟从 1ms 变成 50ms玩家根本感知不到但丢一个包就是灾难。Winsock 2.2 是 Windows 下 TCP 编程的标准接口VC 直接可用。通信模型上两人对战用最简单的 C/S 架构一方做服务端创建监听 socket等待连接另一方做客户端主动连接服务端。服务端负责维护权威棋盘状态客户端落子后发请求给服务端服务端校验合法性后广播给双方。这种“服务端权威”模式比“对等协商”模式简单得多也更容易处理冲突。如果你想让任意一方都能先手可以在连接建立后由服务端随机或协商决定执黑方。但棋盘状态的权威源始终在服务端客户端只负责发送操作请求和渲染服务端同步过来的状态。2.3 工程目录结构与依赖清单一个干净的工程结构能让后续调试省很多事。我通常这样组织GomokuOnline/ ├── GomokuOnline.sln ├── GomokuOnline/ │ ├── GomokuOnline.h // 主头文件包含全局定义 │ ├── GomokuOnline.cpp // 应用入口 │ ├── GomokuDlg.h / .cpp // 主对话框承载棋盘和控件 │ ├── BoardLogic.h / .cpp // 棋盘逻辑落子、胜负判断 │ ├── Network.h / .cpp // 网络模块Winsock 封装 │ ├── Protocol.h // 通信协议定义 │ └── res/ // 资源文件依赖方面只需要系统自带的 ws2_32.lib。在项目属性 → 链接器 → 输入 → 附加依赖项里加上 ws2_32.lib或者在代码里用#pragma comment(lib, ws2_32.lib)自动链接。不需要任何第三方库。提示如果你用的是 VS2017 且编译时报“无法打开包括文件 winsock2.h”检查 Windows SDK 是否安装完整。通常安装 VS 时勾选“Windows 10 SDK”即可。3. 棋盘逻辑与胜负判定从二维数组到五子连珠的完整实现3.1 棋盘数据结构与落子合法性校验五子棋棋盘标准是 15×15 交叉点。用二维数组int board[15][15]表示0 表示空位1 表示黑子2 表示白子。这个数组放在服务端作为权威状态客户端也维护一份用于渲染但客户端的数组只读——所有修改都来自服务端同步。落子合法性校验看起来简单但有几个容易漏掉的点。第一目标位置必须为空第二必须轮到当前玩家第三游戏未结束。前两条是基本要求第三条很多人忘了——如果一方已经五连另一方还能继续落子逻辑就乱了。// BoardLogic.h #pragma once #include vector const int BOARD_SIZE 15; const int EMPTY 0; const int BLACK 1; const int WHITE 2; class BoardLogic { public: BoardLogic(); // 尝试落子返回是否成功 bool PlaceStone(int row, int col, int player); // 判断指定位置落子后是否形成五连 bool CheckWin(int row, int col, int player) const; // 获取棋盘状态 int GetCell(int row, int col) const; // 重置棋盘 void Reset(); // 当前是否已有胜者 bool HasWinner() const { return m_winner ! EMPTY; } int GetWinner() const { return m_winner; } private: int m_board[BOARD_SIZE][BOARD_SIZE]; int m_winner; };// BoardLogic.cpp #include pch.h #include BoardLogic.h BoardLogic::BoardLogic() { Reset(); } void BoardLogic::Reset() { memset(m_board, 0, sizeof(m_board)); m_winner EMPTY; } bool BoardLogic::PlaceStone(int row, int col, int player) { // 边界检查 if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return false; // 位置必须为空 if (m_board[row][col] ! EMPTY) return false; // 游戏已结束则不允许继续落子 if (m_winner ! EMPTY) return false; m_board[row][col] player; if (CheckWin(row, col, player)) { m_winner player; } return true; } int BoardLogic::GetCell(int row, int col) const { if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return -1; return m_board[row][col]; }PlaceStone的参数含义row和col是 0 起始的棋盘坐标player是 BLACK 或 WHITE。返回值表示落子是否被接受。注意这里没有做“轮到谁”的判断——那个逻辑放在上层对局管理里因为棋盘逻辑本身不应该关心回合顺序。3.2 五连判定的四个方向扫描胜负判定是五子棋的核心算法。判断逻辑是在刚落子的位置沿四个方向水平、垂直、主对角线、副对角线分别统计同色连续棋子数任意方向达到或超过 5 个即获胜。bool BoardLogic::CheckWin(int row, int col, int player) const { // 四个方向水平、垂直、主对角线、副对角线 const int dx[] { 0, 1, 1, 1 }; const int dy[] { 1, 0, 1, -1 }; for (int dir 0; dir 4; dir) { int count 1; // 包含当前落子 // 正方向延伸 for (int step 1; step 5; step) { int nr row dx[dir] * step; int nc col dy[dir] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (m_board[nr][nc] ! player) break; count; } // 反方向延伸 for (int step 1; step 5; step) { int nr row - dx[dir] * step; int nc col - dy[dir] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (m_board[nr][nc] ! player) break; count; } if (count 5) return true; } return false; }方向数组dx和dy的组合含义(0,1)是水平向右(1,0)是垂直向下(1,1)是主对角线左上到右下(1,-1)是副对角线右上到左下。每个方向从落子点向两侧各延伸最多 4 步加上自身共最多 9 个位置统计连续同色数量。只要某个方向达到 5 就返回胜利。这里有个细节循环上界写的是step 5意味着每个方向最多检查 4 步。加上当前子一个方向最多覆盖 9 个位置。对于标准五子棋恰好五连获胜长连不算额外奖励这个范围足够。如果你要做“长连禁手”之类的规则需要额外判断 count 是否恰好等于 5。3.3 棋盘渲染GDI 绘制与双缓冲防闪烁MFC 对话框上画棋盘用 GDI 就够了。核心是在OnPaint里绘制网格线、星位、棋子。但直接画会闪烁——每次落子重绘整个棋盘屏幕会抖。解决办法是双缓冲先在内存 DC 上画好整幅图再一次性 BitBlt 到屏幕 DC。void CGomokuDlg::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(rect); // 创建内存 DC 和兼容位图 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rect.Width(), rect.Height()); memDC.SelectObject(bmp); // 背景填充 memDC.FillSolidRect(rect, RGB(220, 179, 92)); // 绘制棋盘网格 DrawBoard(memDC); // 绘制所有棋子 DrawStones(memDC); // 一次性输出到屏幕 dc.BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); }DrawBoard里计算网格间距假设棋盘区域从(MARGIN, MARGIN)开始每个格子边长CELL_SIZE则第 i 条竖线 x 坐标是MARGIN i * CELL_SIZE横线类似。星位天元、四隅在(3,3)、(3,11)、(7,7)、(11,3)、(11,11)处画小圆点。DrawStones遍历board数组对每个非空位置画圆。黑子用FillSolidRect或Ellipse填充黑色白子填充白色加灰色边框。棋子半径约为CELL_SIZE * 0.4。双缓冲的关键是CreateCompatibleDC和CreateCompatibleBitmap必须在每次 OnPaint 时创建并在结束时释放否则会内存泄漏。如果你觉得频繁创建开销大可以在对话框初始化时创建一次缓存起来在OnSize时重建。4. 联机通信协议与状态同步Winsock 实战4.1 自定义应用层协议的消息格式TCP 是字节流没有消息边界。你发两次数据对方可能一次收到也可能分三次收到。所以必须自定义应用层协议来划分消息边界。最简单的做法是“长度前缀 消息体”每条消息前 4 个字节是消息体长度网络字节序后面跟消息体。消息体内部再用一个字节表示消息类型后面跟具体数据。我定义以下几种消息类型消息类型值方向数据内容MSG_JOIN1客户端→服务端玩家昵称MSG_JOIN_ACK2服务端→客户端分配的颜色1或2MSG_MOVE3客户端→服务端row(1B) col(1B)MSG_MOVE_BROADCAST4服务端→双方row(1B) col(1B) player(1B)MSG_GAME_OVER5服务端→双方winner(1B)MSG_RESTART_REQ6客户端→服务端无MSG_RESTART_ACK7服务端→双方无MSG_CHAT8双向文本内容// Protocol.h #pragma once #include winsock2.h #pragma comment(lib, ws2_32.lib) const int MSG_JOIN 1; const int MSG_JOIN_ACK 2; const int MSG_MOVE 3; const int MSG_MOVE_BROADCAST 4; const int MSG_GAME_OVER 5; const int MSG_RESTART_REQ 6; const int MSG_RESTART_ACK 7; // 发送带长度前缀的消息 inline bool SendMessage(SOCKET sock, const char* data, int len) { // 先发 4 字节长度网络字节序 int netLen htonl(len); if (send(sock, (char*)netLen, 4, 0) ! 4) return false; // 再发消息体 int sent 0; while (sent len) { int ret send(sock, data sent, len - sent, 0); if (ret 0) return false; sent ret; } return true; } // 接收一条完整消息返回消息体长度失败返回 -1 inline int RecvMessage(SOCKET sock, char* buffer, int bufSize) { int netLen 0; int received 0; // 先收 4 字节长度 while (received 4) { int ret recv(sock, ((char*)netLen) received, 4 - received, 0); if (ret 0) return -1; received ret; } int msgLen ntohl(netLen); if (msgLen 0 || msgLen bufSize) return -1; // 再收消息体 received 0; while (received msgLen) { int ret recv(sock, buffer received, msgLen - received, 0); if (ret 0) return -1; received ret; } return msgLen; }SendMessage和RecvMessage是网络模块的基础设施。注意send和recv都不保证一次传输完所有字节所以必须循环直到发完或收完。htonl和ntohl处理大小端问题——Windows 是小端网络字节序是大端不转换的话跨平台通信会出错。4.2 服务端监听与客户端连接流程服务端流程初始化 Winsock → 创建 socket → bind 到端口 → listen → accept 等待客户端 → 连接建立后进入消息循环。客户端流程初始化 Winsock → 创建 socket → connect 到服务端地址 → 进入消息循环。// 服务端初始化 WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), wsaData); SOCKET listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in serverAddr; serverAddr.sin_family AF_INET; serverAddr.sin_addr.s_addr INADDR_ANY; // 监听所有网卡 serverAddr.sin_port htons(9527); // 端口号 bind(listenSock, (sockaddr*)serverAddr, sizeof(serverAddr)); listen(listenSock, 2); // 最多两个客户端排队 // 等待客户端连接 SOCKET clientSock accept(listenSock, nullptr, nullptr);端口号选 9527 只是个人习惯你可以用 1024 以上的任意未占用端口。INADDR_ANY表示监听本机所有网络接口局域网内其他机器可以通过你的 IP 连接。如果只想本机测试用inet_addr(127.0.0.1)。客户端连接SOCKET sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in serverAddr; serverAddr.sin_family AF_INET; serverAddr.sin_addr.s_addr inet_addr(192.168.1.100); // 服务端 IP serverAddr.sin_port htons(9527); if (connect(sock, (sockaddr*)serverAddr, sizeof(serverAddr)) SOCKET_ERROR) { // 连接失败提示用户检查 IP 和端口 AfxMessageBox(_T(连接服务端失败请检查 IP 地址和端口)); return; }4.3 落子同步与回合控制的服务端权威模式服务端权威模式的核心原则客户端不直接修改本地棋盘所有落子必须发给服务端服务端校验通过后再广播给双方双方收到广播后才更新本地棋盘并重绘。服务端维护一个BoardLogic实例和当前回合变量currentPlayer。收到MSG_MOVE后检查发送方是否是当前回合玩家调用board.PlaceStone(row, col, player)校验并落子如果成功构造MSG_MOVE_BROADCAST发给双方如果board.HasWinner()再发MSG_GAME_OVER切换currentPlayer// 服务端处理落子请求 void HandleMove(SOCKET fromSock, int row, int col) { int player GetPlayerBySocket(fromSock); if (player ! currentPlayer) { // 不是你的回合忽略 return; } if (!board.PlaceStone(row, col, player)) { // 非法落子忽略 return; } // 广播落子消息 char buf[3] { (char)row, (char)col, (char)player }; BroadcastMessage(MSG_MOVE_BROADCAST, buf, 3); if (board.HasWinner()) { char winBuf[1] { (char)board.GetWinner() }; BroadcastMessage(MSG_GAME_OVER, winBuf, 1); } else { currentPlayer (currentPlayer BLACK) ? WHITE : BLACK; } }客户端收到MSG_MOVE_BROADCAST后更新本地棋盘数组并调用Invalidate()触发重绘。注意客户端本地也要调用PlaceStone来更新数组但不需要再做胜负判断——胜负结果由服务端的MSG_GAME_OVER通知。这种模式的好处是即使客户端因为网络延迟导致操作顺序错乱服务端也会拒绝非法请求最终状态始终以服务端为准。坏处是每次落子都要等一个网络往返局域网内延迟通常小于 5ms体验上无感知。5. 联机五子棋的避坑与排查那些让你调一晚上的问题5.1 连接成功但收不到消息粘包与半包现象客户端 connect 成功服务端 accept 也返回了但双方就是收不到对方发的消息或者收到乱码。原因TCP 粘包。发送方连续发了多条消息接收方一次 recv 把多条消息的数据全收到了但代码只按一条消息解析剩下的数据丢了。或者接收方 recv 的缓冲区太小一条消息被截断。解决严格按“长度前缀 消息体”的协议来收发。每次 recv 先确保收到完整的 4 字节长度再根据长度收消息体。不要假设一次 recv 就是一条完整消息。上面的RecvMessage函数已经处理了这个问题关键是循环 recv 直到收满指定字节数。5.2 界面卡死在主线程里阻塞 recv现象程序运行后界面点不动像死机一样但网络数据其实在收发。原因在 UI 线程里直接调用阻塞的recv导致消息循环被卡住Windows 消息无法处理界面自然不响应。解决把网络接收放在独立线程里。用AfxBeginThread或std::thread创建一个接收线程循环调用RecvMessage收到消息后通过PostMessage把数据投递到 UI 线程处理。不要在工作线程里直接操作 UI 控件。// 接收线程函数 UINT RecvThreadProc(LPVOID pParam) { CGomokuDlg* pDlg (CGomokuDlg*)pParam; char buffer[1024]; while (pDlg-m_bConnected) { int len RecvMessage(pDlg-m_sock, buffer, sizeof(buffer)); if (len 0) { pDlg-m_bConnected FALSE; pDlg-PostMessage(WM_CONNECTION_LOST, 0, 0); break; } // 把收到的数据拷贝到堆上通过 PostMessage 传给 UI 线程 char* pData new char[len]; memcpy(pData, buffer, len); pDlg-PostMessage(WM_NETWORK_DATA, (WPARAM)pData, (LPARAM)len); } return 0; }UI 线程在OnNetworkData消息处理函数里解析数据、更新棋盘、重绘。注意pData要在处理完后delete[]否则内存泄漏。5.3 编译报错“无法解析的外部符号 __imp__WSAStartup”现象代码里调用了WSAStartup、socket、send、recv等函数编译时提示无法解析的外部符号。原因没有链接 ws2_32.lib。解决在项目属性 → 链接器 → 输入 → 附加依赖项里添加ws2_32.lib或者在任意一个 .cpp 文件里加#pragma comment(lib, ws2_32.lib)。另外确保包含的是winsock2.h而不是旧的winsock.h两者有冲突。如果同时包含了windows.h和winsock2.h要把winsock2.h放在前面或者定义WIN32_LEAN_AND_MEAN宏。5.4 对方落子后本地棋盘不更新现象服务端广播了落子消息客户端也收到了但棋盘上没显示新棋子。原因客户端更新了棋盘数组但没触发重绘或者重绘时用的还是旧数据。解决收到MSG_MOVE_BROADCAST后先更新本地board数组然后调用Invalidate()或InvalidateRect()触发OnPaint。如果用了双缓冲确认OnPaint里读取的是更新后的数组。另一个常见问题是消息处理函数里更新了数组但OnPaint里画的是另一个副本——检查是不是有两个棋盘数组实例。5.5 断线后程序崩溃或卡死现象一方关闭程序或网络断开另一方程序无响应或弹出一堆错误。原因recv返回 0 或负数时没有正确处理继续在无效 socket 上操作。或者接收线程退出时没有通知 UI 线程。解决RecvMessage返回 -1 时立即标记连接断开关闭 socket通过PostMessage通知 UI 线程显示“对方已断开”。UI 线程收到后禁用落子操作显示重连或退出选项。不要在 socket 已关闭的情况下继续调用send或recv。6. 从能跑到好用联机五子棋的进阶技巧与验证方法6.1 用本地回环测试联机逻辑在找两台机器测试之前先在本机用回环地址验证通信逻辑。服务端 bind 到127.0.0.1客户端也连127.0.0.1开两个程序实例一个做服务端一个做客户端。这样可以排除局域网防火墙、IP 配置等干扰因素专注调试协议和逻辑。验证步骤启动服务端 → 启动客户端 → 客户端应显示“已连接等待分配颜色” → 服务端分配颜色后双方棋盘初始化 → 黑方落子 → 双方棋盘同步显示 → 白方落子 → 直到一方五连 → 双方显示胜负结果。每一步都用调试输出或日志确认消息收发正常。6.2 加一个简单的日志系统方便排查联机程序出问题时光看界面很难定位是发送端没发、还是接收端没收、还是解析错了。在关键路径加日志输出能省大量时间。void Log(const CString msg) { CString logFile _T(gomoku_log.txt); CStdioFile file; if (file.Open(logFile, CFile::modeCreate | CFile::modeNoTruncate | CFile::modeWrite)) { file.SeekToEnd(); CString line; line.Format(_T([%s] %s\n), CTime::GetCurrentTime().Format(_T(%H:%M:%S)), msg); file.WriteString(line); file.Close(); } }在SendMessage和RecvMessage调用前后各加一条日志记录消息类型和长度。出问题时打开日志文件一眼就能看出消息是在哪一步断掉的。6.3 用 Wireshark 抓包验证协议格式如果日志显示消息发出去了但对方没收到或者收到的数据不对可以用 Wireshark 抓本地回环包选 Loopback 接口或局域网包。过滤条件设tcp.port 9527看 TCP 流里实际传输的字节。对照你的协议定义检查长度前缀是否正确、消息类型字节是否匹配、数据内容是否符合预期。这一步能发现很多“代码看起来对但实际发出去不对”的问题比如htonl忘了加导致长度字段字节序反了或者send的缓冲区指针偏移算错了。6.4 重连与观战两个值得做的扩展方向基础版本跑通后可以考虑两个扩展。一是断线重连客户端断开后保留对局状态重新连接时发送MSG_REJOIN带上之前的玩家标识服务端恢复该玩家的棋盘视图。二是观战模式服务端接受第三种连接类型只接收广播不发送落子请求用于旁观对局。这两个扩展都需要在协议里增加消息类型并在服务端维护更复杂的状态机。建议先把基础双人对战跑稳定再逐步加功能。我自己的习惯是每加一个功能就用回环测试跑一遍完整对局确认没有回归问题再继续。做联机程序最深的教训是不要相信“应该没问题”。网络编程里每一个假设都可能被现实打脸——你以为一次 send 能发完你以为对方一定按顺序收到你以为断线会立刻被发现。每一个“以为”都要用代码去验证、去兜底。把边界情况处理好了程序才真正能用。希望帮到你。本文还有配套的精品资源点击获取
返回列表