VC++ MFC联机五子棋实战:从Socket通信到棋盘逻辑完整实现
2026/9/23 7:07:32 网站建设 项目流程

简介:基于VC++的在线联机五子棋游戏设计与实现源码包,面向C++学习者、课程设计或毕业设计需要联网博弈项目的人群。资源包含完整工程文件与可执行程序,支持双人对战与人机对战两种模式:双人模式由黑白双方鼠标交替落子,先连成五子者获胜并可返回菜单;人机模式中人类执黑先行,AI自动应对,覆盖了棋盘状态管理、落子判断、胜负检测、AI搜索等核心模块。全包共26个文件,以7个cpp源文件、5个h头文件为主体,附带设计报告docx、编译依赖文件depend、工程配置cbp与可直接运行的exe,压缩包仅1.7MB,结构紧凑。已有336人学习使用。通过源码可了解VC++下GDI绘制、鼠标交互、AI决策流程及联机对战逻辑,设计报告辅助理解整体框架,适合作为课设参考或进一步扩展开发的基础。

1. 从 zip 到能联机对战:VC++ 五子棋到底难在哪

从网上某个 VC++ 资源网站下载一份《基于VC++的在线联机五子棋游戏设计与实现.zip》,绝大多数人的第一反应是解压、打开工程、编译,然后在一台电脑上跑两个实例,结果要么连接不上,要么连上了落子乱跳,最后把责任推给“源码有问题”。这类项目的难点从来不在五子棋规则,而在“能连上、能对局、不崩”。整套系统本质是三件事:一个 MFC 对话框界面,一个 C/S 两层的 Socket 通信,一套 15 路棋盘上的落子与判胜逻辑。适合正在做课程设计、毕业设计的人,也适合想用一个小项目把 C++ 网络编程和 Windows 界面编程串起来练一遍的从业者。读完这篇文章,你会得到一份可以直接照着搭的架构、一套能抄的通信协议代码,以及联调两天才能遇到的坑。

2. 先拆三层架构再写代码:C/S 框架、消息协议与工程骨架

2.1 为什么课程设计几乎都用 C/S,而不是点对点

五子棋在线联机,最常见的两种网络模型是:一台服务器、一台客户端(C/S),或者两个客户端直接点对点(P2P)。你从 VC++ 资源网站下载的例程,绝大多数都是前者。原因很实际:C/S 模型天然适合“一局游戏只有一个主持人”的语义,服务器负责创建房间、等待连接、决定先手、广播结果;客户端只负责表现和落子请求。P2P 虽然代码量更少,但必须先解决“谁先开监听、谁去连接”的协商问题,一旦有一方处于内网,UDP 打洞或端口映射会直接把新手劝退。

从模块划分上看,我一般会把整个工程切为三块:

模块职责依赖
界面模块MFC 对话框、棋盘绘制、鼠标落子、状态栏提示仅依赖逻辑模块
逻辑模块棋盘状态、落子合法性、胜负判定不依赖界面与网络
通信模块Socket 监听、连接、收发消息、收发缓冲仅依赖协议头文件

这个划分决定了你能不能在一个晚上把程序调通。如果把“收到消息后直接改界面”“鼠标点击后直接发包”写在一起,初期跑得爽,后期加一个悔棋功能就会把所有代码翻一遍。先写协议,再写逻辑,最后把界面和网络接到逻辑上,这是最省事的路。

2.2 通信协议先行:定长 4 字节的消息结构

在线联机五子棋最核心的通信内容是:谁落子、落在哪里、谁赢了、谁悔棋。为了避免粘包和半包带来的解析地狱,我强烈建议直接把消息设计成定长结构体。网络数据本质是字节流,不带边界;定长意味着收到 4 个字节就是一条完整消息,收到 7 个字节就是一条消息加半条消息,逻辑上非常清晰。

