MFC跳棋游戏源码拆解:从对话框到Socket联机对战
简介经典中国跳棋游戏完整工程源码包面向想要理解棋盘类游戏开发全流程的入门与进阶学习者特别是对 C 桌面程序设计感兴趣的人群。包内源码覆盖棋盘与棋子的二维数组表示、直线/斜线跳及连续跳跃规则、胜负判断、界面交互与游戏循环等核心模块并提供 MFC 对话框程序框架可在此基础上扩展人机对战、存档读档等功能。资源共 43 个文件主要包含 9 个头文件、7 个 C 源文件、11 个位图及 4 个光标图标素材、3 个文本说明和若干工程配置文件整体约 215KB轻量但结构完整。已有 223 人学习下载适合用于课程设计或自学练习通过阅读和修改工程代码可以直观掌握数据结构的实际应用、GUI 事件处理逻辑以及搜索剪枝等基础算法思想是一份麻雀虽小五脏俱全的入门级实战参考资料。1. 为什么一份跳棋游戏源代码.zip 值得拆开看我在电脑上解压这份“跳棋游戏源代码.zip”时第一眼看到的是 Ttq、Tzq、ttqDlg、MySocket 四个模块名交错的一批 C 文件既有 .dsp/.dsw 这种 VC6 时代工程文件也有 .rc、resource.h 这类 Windows 资源脚本。它不是教科书里那种几百行的控制台小游戏而是一个带图形界面、能响应鼠标、还能用 Socket 联机对弈的 MFC 项目。对想搞懂 Windows 桌面程序消息循环、对话框框架、自绘控件和网络通信底层的人这包源码比一个纯算法库更有价值你能从它身上同时看到界面和规则是怎么咬合在一起的。下面我按“结构、规则、交互、改造”的顺序把它拆开。2. 先读懂 MFC 跳棋工程的模块边界和棋盘数据2.1 文件清单其实是三条线索拿到解压后的目录先别急着双击 .dsw。把文件分成几条线索会更清晰以 Ttq 开头的是应用程序启动代码ttqDlg 是对话框主界面Tzq 是棋盘游戏逻辑MySocket 是网络层。这四个模块加上 resource.h、ttq.rc就是一个典型的 MFC 对话框应用骨架。请看下表。文件组角色定位值得关注的代码点Ttq.cpp / TTQ.HCWinApp 派生应用类InitInstance 里弹出主对话框ttqDlg.cpp / ttqDlg.h主对话框类窗口绘制与消息响应OnPaint、OnLButtonDown、OnTimerTzq.cpp / Tzq.h棋盘状态与走法判断二维数组棋盘、跳跃检测函数MySocket.cpp / MySocket.hCSocket 派生类连接、收发包、字节序处理NewGame.cpp / NewGame.h新游戏初始化棋盘复位、双方棋子位置摆放AboutDialog.cpp / AboutDialog.h关于对话框演示对话框资源如何关联类不要小看这个映射。很多人在网上找源码只看 .cpp 里有哪些函数却漏掉了 .rc 与 resource.h 之间通过 IDD_XXX 常量建立的关联。主对话框的 OnInitDialog 能存在是因为 ttqDlg.cpp 里的 CDialog 派生类绑定了资源 ID而游戏画布没有采用自定义控件只是把对话框的 OnPaint 重绘成棋盘位图。也就是说代码里没有独立的 CView 类所有交互都压在一个对话框上。2.2 棋盘到底用一维还是二维数组表示源码里棋盘最可能怎么存常见做法是像 ttqDlg 的私有成员一样声明一个二维数组。它直观、便于判断边界和对称布局。MFC 工程里通常用 int 数组表示不同棋子的归属0 表示空位1 和 2 表示两个玩家也可以用 -1 表示边界外。const int BOARD_ROWS 8; const int BOARD_COLS 8; class Tzq { public: int m_board[BOARD_ROWS][BOARD_COLS]; void NewGame() { for (int r 0; r BOARD_ROWS; r) { for (int c 0; c BOARD_COLS; c) { m_board[r][c] 0; // 自己这一方的初始棋子在靠近底部的几行 if (r BOARD_ROWS - 3) m_board[r][c] 1; else if (r 3) m_board[r][c] 2; } } } };代码逻辑不复杂外层循环遍历行内层循环遍历列先统一清零再按行号划出上下两个阵营的初始区域。这样做的目的是把“布置棋子”和“重置一局”复用同一段代码避免在两处各写一份初始化逻辑。参数说明BOARD_ROWS 和 BOARD_COLS 决定了棋盘边界也是后面所有走法检测的越界判断依据。如果把棋盘改成 10x10只需要修改常量但要注意 Tzq.cpp 里可能硬编码了 8 个方向的跳跃偏移因此棋盘维度变化会直接影响跳跃检测函数。我一般会替它加一个边界宏 IS_IN_BOARD(r,c)后续处理斜线交替时能少写很多 if。2.3 对话框资源、位图和双缓冲继续往下看 ttq.rc 和 resource.h通常会有一张附加的棋盘背景位图或一组棋子坐标常量。MFC 的对话框自绘很容易闪烁因为 OnPaint 里如果直接把背景和棋子分别画到位图上再一次性 BitBlt 到窗口就能避免每秒多次刷新时出现残影。这里我给出一个最简的双缓冲写法适合对照源码阅读。void CTtqDlg::OnPaint() { CPaintDC dc(this); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bitmap; bitmap.CreateCompatibleBitmap(dc, m_rect.Width(), m_rect.Height()); CBitmap* pOld memDC.SelectObject(bitmap); DrawGrid(memDC); // 画棋盘线 DrawPieces(memDC); // 按 m_board 画棋子 dc.BitBlt(0, 0, m_rect.Width(), m_rect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }这里的 DrawGrid 和 DrawPieces 是我补出来的函数名源码里可能叫 DrawBoard 或 DrawCurrentState但不影响理解先把所有内容画到内存设备上下文 memDC最后一次 BitBlt 刷新到窗口。因为跳棋落子后需要重绘的行数有限连窗口区域刷新范围都可以用 InvalidateRect(m_rectLastMove) 控制而不是每次 Invalidate 整个客户区。3. 跳棋核心规则与棋盘状态检测的实现3.1 先划分“移动”和“跳跃”两种动作跳棋的落子动作可以分成两类一类是直接移动到相邻的空位另一类是跳过对方的棋子落到对面的空位如果满足条件还能连续跳。很多初读源码的人会在这段逻辑卡住因为直接移动和跳跃在界面上的表现不一样直接移动是按下目标位置就落地跳跃则要同时检查被跳过的棋子是否属于对手。一个清晰的判断流程是把移动合法性拆成三个独立的问题目标位置是否在棋盘内且为空当前位置和目标位置之间是否隔着另一个格子被隔开的格子是否是对方的棋子这三个问题一旦拆开代码的可读性会提升很多。源码的 Tzq.cpp 里最能学到的就是这一点它没有把所有条件堆在一个 if 里而是把“路径上有没有敌人”单独提出来处理。参数变化动作类型合法性判断abs(dr)1 abs(dc)1直接移动只要求目标格为空abs(dr)2 abs(dc)0垂直跳中间格有对方棋子abs(dr)0 abs(dc)2水平跳中间格有对方棋子abs(dr)2 abs(dc)2对角跳中间格有对方棋子其他非法直接返回 falsebool Tzq::IsValidMove(int fromR, int fromC, int toR, int toC) { if (!InBoard(toR, toC)) return false; // 目标位置如果不是空位任何走法都不成立 if (m_board[toR][toC] ! BLANK) return false; int dr toR - fromR; int dc toC - fromC; if (abs(dr) 1 abs(dc) 1) return true; // 直接移动只允许走到相邻空格 // 跳跃模式行和列方向各跨越两个单位 if (abs(dr) 2 abs(dc) 2) { int midR (fromR toR) / 2; int midC (fromC toC) / 2; return m_board[midR][midC] ! m_board[fromR][fromC] m_board[midR][midC] ! BLANK; } return false; }这段代码只处理一步跳跃却把规则的骨架立住了。参数说明fromR/fromC 是当前棋子坐标toR/toC 是用户点击的目标坐标midR/midC 是被越过棋子的中间坐标。判断条件里 abs(dr) 和 abs(dc) 同时等于 2表示这是一次对角或直线方向上的跳跃且恰好越过一个格子如果 abs(dr) 和 abs(dc) 一个等于 2、一个等于 0同样成立所以上面的代码不会漏掉直线跳。为了让落子逻辑真正可用还需要在调用层区分“直接移动”和“跳跃动作”。如果 from 和 to 之间的曼哈顿距离大于 2IsValidMove 返回 false调用方就不再向下执行。这种设计的优点是规则函数足够干净不牵扯任何界面坐标换算单元测试时可以直接喂坐标断言。3.2 用深度优先搜索处理连续跳跃连续跳跃是跳棋代码里最容易出错的地方。用户明明只点了一下但如果棋子落在新位置后还能继续跳界面必须保持棋子处于“选中”状态并把可跳路径提示出来。实现连续跳跃的常见做法是在鼠标第一次点击棋子后先收集所有合法跳跃点落子后再次收集只要结果不为空就继续等待下一次点击。收集跳跃点的函数一般写成深度优先搜索避免人为规定一个最常见的跳法。void Tzq::CollectJumpCandidates(int r, int c, std::vectorMove out) { static const int DIRS[8][2] { {-2,-2}, {-2,0}, {-2,2}, {0,-2}, {0,2}, {2,-2}, {2,0}, {2,2} }; for (int i 0; i 8; i) { int nr r DIRS[i][0]; int nc c DIRS[i][1]; if (!IsValidMove(r, c, nr, nc)) continue; Move mv; mv.fromR r; mv.fromC c; mv.toR nr; mv.toC nc; out.push_back(mv); } }这个收集函数只有一层循环但已经能覆盖所有一步跳跃的候选。如果要在连跳时避免同一个位置被反复跳到需要给 DFS 增加一个 visited 标记并保存路径如下所示。void Tzq::FindAllJumps(int r, int c, int step, bool visited[8][8], std::vectorMove path, std::vectorstd::vectorMove routes) { bool hasNext false; static const int DIRS[8][2] { {-2,-2}, {-2,0}, {-2,2}, {0,-2}, {0,2}, {2,-2}, {2,0}, {2,2} }; for (int i 0; i 8; i) { int nr r DIRS[i][0]; int nc c DIRS[i][1]; if (!InBoard(nr, nc) || visited[nr][nc]) continue; if (m_board[nr][nc] ! BLANK) continue; int midR (r nr) / 2; int midC (c nc) / 2; if (m_board[midR][midC] BLANK) continue; if (m_board[midR][midC] m_board[r][c]) continue; hasNext true; visited[nr][nc] true; Move mv; mv.fromR r; mv.fromC c; mv.toR nr; mv.toC nc; path.push_back(mv); FindAllJumps(nr, nc, step 1, visited, path, routes); path.pop_back(); visited[nr][nc] false; } if (!hasNext !path.empty()) routes.push_back(path); }这段代码是标准的回溯每跳一步把中间被越过的棋子视作可跨越对象但不在棋盘上真的删除它路径存到容器里回退时恢复 visited。参数说明step 只用于调试路径长度visited 防止形成环path 保存当前这条分支的 Move 序列routes 最终包含所有可能的连跳路线。真正的游戏程序通常只取 routes 的前几条用于高亮提示否则棋盘上五六个跳跃分支会显示成一团乱线。3.3 从 Tzq 到界面层的错误处理在 MFC 对话框工程里Tzq 负责规则判断ttqDlg 负责把鼠标坐标映射到 r/c。用户最常见的误操作是不选中棋子就直接点空白区域或者点中己方棋子的同时又落在另一颗己方棋子上。源码中一般会在这些检查点用返回值或抛出 AfxMessageBox 提示。我更推荐返回一个枚举值例如 MOVE_OK、MOVE_EMPTY_SELECTION、MOVE_OCCUPIED_DEST而不是返回 int 让人猜含义。少量改动就能让联机对弈时对端收到的移动指令永远是经过校验的合法指令避免出现“对方棋盘上的棋子少了”这类同步错误。4. 从消息响应到网络对弈把坐标变成指令4.1 三种坐标系在 Windows 跳棋程序中的作用在 ttqDlg.cpp 里鼠标落点和棋盘格子的换算比规则还要容易写错。因为屏幕坐标、客户区坐标、棋盘网格坐标、棋盘二维数组坐标是四套东西很多二次开发的人乱用设备坐标导致棋子画偏。一般会在对话框的构造函数或 OnInitDialog 里算好棋盘左上角 (m_originX, m_originY) 和每格边长 m_cellSize然后用下面的公式换算。坐标系典型来源用途屏幕坐标GetCursorPos全局定位基本不用客户区坐标OnLButtonDown 的 lParam和窗口客户区左上角对齐网格坐标手动画出的棋盘格子决定棋子图贴到哪里数组坐标m_board[r][c]规则判断的唯一依据公式很简单数组行 r (y - m_originY) / m_cellSize数组列 c (x - m_originX) / m_cellSize。反过来画棋子时像素 x m_originX c * m_cellSize m_cellSize / 2。有些源码会写死棋盘左边距这样在窗口拉伸后棋子就会错位因为对话框没有 OnSize 处理。4.2 OnLButtonDown 里应当出现的完整链路一次正常的落子经过的代码路径是用户按下鼠标触发 WM_LBUTTONDOWNMFC 把它路由到 CTtqDlg::OnLButtonDown函数里先判断点击的格子属于谁再判断是否满足 Tzq 的走法规则最后更新数组并 InvalidateRect。如果是联机模式还要把移动指令通过 MySocket 发给对方。void CTtqDlg::OnLButtonDown(UINT nFlags, CPoint point) { int r (point.y - m_originY) / m_cellSize; int c (point.x - m_originX) / m_cellSize; if (!m_game.InBoard(r, c)) { CDialogEx::OnLButtonDown(nFlags, point); return; } // m_selectedR 0 表示当前还没有选中棋子 if (m_selectedR 0) { if (m_game.At(r, c) m_currentPlayer) { m_selectedR r; m_selectedC c; Invalidate(); // 高亮选中棋子 } } else { if (m_game.At(r, c) m_currentPlayer) { // 换选己方另一枚棋子而不是走棋 m_selectedR r; m_selectedC c; } else if (m_game.IsValidMove(m_selectedR, m_selectedC, r, c)) { m_game.Move(m_selectedR, m_selectedC, r, c); m_selectedR m_selectedC -1; m_currentPlayer 3 - m_currentPlayer; Invalidate(); } } CDialogEx::OnLButtonDown(nFlags, point); }代码说明m_selectedR/m_selectedC 保存当前选中棋子初始设置成 -1 表示没有选中。第一次点击自己的棋子只是选中第二次点击如果落在合法目标位就执行移动并交换玩家。这个逻辑写进 MFC 消息响应里规则层不用知道任何控件信息。注意 m_currentPlayer 用 3 - m_currentPlayer 来切换 1 和 2是这类两玩家棋盘程序里常见的位运算技巧直观且不易写错。4.3 MySocket 如何承担联机跳棋的数据包文件清单里的 MySocket.h / MySocket.cpp 是网络对战的入口。它基于 MFC 的 CSocket 派生所以 read/write 对开发者的体验接近对称的流式套接字。网络对局中只需要两种消息握手消息和落子消息。落子消息里至少要包含 fromR、fromC、toR、toC 和一个校验号。为了保证不同机器字节序一致发送前要把 short/int 明确打包CSocket 本身不保证这点。struct MovePacket { unsigned short magic; // 固定 0xA55A用于校验 unsigned char cmd; // 0x01 表示落子 unsigned char player; signed char fromRow; signed char fromCol; signed char toRow; signed char toCol; }; void MySocket::SendMove(int fromRow, int fromCol, int toRow, int toCol) { MovePacket pkt; pkt.magic 0xA55A; pkt.cmd 0x01; pkt.player m_selfPlayer; pkt.fromRow (signed char)fromRow; pkt.fromCol (signed char)fromCol; pkt.toRow (signed char)toRow; pkt.toCol (signed char)toCol; // 逐字节发送而不是直接把 C 结构体扔进 Send // 避免成员对齐和字节序导致对端解析错误 char buf[9]; buf[0] (char)(pkt.magic 8); buf[1] (char)(pkt.magic 0xFF); buf[2] (char)pkt.cmd; buf[3] (char)pkt.player; buf[4] pkt.fromRow; buf[5] pkt.fromCol; buf[6] pkt.toRow; buf[7] pkt.toCol; buf[8] 0x00; // 保留字段 Send(buf, sizeof(buf)); }这里的重点不是代码本身而是数据包定义方式。很多 C 初学者喜欢把 struct 直接写入 socket这在本机测试可能没问题一旦跨平台结构体对齐和大小端会立刻暴露。采用 MovePacket 也是因为这个它约束了每个字段的字节位置接收方按相同顺序解析就不依赖编译选项。接收端的 MySocket 还要维护一个环形缓冲区防止一次收包被拆成两次收到的情形。这是所有基于 TCP 的 MFC 游戏最容易忽略的边界问题。4.4 把网络帧接到规则层联机跳棋不是把鼠标移动到坐标发给对方就完事了接收方必须做二次校验。即使发送方已经校验过接收方也要在自己的棋盘状态上调用一遍 IsValidMove再把合法移动应用到自己的 m_board 上否则会出现“两个客户端各自维护的状态不一致”的问题。因此接收函数里不要直接改 m_board而是先临时保存原坐标校验失败就输出一条调试日志。这个思路不仅适用于跳棋源码也适用于任何需要联机同步的棋类程序。5. 给这份跳棋源码加一个可用的 AI 对手并完成实战调优5.1 用最小可行 Minimax 替代随机走棋拿到这份源码之后最常被问到的需求是“怎么让电脑跟人玩”。原工程很可能只做了热座双人和 Socket 双人因为 MySocket 的存在已经说明联机是主推功能。但若要加 AI一个最简且不至于太慢的方案是深度限制为 3 层的 Minimax并加一条评估函数——计算我方棋子到敌方营地的曼哈顿距离之和。搜索分支过多时优先裁剪掉前进方向为负的走法。int Evaluate(const int board[BOARD_ROWS][BOARD_COLS], int player) { int score 0; for (int r 0; r BOARD_ROWS; r) for (int c 0; c BOARD_COLS; c) { if (board[r][c] player) score 100 - (r c); // 越靠近目标营地分值越高 else if (board[r][c] 3 - player) score - 100 - (r c); } return score; }这个评估函数故意写得非常简单因为跳棋棋子没有吃子数量可以统计只能靠位置评分。改动时不要只调节步长要同时考虑左右镜像如果己方在下方目标方向是上(r c) 越小越好也可以直接用 (BOARD_ROWS - r) 代替 r。参数含义是 player 自己3 - player 是对方。一般来说评估函数不动超过三行AI 就可以从“乱跳”变成“知道往前方跳”。5.2 源码编译前必须处理的三个坑MFC 项目从旧版 VC6 移植到新版 Visual Studio会先撞上三个与棋盘无关的坑。第一个是字符集默认的 MBCS 工程在新版会被提示转换成 Unicode建议直接在项目属性里把字符集改为“使用多字节字符集”避免改写所有 CString 的宽窄字符调用。第二个是 _WIN32_WINNT 版本没有定义导致某些网络 API 不可见显式在 stdafx.h 里加一句#ifndef _WIN32_WINNT #define _WIN32_WINNT 0x0601 #endif第三个坑是 Debug 目录。源码包自带的 Debug 文件夹往往是别人机器上编译出来的中间产物发布时要删干净否则 VC 可能误用旧的 .obj 文件导致断点命中不了修改后的代码。我一般会执行一次“生成 → 清理解决方案”再改代码这样能过滤掉因为过期调试信息引起的各种诡异表现。5.3 验证棋盘逻辑的快速方法不打开界面就能验证核心规则才是这次拆源码收益最大的地方。在 Tzq.cpp 里加一个静态测试函数构造一组已知坐标断言 IsValidMove 的返回值。例如把两个玩家棋子摆在 (4,4) 和 (5,5)让 (3,3) 为空调用 CollectJumpCandidates(4,4) 必须能找到一步到 (6,6) 的路线。这个测试跑通了才去考虑怎么画棋盘、怎么接 Socket。验证完再把这个函数放进 MFC 的 InitInstance 里临时调用比每次全流程点鼠标慢得多。提示断点可以打在 IsValidMove 的 return 语句上用“即时窗口”输出 m_board[fromR][fromC] 和 m_board[midR][midC] 的当前值能在不写日志的情况下快速定位是越过棋子判断出了问题还是目标位置被其它棋子占住。本文还有配套的精品资源点击获取