简介基于MFC框架的双人五子棋完整工程已配好VS2019编译环境适合有一定C基础、正在学习Windows界面编程或博弈算法实现的开发者。程序使用纯图形界面支持黑白双方轮流落子、自动判断胜负、悔棋以及棋局的保存与打开胜负判断覆盖横、竖、斜多个方向悔棋和存档便于对局复盘下载后可直接编译运行。压缩包共19个文件其中6个.h头文件用于类与接口声明3个.cpp实现主对话框逻辑、功能调用与程序入口同时包含Visual Studio解决方案与工程配置sln/vcxproj以及RC界面定义、图标、光标和背景图等资源整体仅502KB工程按MFC对话框程序组织棋盘绘制、落子响应、胜负检测等模块拆分清晰方便定位与借鉴。已有3444人学习下载读者可学习MFC消息映射、鼠标坐标到棋盘格子的换算、五子棋连珠判定算法以及棋局状态保存与恢复方法从空白对话框到可运行的双人对战程序关键知识点均有对应代码可查无论用作MFC课程设计、毕业设计还是游戏编程入门都能节省从零搭建工程的时间并可进一步扩展人机对战或网络对战功能。1. 这块“双人五子棋”能带给你什么先看清它的边界如果你在 VS2019 里新建一个 MFC 单文档项目然后顺手双击一个网上找来的 .sln大概率会先撞上一堵墙一堆“无法解析的外部符号”或者干脆连 MFC 库都没装。而“MFC双人五子棋(VS2019编译通过)”这个标题真正值钱的地方不在于五子棋本身有多难而在于它把 MFC 的消息循环、GDI 绘图、鼠标交互、二维数组状态管理这四件事用一个足够小的程序串了起来。换句话说这是一份能让你在一晚上之内完整走通“从建工程到玩得上手”的 MFC 入门路线图。这个项目适合两类人一类是刚学完 C 语法、想看看 Windows 桌面程序到底怎么组织代码的学生另一类是被 MFC 的文档和宏绕晕、急需一个“最小可运行骨架”来对照的初级开发者。它不涉及网络对战、AI 落子或复杂动画双人模式意味着所有逻辑都集中在本地窗口内部——这反而让问题域变得干净你只需要关心“点击棋盘 → 判断落子 → 检查胜负 → 重绘界面”这一条主链路。整篇文章会沿着这个链路拆解先把 MFC 单文档工程怎么搭讲清楚再把落子与胜负判断的关键实现逐段过一遍最后用一章节专门讲我踩过的编译和运行坑。我不打算复刻某个具体作者的源码因为网上流传的版本千差万别有的基于对话框、有的基于单文档视图、有的甚至用到了双缓冲但没处理擦除背景。我给出的实现路径是 MFC 五子棋项目里最主流、最不容易出问题的一种CView 派生类承载棋盘绘制C 数组存储棋局状态鼠标消息驱动落子。你照着这个骨架改比直接抄一份看不懂的代码要靠谱得多。2. 从工程模板到首盘对局VS2019 下的框架与配置2.1 为什么选“单文档视图”而不是“对话框”很多初学者在 VS2019 里创建 MFC 项目时会被向导里的三个选项卡住单个文档、多个文档、基于对话框。双人五子棋看似不需要文件操作用对话框似乎更简单但实际做下来你会发现对话框方案在棋盘绘制上非常别扭。对话框的资源编辑器是把控件摆上去而棋盘需要的是一个可以自由响应鼠标、随时重绘的区域你最终仍然要在对话框上放一个自绘控件或者直接处理对话框的 WM_PAINT这绕了一圈反而增加了复杂度。我推荐选择“单个文档”视图架构因为在 MFC 单文档程序里CView 派生类天生就是一个可绘制的窗口。你重写它的 OnDraw或 OnPaint成员函数就能把棋盘和棋子画上去你处理它的鼠标消息就能把点击坐标换算成棋盘行列。再加上文档模板提供的框架窗口你连菜单、工具栏、窗口关闭这些外围代码都不用自己写VS2019 的向导会一次性生成好整个工程骨架这比你手搓一个窗口类省掉至少两百行样板代码。创建步骤在 VS2019 里很直接新建项目 → 搜索“MFC 应用” → 项目名称随便起一个比如 FiveInARow→ 在应用程序类型里选“单个文档” → “MFC 标准”视觉样式和“Visual Studio”主题都保持默认即可。值得手动确认的一个选项是“生成的文件”里勾选了“MFC 标准”类这样向导会生成我们需要的 MainFrame、View、Doc 三个派生类。2.2 编译前必查的三处环境配置标题里强调“VS2019 编译通过”这句话背后的潜台词是VS2019 默认安装不一定带 MFC 库。MFC 是独立的可选组件如果你在安装 Visual Studio 时只勾了“使用 C 的桌面开发”而没有展开右侧的“适用于 v142 生成工具的 MFC 组件”那项目的 include 和 lib 路径都是空的编译时你会看到一堆“无法打开包括文件: afxwin.h”或者“无法解析的外部符号”错误。所以第一步先确认自己装没装这个组件没装就在 Visual Studio Installer 里勾上然后点修改。第二个常见坑是工具集版本。VS2019 默认使用 v142 工具集但如果你的电脑上还装了 VS2015 或 VS2017某些老旧的 MFC 工程文件.vcxproj会显式指定 v140 或 v141。此时你需要右键项目 → 属性 → 常规 → 平台工具集手动选成“Visual Studio 2019 (v142)”。这个动作在编译通过的路上几乎是必做的因为网上很多 MFC 代码是几年前甚至十几年前写的当时的工程文件并不会自动适配新工具集。第三个配置项是字符集。VS2019 的 MFC 向导默认使用 Unicode 字符集而网上流传的很多老代码用的是多字节字符集。如果代码里写了 五子棋 这样的字符串字面量并直接传给 MFC 的窗口标题函数在 Unicode 工程下编译可能会出现类型不匹配的警告或错误。我的习惯是直接把项目属性 → 常规 → 字符集设为“使用 Unicode 字符集”然后代码里所有字符串都用 CString 或 _T() 宏包裹这样不管你拿到的是哪份老代码改起来最省事。2.3 一张配置对照表帮你少走弯路下面的表格总结了我在多个 MFC 项目里反复用到的关键配置项你可以直接照抄到自己的工程里。它不是 VS2019 特有的但用在 VS2019 上最省心。配置项推荐值说明平台工具集Visual Studio 2019 (v142)老工程必须手动改否则可能出现 C 标准库不匹配字符集使用 Unicode 字符集新工程默认值字符串用 _T() 包裹MFC 的使用在共享 DLL 中使用 MFC调试方便不用每次编译一堆静态库运行时库多线程调试 DLL (/MDd)与 MFC 共享 DLL 配套Debug 模式默认值Windows SDK 版本10.0最新已安装如果装了多个版本选最新的即可C 语言标准默认C14MFC 项目不需要更高的标准代码反而更兼容以上配置里最核心的其实是第一项和第二项。工具集决定了编译器和头文件版本字符集决定字符串宏如何展开。这两步做完一个干净的单文档 MFC 工程就能正常编译了棋盘逻辑完全是花括号和普通 Win32 API 调用的组合不会涉及复杂的 C 模板。3. 棋局是怎么跑起来的核心逻辑的消息流与关键代码3.1 棋盘状态的数据结构一个二维数组就够了五子棋的局面本质上就是“哪个位置有哪方棋子”所以用 C 的二维数组来表示棋盘状态是最直接的做法。我习惯定义棋盘大小为 15×15标准五子棋棋盘每个格子有三种状态0 表示空1 表示黑棋2 表示白棋。在视图类的头文件里你会看到类似这样的声明// BoardView.h 中声明棋局数据 public: int m_board[15][15]; // 15×15 棋盘状态0空 1黑 2白 int m_currentPlayer; // 当前落子方1黑 2白 bool m_gameOver; // 是否已有胜负结果防止游戏结束后继续落子这段代码的意图很简单但有几个细节值得说明。m_board 用固定大小而非动态分配是为了避免内存管理和越界问题15×15 总共 225 个 int 占不到 1KB完全谈不上浪费。m_currentPlayer 用来在每次落子后切换对局方落子前先看它等于多少就知道当前该画黑棋还是白棋。m_gameOver 这个 bool 变量是我后来加上的因为不加的话游戏分出胜负后玩家继续点击棋盘程序还会往里落子画面上就会出现“赢家旁边又冒出一颗棋”的诡异情况。在视图类的构造函数里你需要把这些成员变量初始化用 memset 把 m_board 全部置 0m_currentPlayer 设为 1黑棋先手m_gameOver 设为 false。这正是 C 里最经典的做法。如果想做得更规范一点也可以把这段初始化逻辑放进文档类的 OnNewDocument 里但程序规模不大时放在构造函数里完全够用。3.2 鼠标点击到落子的坐标换算边界处理是重头戏当你在棋盘上用鼠标点一下MFC 视图会收到 WM_LBUTTONDOWN 消息。你的任务是把鼠标的屏幕坐标相对于窗口客户区换算成棋盘的行列号。这部分最容易出错的地方不在算法而在边界格子的取舍。如果鼠标点到两格之间的线缝上到底算哪一格我的做法是以棋盘的起点坐标 BOARd_LEFT 为基准用“经纬线间距”做除法取整然后判断余数是否落在安全范围内。核心处理函数长这样// 在 View 类中处理鼠标左键按下消息 void CBoardView::OnLButtonDown(UINT nFlags, CPoint point) { // 第一步把屏幕坐标换算成棋盘行列 int col (point.x - BOARD_LEFT BOARD_PADDING) / CELL_SIZE; int row (point.y - BOARD_TOP BOARD_PADDING) / CELL_SIZE; // 第二步越界检查防止数组越界写入 if (col 0 || col BOARD_SIZE || row 0 || row BOARD_SIZE) return; // 第三步检查该位置是否已有棋子已有则忽略本次点击 if (m_board[row][col] ! 0) return; // 第四步游戏结束时不允许继续落子 if (m_gameOver) return; // 第五步落子并切换玩家 m_board[row][col] m_currentPlayer; m_currentPlayer (m_currentPlayer 1) ? 2 : 1; // 第六步触发窗口重绘让新棋子显示出来 Invalidate(); CView::OnLButtonDown(nFlags, point); }这个函数里最需要解释的是坐标换算的细节。BOARD_LEFT 和 BOARD_TOP 是棋盘第一条经纬线的像素坐标CELL_SIZE 是相邻两条线之间的像素间距。point.x - BOARD_LEFT 算出鼠标到棋盘左边界线的偏移量再除以间距就能得到格子序号。加 BOARD_PADDING 的目的是处理一个实际体验问题棋盘最边上的格子与窗口边缘如果贴得太近玩家点到窗口边缘以外时会得到负数的行列值加个内边距可以让误差范围内的模糊点击也落进合法区域。在真正写代码之前你需要先定好这些常量。我常用的一组值是窗口客户区 640×640棋盘左上角初始坐标 BOARD_LEFT 30, BOARD_TOP 30格子间距 CELL_SIZE 40棋盘 15×15 一共 15 条经纬线实际占用的像素范围是 30 到 30 14×40 590四周留出 30 像素边距。这套参数的优点是 40 像素的格子间距对鼠标点击的容错很好手抖一下也不太容易点错格子。3.3 胜负判断中心扩展法的实现与剪枝五子棋的胜负只有四种线方向横线、竖线、左斜线、右斜线。网上有一些用八方向循环的版本但我在实践中更推荐从落子点向四个方向分别延伸的做法。因为每次落子后只需要检查刚落下的这颗棋子是否形成五连其他位置的棋子不用管检查量非常小。核心代码用一个独立的成员函数实现传入刚落子位置的行列号和该位置棋子的颜色值1 或 2返回值是 bool// 判断 (row, col) 位置的颜色值为 color 的棋子是否形成五连 bool CBoardView::CheckWin(int row, int col, int color) { // 四个方向水平、垂直、主对角线、副对角线 int dirs[4][2] { {0, 1}, // 水平向右 {1, 0}, // 垂直向下 {1, 1}, // 主对角线右下 {1, -1} // 副对角线左下 }; for (int i 0; i 4; i) { int count 1; // 当前落点本身算一颗 // 正方向延伸 for (int step 1; ; step) { int nr row dirs[i][0] * step; int nc col dirs[i][1] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (m_board[nr][nc] ! color) break; count; } // 反方向延伸 for (int step 1; ; step) { int nr row - dirs[i][0] * step; int nc col - dirs[i][1] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (m_board[nr][nc] ! color) break; count; } if (count 5) return true; } return false; }这段代码的思路是从落子点出发先往方向数组定义的正向走走不动或遇到不同色的棋子就停下然后往反向走两端的长度连上落子点本身就是当前方向上这颗棋子连成的实际长度。count 的初始值是 1因为落子那一颗本身就在连子里。这段代码没有用递归也没有用状态压缩就是最简单的循环因为五子棋判定只需要数数不需要更复杂的数据结构。调用它的地方在 OnLButtonDown 的落子逻辑之后。你需要在切换当前玩家之前先检查刚落下的棋子是否赢了if (CheckWin(row, col, m_board[row][col])) { m_gameOver true; AfxMessageBox(_T(游戏结束黑方获胜)); // m_board[row][col] 1 时为黑方 }按下确认键后窗口关闭程序收尾。这部分因为逻辑直白几乎不存在性能问题。整个棋盘 225 个格子的状态遍历只是 n^2 级别连 1 毫秒都用不到所以你在 OnDraw 里每一帧都从头画棋盘、画棋子也和性能优化沾不上边。3.4 重绘OnDraw 里画棋盘和棋子GDI 对象要善用MFC 视图类在窗口需要刷新时调用 OnDraw所以你所见到棋盘上的线、网格、棋子全部是在 OnDraw 函数里用 GDI 画笔和画刷绘制的。网上有的版本会在这个函数里写一大坨代码把画棋盘、画黑子、画白子全塞进去看得人头大。我更习惯把绘制分成两个独立小函数DrawBoard 负责画网格线DrawPiece 负责画单个棋子OnDraw 只是负责调它们。下面是 OnDraw 的精简实现它会在窗口初始显示和每次 Invalidate 之后被调用void CBoardView::OnDraw(CDC* pDC) { // 画背景默认系统背景即可也可以自己填白色 CRect rcClient; GetClientRect(rcClient); CBrush bgBrush(RGB(240, 210, 140)); // 木黄色棋盘底色 pDC-FillRect(rcClient, bgBrush); // 画棋盘网格线 CPen linePen(PS_SOLID, 1, RGB(0, 0, 0)); CPen* pOldPen pDC-SelectObject(linePen); for (int i 0; i BOARD_SIZE; i) { // 横线 pDC-MoveTo(BOARD_LEFT, BOARD_TOP i * CELL_SIZE); pDC-LineTo(BOARD_LEFT (BOARD_SIZE - 1) * CELL_SIZE, BOARD_TOP i * CELL_SIZE); // 竖线 pDC-MoveTo(BOARD_LEFT i * CELL_SIZE, BOARD_TOP); pDC-LineTo(BOARD_LEFT i * CELL_SIZE, BOARD_TOP (BOARD_SIZE - 1) * CELL_SIZE); } // 画所有棋位上的棋子 for (int row 0; row BOARD_SIZE; row) { for (int col 0; col BOARD_SIZE; col) { if (m_board[row][col] 0) continue; // 无棋子就跳过 int x BOARD_LEFT col * CELL_SIZE; int y BOARD_TOP row * CELL_SIZE; // 黑子或白子分开画 if (m_board[row][col] 1) { CBrush blackBrush(RGB(0, 0, 0)); pDC-SelectObject(blackBrush); pDC-Ellipse(x - RADIUS, y - RADIUS, x RADIUS, y RADIUS); } else { CBrush whiteBrush(RGB(255, 255, 255)); pDC-SelectObject(whiteBrush); pDC-Ellipse(x - RADIUS, y - RADIUS, x RADIUS, y RADIUS); } } } pDC-SelectObject(pOldPen); }这段代码里需要注意几个点。第一GDI 对象画笔、画刷是有限的系统资源每次循环里如果都新建 CBrush在循环结束后必须释放。上面代码里我利用 CBrush 的析构函数会自动删除 GDI 对象但为了稳妥你可以在循环里显式调用 DeleteObject()。第二SelectObject 返回旧对象指针画完以后要恢复否则 GDI 状态残留会影响后续绘制。第三RADIUS 应该是棋子半径一般设为 CELL_SIZE / 2 - 2比如间距 40 时半径就是 18这样棋子之间会留出一点缝隙视觉效果更接近真实棋盘。这套绘制逻辑没有用到双缓冲因为棋盘和棋子数量很少一帧画完用不了几毫秒肉眼完全看不到闪烁。如果你后续要加“棋子落下时的动画效果”再考虑用双缓冲也不迟。初学者不建议一开始就碰双缓冲因为容易把 OnDraw 里的逻辑搞乱。4. 把代码组织理顺视图类、文档类与消息映射4.1 哪些代码该放视图类哪些该放文档类MFC 单文档架构里CDocument 负责数据管理CView 负责数据展示。理论上棋盘数组 m_board 是数据应该放在文档类里OnDraw 和鼠标消息是展示与交互放在视图类里。但当程序规模只有几百行时强行分离会让代码跳来跳去调试时要来回翻文件。我见过不少新手因为遵从这个原则结果在文档和视图之间传递数据时出现指针野引用窗口一刷新数据就丢了一部分。我的建议偏实用棋盘状态数组放在视图类里文档类暂时空着用。这样做的前提是你不需要“多视图同步显示同一个棋盘”或“文档序列化保存棋局进度”。双人本地对战不涉及这些需求数据放视图类最省事。如果将来要扩展网络对战或复盘功能再把数据挪到文档类也不迟迁移成本并不高。4.2 消息映射宏是怎么把鼠标点击送到函数里的MFC 与众不同的地方在于它不是用一个 switch 语句分发消息而是通过消息映射表把 Windows 消息映射到类成员函数。你在视图类头文件里看到 DECLARE_MESSAGE_MAP() 宏在实现文件里看到 BEGIN_MESSAGE_MAP 和 END_MESSAGE_MAP 之间的表项比如BEGIN_MESSAGE_MAP(CBoardView, CView) ON_WM_LBUTTONDOWN() // 鼠标左键按下消息映射到 OnLButtonDown END_MESSAGE_MAP()ON_WM_LBUTTONDOWN 这个宏会展开成一个结构体把 Windows 消息 WM_LBUTTONDOWN 与当前类的 OnLButtonDown 成员函数绑定。当用户在窗口内按下鼠标左键时Windows 把这个消息发给程序MFC 框架查找消息映射找到对应函数并调用。整个过程的核心代码是框架写好的你只要保证在类声明里写了 afx_msg void OnLButtonDown(UINT nFlags, CPoint point); 并且在消息映射里加了对应宏即可。这里一个非常容易踩的坑是忘记在头文件里声明这个成员函数。消息映射宏本身不会自动创建函数声明如果你只写了映射宏和函数实现编译时不会报错因为映射宏里的函数名没有类型检查但连接时会报“无法解析的外部符号”因为框架找不到对应函数实体。所以在实际写代码时顺序应该是先头文件里声明成员函数再实现文件里写函数体最后把映射宏写进消息映射表。这个顺序错一步都会带来莫名其妙的编译或链接错误。4.3 工程里文件的常见划分方式一个完整的 MFC 五子棋工程文件结构大概是这样的。要按文件去组织的话我推荐这样划分文件内容责任BoardView.h / BoardView.cpp视图类含绘制与鼠标消息处理展示与交互BoardView.h 中定义的常量BOARD_SIZE、CELL_SIZE、BOARD_LEFT 等布局参数MainFrm.h / MainFrm.cppCMainFrame 派生类框架窗口一般不用改FiveInARow.h / FiveInARow.cppCWinApp 派生类应用程序入口一般不用改FiveInARowDoc.h / FiveInARowDoc.cpp文档类本项目可以留空不强制使用如果你是从 VS2019 向导生成的工程文件结构已经长这样了你只需要往 BoardView 里填充代码就行。其他文件完全不用动这能极大降低初学者的“我是不是漏了什么”的焦虑。也正因如此MFC 五子棋才被大量教学使用——它让学习者把精力集中在“消息怎么走、数据怎么存、画面怎么画”这三件事上而不是去琢磨窗口注册和消息循环。5. VS2019 编译与运行的避坑手册现象与解法5.1 错误“无法打开包括文件: afxwin.h”或“afxv_w32.h”现象新建工程或打开别人的 .sln 后编译刚开始就报错提示找不到 MFC 核心头文件。原因安装 VS2019 时没有勾选“适用于 v142 生成工具的 MFC 组件”导致 include 路径里没有 MFC 的目录。解决打开 Visual Studio Installer → 修改 → 单个组件里搜索“MFC” → 勾选“用于 x86 和 x64 的 MFC 生成工具组件” → 修改安装。装完重开 VS2019问题就会消失。这是所有 MFC 项目在 VS2019 上的第一道必过门槛。5.2 错误“无法解析的外部符号 _main”或“WinMain16”现象代码逻辑没写错但链接阶段报错提示找不到入口函数。原因项目被配置成了控制台应用程序或子系统设置不对。MFC 程序需要的是 Windows 图形界面子系统而控制台子系统找不到 WinMain。解决右键项目 → 属性 → 链接器 → 系统 → 子系统选择“窗口 (/SUBSYSTEM:WINDOWS)”。如果项目是从“Windows 桌面应用程序”模板创建的一般不会出这个问题但从旧工程转换来的经常有。5.3 字符集不匹配导致的中文乱码或错误现象程序编译通过但运行后窗口标题或按钮上的中文变成了乱码或者编译时大量警告 C4566。原因老代码的字符串字面量用了窄字符char而工程设置为 Unicode 字符集两者编码不一致。解决一种是字符串都改成 _T(五子棋) 或 CString(L五子棋)另一种是把项目字符集改成“使用多字节字符集”。我建议用 _T() 宏的习惯这样以后在 Unicode 和 ANSI 之间切换时不用改代码。5.4 棋盘被点击后不重绘新棋子不显示现象点击棋盘没有任何反应或者棋子在鼠标松开后才出现但下一次点击又消失了。原因落子后没有调用 Invalidate()或者 Invalidate() 之后没有更新窗口。MFC 里 Invalidate() 只是让窗口变成“无效区域”下一次消息循环里的 WM_PAINT 才会真正重绘。解决在每次改变 m_board 和 m_currentPlayer 之后立刻调用 Invalidate()如果希望立即重绘可以用 Invalidate() UpdateWindow() 组合。但一般单文档程序用 Invalidate() 就够因为消息循环马上会分发 WM_PAINT。5.5 游戏结束后依然能往棋盘上落子现象一方已经五连弹窗提示获胜后玩家点击棋盘还能在别的位置再落子画面变得混乱。原因落子逻辑里没有判断游戏状态m_gameOver 变量为空或没有参与判断。解决我已经在 3.2 节代码里给出了判断在 OnLButtonDown 开头检查 m_gameOver若为 true 直接 return。同时在每次 CheckWin 返回 true 之后把 m_gameOver 赋为 true防止继续落子。这一点在很多网上的教学代码里被漏掉属于典型的“功能做完但没做状态锁死”。5.6 每一步落子时出现一眼可见的闪烁现象棋盘上的棋子较多时每次落子后整个棋盘闪一下视觉体验很糟。原因没有使用双缓冲。MFC 默认在 OnDraw 里直接绘制到屏幕重绘时先擦背景再画前景中间会出现几毫秒空白。解决在 OnDraw 开头加一段双缓冲样板代码。网上常见的做法是创建一个内存 DCCDC memDC选入兼容位图CBitmap先把所有绘制画到内存 DC最后用 BitBlt 一次性复制到窗口。对于 15×15 的棋盘双缓冲能让重绘时间从十几毫秒降到几毫秒以下闪烁能明显减轻。这个优化不是必须的但如果你对画面质量有要求就值得做。这一章的每一条踩坑记录我都在不同机器上用不同的 MFC 工程遇到过。尤其是第 5.2 条的子系统设置每次帮别人看工程时几乎必中一次。这些坑跟五子棋逻辑没关系纯粹是 MFC 工程本身的门槛所以标题里那句“编译通过”其实是在说连这些外围坑都趟过了逻辑代码才是真正值得你花时间的地方。6. 让项目更顺手三个进阶改造方向与实测建议当基础的双人五子棋能正常编译、正常玩代码你已经看得明明白白之后再往下走有三个我认为“投入产出比很高”的改造方向。它们都不改变程序的主体架构却能让整个项目从“能跑”变成“像样”。第一个方向是加入最后一步高亮。现在你落子之后棋盘上没有任何标记告诉你上一步落在哪儿复盘时容易看花眼。实现方法是加两个成员变量 m_lastRow 和 m_lastCol每次落子后更新它们在 OnDraw 里检查如果该位置不为空就用一个红色小正方形或者红色边框包围那颗棋子。这个改动涉及的数据量极小代码只有几行但交互体验提升非常明显也是很多人拿到代码后第一个上手改动的地方。第二个方向是加入悔棋功能也就是所谓的“后悔药”。双人本地对战经常出现一方点错没有一个撤销入口会很抓狂。最简单的实现是维护一个 std::vectorstd::pairint,int m_history每次落子时把行列坐标 push 进去悔棋时从尾部 pop 出最近一次落子坐标把 m_board 上那个位置清空然后把当前玩家切换回上一位最后 Invalidate。这个实现只用到了 STL 容器不需要额外的栈类改动量大概 20 行。要注意的是悔棋后 m_gameOver 状态要重置为 false否则赢棋那方悔棋后会发现自己不能继续落子。第三个方向是把棋子状态从 int 换成枚举。现在的 m_board 里 0、1、2 三个数字含义不够直观。你可以定义 enum PieceColor { EMPTY 0, BLACK 1, WHITE 2 };然后用 PieceColor 类型来声明 m_board。这个改动纯粹是代码可读性层面的不影响运行时行为和文件大小但对后续维护有实打实的好处——你不会在看到 m_board[0][1] 2 时还要反应半秒才明白这是白棋。除了项目本身的改造我还建议把整个工程用 Git 管理起来。每完成一个小功能就提交一次这样当你实验某个新算法把代码改崩时随时有后悔药。我自己的习惯是“能跑就先提交改坏就 revert 到上一个能跑的版本”这句习惯帮我省过很多次重写代码的时间。如果你做完这三个方向这套代码的含金量已经比网上大多数版本高出不少。剩下的事就看你自己的棋友有没有耐心陪你下一整盘了。希望这一篇文章能帮你把 MFC 的路趟平让你把更多精力放在对局逻辑和界面细节上。本文还有配套的精品资源点击获取