// NetProto.h —— 客户端和服务端共用的协议头文件 #pragma pack(push, 1) // 强制 1 字节对齐,保证结构体大小固定为 4 enum MsgType { MSG_HELLO = 1, // 客户端连上后主动打招呼 MSG_START = 2, // 服务端通知客户端:对局开始,extra=1 表示服务端先手黑棋 MSG_MOVE = 3, // 落子:x、y 为 0~14 的棋盘坐标 MSG_WIN = 4, // 某方获胜:extra=1 表示黑胜,extra=2 表示白胜 MSG_BYE = 5, // 主动退出或掉线通知 }; struct NetMsg { BYTE type; // 消息类型,见 MsgType BYTE x; // 横坐标,0~14 BYTE y; // 纵坐标,0~14 BYTE extra; // 附加参数:先手标记 / 胜负标记 / 保留 }; #pragma pack(pop)

这个结构体的核心参数有三个:type决定消息语义,xy是落子坐标,extra是万金油字段。比如MSG_START里用extra=1表示服务器执黑先行,extra=2表示客户端执黑先行;MSG_WIN里用它表示哪一方获胜。#pragma pack(push, 1)是为了让编译器按 1 字节对齐,保证sizeof(NetMsg)一定是 4,不会因为默认 4 字节对齐而变成 8,否则两端对同一段字节流的解析就会错位。这一点在 VC++6.0 老工程里尤其容易踩,新工程用 VS2017 的默认值也未必是 1。

有了这个 4 字节消息,整个通信模型的复杂度就降低了一大截:不需要解析变长字符串,不需要约定结束符,只需要处理“收到 4 字节整数倍数据”这一种情况。字节序方面,局域网内两端都是 x86 小端,直接用结构体指针读内存即可;如果以后要跨平台,再对端口用htons/ntohs转换,棋盘坐标不受影响。

2.3 工程骨架:两个工程还是一个工程,环境怎么搭

拿到一份五子棋源码,先看它是 VC6.0 工程还是 VS2017 工程。老例程通常包含Server.dspClient.dsp两个工程,新一点的会是一个解决方案里两个.vcxproj。如果只有一份代码,我建议照这个方式拆开:一个对话框程序,通过命令行参数决定自己是服务器还是客户端,或者干脆复制成两个独立工程。一个工程两个模式的缺点是全局变量互相污染,调试时切来切去容易精神分裂。

环境搭建上,直接面对两个高频问题:一是老 VC6 工程在 VS2017 上打开后提示“需要 vc++ 2013 安装”或转换向导;二是编译报错cannot convert from 'const char [N]' to 'CString'。前者是运行库版本问题,后者是字符集问题——新工程默认 Unicode,老代码里AfxMessageBox("hello")会编译失败。我的做法是:属性页里把字符集显式改成“使用多字节字符集”,或者全代码用TCHAR/_T()包字符串。对课程设计来说,改成多字节字符集最快,代码里可以直接写charsprintf,不用到处加_T

然后是 Winsock 初始化,每个进程只需要做一次,放在对话框OnInitDialog里即可:

// ServerDlg.cpp / ClientDlg.cpp 的 OnInitDialog 中 WSADATA wsaData; int nResult = WSAStartup(MAKEWORD(2, 2), &wsaData); if (nResult != 0) { AfxMessageBox(_T("Winsock 初始化失败")); return FALSE; }

MAKEWORD(2, 2)表示请求 2.2 版本的 Windows Socket 实现;返回值非 0 时不要继续往下走,直接提示后退出。很多人忽略这一步,导致后面socket()返回INVALID_SOCKET又找不到原因。理论上 MFC 在CWinApp::InitInstance里也可能隐式初始化,但显式写一次是最稳妥的,新手调错时也能明确知道是哪一步挂了。

3. 棋盘逻辑先单机跑通:数据落子、四向判胜与 MFC 绘图

3.1 棋盘数据结构与落子函数

联机五子棋的棋盘通常有两种选择:15 路或 19 路。课程设计里最常见的是 15 路,对应标准五子棋规则。数据结构不需要任何花哨设计,一个全局二维数组就够了:

// ChessLogic.h const int BOARD_SIZE = 15; // m_board[i][j]:0=空,1=黑棋,2=白棋 extern int m_board[BOARD_SIZE][BOARD_SIZE]; // 落子函数:成功返回 true,失败返回 false 并给出原因 bool PlacePiece(int x, int y, int player) { if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) return false; // 越界 if (m_board[x][y] != 0) return false; // 已有棋子 if (player != 1 && player != 2) return false; // 非法玩家标识 m_board[x][y] = player; return true; }

这个函数的三个参数很直白:xy是棋盘坐标,player是执子方。关键设计点是“把合法性判断集中在一个函数里”。有些同学在鼠标点击事件里写一遍判断,在收到网络消息里又写一遍判断,两次判断逻辑稍有不一致,就会出现“本地看到棋子落下、对面没收到”或“对面发来一个越界坐标导致数组越界崩溃”。只留一个入口,所有落子都必须经过PlacePiece,这是最省心的做法。

坐标系的约定在这里就要定死:我习惯用board[x][y]x对应列(从左到右),y对应行(从上到下)。这样和后面画棋盘时的dc.Ellipse(ORIGIN_X + x * CELL, ...)天然对应,不用在绘图函数里再做一次 x/y 交换。如果你看到某份代码里是board[y][x]也别惊讶,跟着它的绘图函数走就行,但自己的代码里注意不要再混用。

3.2 胜负判定:四方向双倍计数

五子棋判胜逻辑是所有源码里最容易写错的部分。错误典型有两种:只扫了一个方向就返回,或者从棋盘左上角到右下角全盘扫描导致最后一手判断重复。正确做法是:只对最后一手落子的位置,沿四个方向分别向两边延伸统计同色棋子数量,任何方向连成 5 子即胜。

bool CheckWin(int x, int y) { int player = m_board[x][y]; if (player == 0) return false; // 四个方向向量:水平、垂直、主对角线、副对角线 int dir[4][2] = { {1,0}, {0,1}, {1,1}, {1,-1} }; for (int i = 0; i < 4; i++) { int count = 1; // 当前棋子自身 // 正方向延伸 for (int step = 1; step < 5; step++) { int nx = x + dir[i][0] * step; int ny = y + dir[i][1] * step; if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) break; if (m_board[nx][ny] != player) break; count++; } // 反方向延伸 for (int step = 1; step < 5; step++) { int nx = x - dir[i][0] * step; int ny = y - dir[i][1] * step; if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) break; if (m_board[nx][ny] != player) break; count++; } if (count >= 5) return true; } return false; }

这段代码里最需要注意的是反方向扫循环。因为最后一手棋可能落在连珠正中间,比如棋盘上已经有(3,5)(7,5)五颗黑子,最后一手是(5,5),如果只朝一个方向数只能数到 3 个,必须反方向合起来才能判定胜利。count从 1 开始是因为当前棋子已经算一颗。step < 5是优化项,五子棋只需要统计到 5 颗即可,多了没必要扫完整条线。

边界条件是另一个高频翻车点:当落子在棋盘边缘,正方向或反方向会很快越界,所以每个step都要先判断nxny是否合法。如果漏掉这个判断,数组越界可能不会立刻崩溃,但会在某次特定落子时读到脏数据导致误判。我在调试阶段踩过一次:坐标 (0,0) 落子后,正向扫描没问题,反向扫描直接读到了board[-1][0],win 的条件莫名成立,花了半小时才定位到缺了越界判断。

3.3 MFC 绘图与鼠标落子:15 条线加双缓冲

界面部分用 MFC 对话框实现时,核心只有两个函数:OnPaint画棋盘和棋子,OnLButtonDown把鼠标坐标换算成格子坐标并调用落子逻辑。棋盘区域我一般用CStatic控件或直接在对话框上画,关键是设定三个常量:原点坐标ORIGIN_XORIGIN_Y和格子边长CELL

const int ORIGIN_X = 30; // 棋盘左边距 const int ORIGIN_Y = 30; // 棋盘上边距 const int CELL = 30; // 每个格子边长 void CChessDlg::OnPaint() { CPaintDC dc(this); // 画 15x15 网格 for (int i = 0; i < BOARD_SIZE; i++) { dc.MoveTo(ORIGIN_X, ORIGIN_Y + i * CELL); dc.LineTo(ORIGIN_X + (BOARD_SIZE - 1) * CELL, ORIGIN_Y + i * CELL); dc.MoveTo(ORIGIN_X + i * CELL, ORIGIN_Y); dc.LineTo(ORIGIN_X + i * CELL, ORIGIN_Y + (BOARD_SIZE - 1) * CELL); } // 画已经落下的棋子 CBrush brush; for (int x = 0; x < BOARD_SIZE; x++) { for (int y = 0; y < BOARD_SIZE; y++) { if (m_board[x][y] == 0) continue; int cx = ORIGIN_X + x * CELL; int cy = ORIGIN_Y + y * CELL; if (m_board[x][y] == 1) brush.CreateSolidBrush(RGB(0, 0, 0)); // 黑棋 else brush.CreateSolidBrush(RGB(255, 255, 255)); // 白棋 dc.SelectObject(&brush); dc.Ellipse(cx - CELL / 2 + 1, cy - CELL / 2 + 1, cx + CELL / 2 - 1, cy + CELL / 2 - 1); brush.DeleteObject(); } } }

绘制时注意Ellipse的四个参数要留出 1 到 2 像素的间隙,否则相邻两格子的棋子边缘会连在一起。鼠标点击的换算公式是x = (point.x - ORIGIN_X + CELL / 2) / CELL,加CELL / 2是为了让点击在格子边缘时也能就近吸附到最近的交叉点。

void CChessDlg::OnLButtonDown(UINT nFlags, CPoint point) { int x = (point.x - ORIGIN_X + CELL / 2) / CELL; int y = (point.y - ORIGIN_Y + CELL / 2) / CELL; if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) return; // 点击棋盘外 // 合法且轮到我方时才落子;网络部分稍后补充 if (PlacePiece(x, y, m_myPlayer)) { Invalidate(FALSE); // 只重绘客户区,不擦除背景,减少闪烁 // 这里后续会调用 SendMsg(MSG_MOVE, x, y, 0) } }

Invalidate(FALSE)是低成本的防闪烁手段,FALSE表示不先擦除背景。对课程设计来说,这比OnEraseBkgnd返回TRUE再加内存 DC 的做法简单得多,效果也够用。等单机逻辑全部验证通过,再把这个函数里的注释替换成真正的发包调用。

4. Socket 联机从连接到对局:先手约定、收包缓存与断线处理

4.1 服务端:CAsyncSocket 与 OnAccept/OnReceive

网络部分有两种主流写法:直接用 WinSock API 的socket()+bind()+listen()+accept(),或者用 MFC 封装的CAsyncSocket。我见过大量课程设计代码用阻塞 socket 加recv()死等,这有一个致命问题:对话框 UI 线程会被recv()卡死,对方不掉线你就永远等不到消息,窗口拖动都变得迟滞。传统解法是开一个工作线程做recv,然后用PostMessage通知主线程;但既然是 VC++/MFC 项目,更优雅的做法是用CAsyncSocket,它在后台通过窗口消息机制回调OnReceive,不阻塞 UI 线程。

// 服务端监听 socket 派生类 class CListenSocket : public CAsyncSocket { public: virtual void OnAccept(int nErrorCode) override; }; void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode != 0) return; CAsyncSocket* pClient = new CAsyncSocket(); if (Accept(*pClient)) { // 把新连接交给对话框管理,用成员变量保存 theApp.m_pMainDlg->OnNewConnection(pClient); } else { delete pClient; } }

调用流程是:先在对话框OnInitDialogCreate(5600)监听端口 5600,然后Listen();当有客户端connect进来,框架自动调用OnAccept,此时调用Accept取回一个已连接的 socket。注意Accept传入的必须是一个新的CAsyncSocket对象,不能在原监听 socket 上直接收发数据。theApp那行是示意,实际代码里你可以用一个全局指针或者把对话框指针传进监听类,只要保证OnAccept里能访问到主对话框即可。

发送消息的代码我封装成一个通用函数,所有消息类型都走同一条路:

bool SendMsg(CAsyncSocket& sock, const NetMsg& msg) { int nSent = sock.Send(&msg, sizeof(NetMsg)); return nSent == sizeof(NetMsg); }

CAsyncSocket::Send与阻塞 socket 的send一样,不能保证一次把 4 个字节全部发出,极端情况下可能只发出去 1 字节。对课程设计这种局域网环境,4 字节消息一次发完的概率极高,但严谨起见你应该写一个循环,把没发完的剩余字节继续发。这个小细节不会影响演示,但面试官追问时能答上来会加分。

4.2 客户端:连接、握手与收到 START 才能落子

客户端同样派生自CAsyncSocket,重写OnReceiveOnClose。连接动作放在对话框的“连接”按钮事件里:

void CClientDlg::OnBnClickedConnect() { // 假设界面上有一个 IP 编辑框 m_strIP 和端口编辑框 m_nPort UpdateData(TRUE); m_sockClient.Create(); // 客户端 socket 不需要绑定端口 if (!m_sockClient.Connect(m_strIP, m_nPort)) { AfxMessageBox(_T("连接失败,请确认服务端已启动")); } }

Connect是异步的,返回值只代表“连接发起成功”,不代表“已经连上”。真正的连接结果由OnConnect回调返回,但课程设计里通常没人深究这一步,只要服务端已启动,Connect几乎都成功。连接上之后客户端要立刻发送一条MSG_HELLO,服务端收到HELLO后回复MSG_START,客户端收到START才真正进入对局状态。这套握手机制防止了“界面已经是棋盘,但对端什么时候准备好都不知道”的灰色状态。

4.3 先手约定与轮次切换:避免两个人同时落子

联机五子棋能不能玩,很大程度上取决于“先手逻辑”有没有设计清楚。我看过的失败代码里,最常见的错误是让“连接发起方”先手,或者干脆双方都能随时落子,结果两个人都能下,棋盘上出现黑白交叉的非法局面。正确做法是:先手完全由服务端决定,并通过MSG_START消息中的extra字段告知客户端。

// 服务端收到 MSG_HELLO 后 NetMsg msg; msg.type = MSG_START; msg.x = 0; msg.y = 0; msg.extra = 1; // 1=服务端执黑先行 SendMsg(m_sockClient, msg); // 客户端收到 MSG_START 后 if (msg.extra == 1) { m_myPlayer = 2; // 服务端黑棋,客户端白棋 m_myTurn = false; // 对方先走 } else { m_myPlayer = 1; m_myTurn = true; }

轮次判断直接在鼠标落子函数里加一道闸:不是自己的回合,点击棋盘没反应,状态栏显示“等待对方落子”。收到MSG_MOVE后,先判断消息里的坐标是否是空位,再调用PlacePiece落子,然后m_myTurn = true并刷新界面。这里有个事情必须想清楚:MSG_MOVE到达本方时,说明对方已经完成落子,接下来就该你走。这段逻辑写在OnReceive里,不要写在OnLButtonDown里。

4.4 收包缓存与半包/粘包处理

这是网络部分最核心的一段代码。前面用定长 4 字节结构体,已经避免了“按行读”“按结束符拆”的复杂性,但recv/OnReceive仍然不保证一次正好收到 4 字节。你需要一个字节缓存区,把收到的新数据追加到尾巴,然后从头扫描有没有完整的NetMsg

// 每个 socket 连接维护一个缓存区 BYTE m_recvBuf[1024]; // 缓存区 int m_recvLen = 0; // 当前缓存里有效字节数 void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode != 0) return; // 1. 先把网络数据读进临时缓冲区 BYTE temp[256]; int nRead = Receive(temp, sizeof(temp)); if (nRead == 0) { OnClose(0); // 收到 0 字节说明对端关闭 return; } if (nRead == SOCKET_ERROR) { int err = GetLastError(); if (err != WSAEWOULDBLOCK) OnClose(0); return; } // 2. 追加到缓存尾部(注意不要越界) if (m_recvLen + nRead > sizeof(m_recvBuf)) { // 实际工程中应扩容;课程设计里清空重来即可 m_recvLen = 0; return; } memcpy(m_recvBuf + m_recvLen, temp, nRead); m_recvLen += nRead; // 3. 从缓存中解析完整消息 int offset = 0; while (m_recvLen - offset >= (int)sizeof(NetMsg)) { NetMsg* pMsg = (NetMsg*)(m_recvBuf + offset); // 在这里根据 pMsg->type 分发:落子、开始、胜负、退出 ProcessMsg(*pMsg); offset += sizeof(NetMsg); } // 4. 把剩余不完整的字节移到头部,下次继续 if (offset > 0) { memmove(m_recvBuf, m_recvBuf + offset, m_recvLen - offset); m_recvLen -= offset; } }

这段代码有三个关键点。第一,读完temp后要立即memcpy追加到缓存,不能在temp上直接解析,因为temp是临时数组,下次Receive会覆盖它。第二,while循环处理粘包:对端连续发来两个MSG_MOVE,一次OnReceive可能同时收到 8 字节,循环能依次拆出两条。第三,memmove处理半包:如果收到 5 字节,前 4 字节解析完,剩下 1 字节就是下一条消息的开头,要挪到数组头部,等待下次Receive再补 3 字节凑齐。这里不能用memcpy,因为源和目的地址可能重叠,memmove才能保证正确。

5. 联机五子棋常见问题排查:5 个高频坑与定位顺序

5.1 现象:棋盘上突然多出若干颗乱掉的棋子,或坐标错乱

原因:半包与粘包没有处理。很多人直接在OnReceive里拿到多少字节就按照NetMsg*强转解析,如果这次只收到 2 字节,结构体里的xy就会是上次缓存里的残留数据;如果一次收到 8 字节,就只解析了第一条,第二条被覆盖掉。

解决:用第 4.4 节的缓存追加 + 循环解析方案,把所有解析逻辑收敛到ProcessMsg。现象一旦出现,先在每次Receive后把收到的字节数、解析出的消息类型打印到调试窗口或写入日志文件,盯两局就能确认是否粘包。

5.2 现象:服务端关闭程序后,客户端窗口假死或卡住

原因:如果客户端用的是阻塞recv(),它会一直等待数据,服务端 socket 关闭后,recv()返回 0 或SOCKET_ERROR,但阻塞线程如果没处理退出信号,就会空转或死循环。即便用CAsyncSocket,如果OnClose里没有关闭socket句柄并释放资源,也会出现“连接已断开但程序毫无反应”。

解决:统一在OnClose里做四件事:调用Close()关闭 socket、把指向该 socket 的指针置空、更新界面状态栏为“对方已断开”、弹一次AfxMessageBox提示。另外在发送MSG_BYE之后,发送方延迟 1 秒再关闭 socket,避免消息还在系统缓冲区里就被 RST 包打断。

5.3 现象:两个实例连上了,但只有一方能落子,另一方点击无反应

原因:先手状态没有正确传递,或者某一方m_myTurn初始值就是false,也没有在收到MSG_START时更新。常见于复制粘贴代码时漏掉了MSG_START的消息分发分支。

解决:按 4.3 节的方式,把“谁先手”固化到MSG_STARTextra字段中。在服务端和客户端各写一条日志,记录“发送 START(extra=1)”和“收到 START(extra=1)”两行,对照日志定位是谁没收到消息,还是在ProcessMsg里漏写了分发。这属于逻辑 bug 而不是网络问题,用日志比断点好使。

5.4 现象:程序在自己机器上能跑,复制到教室电脑上报 0xc000007b 或缺 dll

原因:缺少对应的 VC++ 运行库。用 VS2017 编译的工程在目标机器上需要安装 vc++ 2015-2022 运行库,VC6.0 老程序需要 vc++ 2005/2008 运行库。教室电脑常年不更新,很容易中招。

解决:两个方案,选一个就行。方案一是把工程属性里的“运行库”从“动态库 (/MD)”改成“静态库 (/MT)”,这样 MFC 和 CRT 全部静态链进 exe,单文件复制即可运行;方案二是在发布包里附带对应版本的 vc++ 运行库安装包。对课程设计答辩来说,方案一最省事,但 exe 体积会从几百 KB 涨到几 MB,属正常现象。

5.5 现象:OnReceive只被调用一次,之后再收不到消息

原因:OnReceive是基于窗口消息机制的,如果你在OnReceive里做了耗时操作,比如Sleep、弹MessageBox、或者执行了一个大循环,窗口消息泵被阻塞,后续的通知消息根本进不来。另一个常见原因是 socket 对象被提前销毁,回调时访问到野指针。

解决:OnReceive里只做“读数据 + 存缓存 + 解析 + 更新简单状态”,所有界面刷新用Invalidate触发重绘,不要弹框。如果要弹“你赢了”这样的提示,用PostMessage发自定义消息给主窗口,等OnReceive返回后主窗口再弹框。我在最初版本里直接在OnReceive里调了AfxMessageBox,结果第二个人赢的提示弹完后,第三手落子就再也不触发了,折腾了一个晚上才反应过来是消息泵卡死。

6. 验证三连与进阶方向:从回环测试到 AI 补全

拿到代码之后不要急着联机,先按三个层次做验证,每一层都能暴露不同类别的问题。

第一步是单机逻辑验证。在OnInitDialog里加一个调试开关#define LOCAL_TEST 1,让鼠标落子时不经过网络,直接调用PlacePieceCheckWin。把第 3 章的判胜函数故意摆出各种形状测试:横五、竖五、左斜五、右斜五、边缘五子。这一步能隔离掉全部棋盘逻辑 bug。

第二步是回环测试。在同一台电脑上运行两个实例,服务端监听 5600,客户端连接127.0.0.1。回环测试通过说明协议设计、消息解析、先手切换逻辑都没有大问题。如果连回环都不通,把第 4 章的收发日志打开,逐条核对MSG_HELLOMSG_STARTMSG_MOVE的顺序和内容。

第三步是跨机器测试。关掉 Windows 防火墙,或者手动添加 5600 端口的放行规则,两台电脑连同一个局域网。跨机器失败的第一嫌疑是防火墙,第二嫌疑是 IP 填错,用服务端 IPipconfig查到的 IPv4 地址,不要填虚拟机地址。如果服务端能OnAccept但客户端收不到数据,大概率还是防火墙拦截了入站数据。

以上都跑通之后,这个项目已经具备完整对局能力。再往上走,我建议按这个顺序补功能:悔棋协议(MSG_UNDO+ 应答、双方各限一次)、认输按钮(MSG_LOSE)、30 秒无操作超时判负(SetTimer+OnTimer)、断线重连(客户端记录棋盘现场,连上后服务端回放)。AI 方面可以做一个最简单的“打分表”算法:对每个空位扫描横竖斜四个方向的活二、活三、冲四,累加一个分值,分值最高的位置就是下一步,这个难度不高但效果明显,适合毕业设计拿来当亮点。

这个项目我做下来最大的体会是:网络联机五子棋最难的不是五子棋,而是“双方状态一致”。协议定长、先手归服务端、收包进缓存,这三个习惯帮我避开了至少十个晚上的返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询