简介:这是一份面向高校C++课程学习者与Windows桌面开发入门者的期末大作业参考方案,围绕MFC框架实现人机对战五子棋,帮助读者理解面向对象设计、界面开发与博弈算法的结合方式。压缩包共36个文件,约160KB,以cpp与h源码为主体,配合vcproj、sln、vcxproj等工程配置,以及rc资源脚本、doc说明文档和user、opt等开发环境文件,可直接用Visual Studio打开编译运行。项目涵盖棋盘绘制、落子与胜负判断、重新开始等界面交互,并实现基于极小化极大搜索与启发式评估函数的电脑棋手,同时包含悔棋、计时、难度选择等细节功能,代码按游戏逻辑、用户界面与AI模块分离组织。目前已有103人学习下载,适合作为课程设计模板、算法练手素材与MFC工程结构参考。
1. 从课程设计到能跑起来的人机对战:MFC 五子棋到底在做什么
很多人对 C++ 期末大作业的印象停留在控制台里打印一个棋盘,用cin输入坐标,再用cout画个X和O。这种作业能过,但拿不出手。基于 MFC 的人机对战五子棋项目,本质是把一个算法问题包装成一个 Windows 桌面程序:用 MFC 的对话框框架承载棋盘绘制和鼠标交互,用 C++ 实现胜负判定和电脑落子逻辑,最终交付一个双击就能运行、鼠标点两下就能下棋的.exe。它解决的不是“五子棋怎么下”的问题,而是“怎么让一个 C++ 程序看起来像个正经软件”的问题。适合正在做课程设计、想拿高分、又不想从零啃 Win32 API 的在校生,也适合工作后想补一补 MFC 桌面开发手感的后端工程师。这个标题里的关键词——C++、MFC、五子棋、人机对战——每一个都对应着具体的代码模块和踩坑点,下面逐层拆开。
2. 棋盘、落子与胜负判定:MFC 五子棋的骨架怎么搭
2.1 为什么选 MFC 对话框而不是单文档
MFC 提供了三种常见工程模板:单文档(SDI)、多文档(MDI)和基于对话框(Dialog-based)。五子棋这个场景,界面固定、交互单一、不需要菜单栏和工具栏的复杂切换,对话框模板是最省事的。单文档会默认生成CView派生类,你得自己处理OnDraw的坐标映射和滚动条,对于只画一个 15×15 棋盘的作业来说属于过度设计。对话框模板下,你只需要在资源编辑器里拖一个Picture Control或者直接在主对话框上画,所有绘制逻辑集中在OnPaint里,鼠标消息OnLButtonDown直接拿到点击坐标,省去文档/视图之间的数据同步。
常见做法是:新建 MFC 项目,选择“基于对话框”,取消“关于”框,资源 ID 保持默认的IDD_XXX_DIALOG。然后在对话框类里加成员变量:一个二维数组int board[15][15]存棋盘状态,0 表示空,1 表示玩家,2 表示电脑;一个BOOL bIsPlayerTurn标记当前轮到谁;一个CPoint ptLastMove记录最后落子位置用于高亮。这些变量在OnInitDialog里初始化,棋盘数组全部置 0。
2.2 棋盘绘制的坐标换算与双缓冲
MFC 的OnPaint里直接CPaintDC dc(this)绘制,如果每落一子就全量重绘,快速点击时会出现闪烁。血泪经验是:不用双缓冲,鼠标点得快一点棋盘就闪成幻灯片。解决办法是在OnPaint里创建内存 DC:
void CChessDlg::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, 180, 120)); // 画网格线,15条横竖线,间距根据窗口大小动态计算 int margin = 30; int cellSize = (min(rect.Width(), rect.Height()) - 2 * margin) / 14; for (int i = 0; i < 15; i++) { memDC.MoveTo(margin, margin + i * cellSize); memDC.LineTo(margin + 14 * cellSize, margin + i * cellSize); memDC.MoveTo(margin + i * cellSize, margin); memDC.LineTo(margin + i * cellSize, margin + 14 * cellSize); } // 画棋子 for (int r = 0; r < 15; r++) { for (int c = 0; c < 15; c++) { if (board[r][c] == 0) continue; int x = margin + c * cellSize; int y = margin + r * cellSize; CBrush brush(board[r][c] == 1 ? RGB(0, 0, 0) : RGB(255, 255, 255)); memDC.SelectObject(&brush); memDC.Ellipse(x - cellSize/2 + 2, y - cellSize/2 + 2, x + cellSize/2 - 2, y + cellSize/2 - 2); } } // 最后一次性贴到屏幕 dc.BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); }这段代码的逻辑说明:CreateCompatibleDC创建与屏幕兼容的内存设备上下文,所有绘制先画在内存位图上,最后BitBlt一次性拷贝到屏幕,消除闪烁。参数方面,margin是棋盘边距,cellSize根据窗口实际大小动态计算,这样窗口拉伸时棋盘会跟着缩放。注意Ellipse的坐标要减去半径再加内边距,否则棋子会超出格子。board数组的索引[r][c]对应行和列,绘制时x由列决定,y由行决定,别搞反。
2.3 鼠标落子与坐标反算
鼠标点击消息OnLButtonDown拿到的是像素坐标,需要反算成棋盘的行列索引。常见错误是直接用point.x / cellSize,忽略了margin偏移,导致点击位置和落子位置差半格。正确做法:
void CChessDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (!bIsPlayerTurn || bGameOver) return; CRect rect; GetClientRect(&rect); int margin = 30; int cellSize = (min(rect.Width(), rect.Height()) - 2 * margin) / 14; // 反算行列,四舍五入到最近交叉点 int col = (point.x - margin + cellSize / 2) / cellSize; int row = (point.y - margin + cellSize / 2) / cellSize; // 边界检查,防止越界 if (row < 0 || row >= 15 || col < 0 || col >= 15) return; if (board[row][col] != 0) return; board[row][col] = 1; bIsPlayerTurn = FALSE; Invalidate(); // 触发重绘 if (CheckWin(row, col, 1)) { MessageBox(_T("你赢了!")); bGameOver = TRUE; return; } // 电脑落子 ComputerMove(); Invalidate(); CDialogEx::OnLButtonDown(nFlags, point); }逻辑说明:(point.x - margin + cellSize / 2) / cellSize这个式子是先减去边距,再加上半个格子做四舍五入,最后整除格子大小得到列索引。参数cellSize必须和OnPaint里保持一致,否则点击和绘制对不上。Invalidate()触发OnPaint重绘,不要手动调OnPaint。CheckWin传入刚落子的行列和玩家编号,判断四个方向是否有五连。
2.4 胜负判定的四个方向扫描
五子棋胜负判定不需要遍历整个棋盘,只需要从最后落子点向四个方向延伸计数。常见做法是写一个CheckWin(int row, int col, int player),分别检查水平、垂直、主对角线、副对角线:
BOOL CChessDlg::CheckWin(int row, int col, int player) { // 四个方向:右、下、右下、左下 int dirs[4][2] = {{0,1}, {1,0}, {1,1}, {1,-1}}; for (int d = 0; d < 4; d++) { int count = 1; // 正方向延伸 for (int step = 1; step < 5; step++) { int r = row + dirs[d][0] * step; int c = col + dirs[d][1] * step; if (r < 0 || r >= 15 || c < 0 || c >= 15) break; if (board[r][c] != player) break; count++; } // 反方向延伸 for (int step = 1; step < 5; step++) { int r = row - dirs[d][0] * step; int c = col - dirs[d][1] * step; if (r < 0 || r >= 15 || c < 0 || c >= 15) break; if (board[r][c] != player) break; count++; } if (count >= 5) return TRUE; } return FALSE; }参数说明:dirs数组存了四个方向的增量,{0,1}是水平向右,{1,0}是垂直向下,{1,1}是主对角线,{1,-1}是副对角线向左下。每个方向从落子点向正反两侧各延伸最多 4 步,累计连续同色棋子数,达到 5 就返回胜利。注意边界检查r < 0 || r >= 15必须放在访问board[r][c]之前,否则数组越界会读到垃圾值,出现“莫名其妙就赢了”的玄学 bug。
3. 人机对战的核心:电脑落子算法怎么选、怎么写
3.1 从随机落子到评分表:三种难度方案对比
人机对战是這個项目的加分项,也是区分“能跑”和“像样”的分水岭。最简单的做法是电脑随机找一个空位落子,代码三行,但玩家下两把就发现电脑是个傻子,体验极差。稍微好一点的是基于规则的评分:对每个空位,分别计算如果玩家下在这里电脑会损失多少分、如果电脑下在这里能得多少分,取最高分位置。再往上就是极小化极大搜索加 Alpha-Beta 剪枝,能做出有段位感的电脑,但代码量和调试难度陡增,对于期末大作业来说性价比不高。
我一般会推荐评分表方案:实现难度适中,效果明显比随机好,而且评分逻辑可以写成表格,方便在报告里画图说明。具体做法是维护两个 15×15 的评分矩阵,一个算电脑的进攻分,一个算玩家的威胁分,最后加权求和选最大值的空位。
3.2 评分表的五个棋型与分值设计
评分表的核心是识别棋型。对于五子棋,常见的棋型有:连五、活四、冲四、活三、眠三、活二、眠二。每种棋型对应一个分值,分值差距要拉开,让电脑优先堵活四而不是活二。一个可用的分值表如下:
| 棋型 | 说明 | 分值 |
|---|---|---|
| 连五 | 五子连珠 | 100000 |
| 活四 | 两端开放的四连 | 10000 |
| 冲四 | 一端被封的四连 | 1000 |
| 活三 | 两端开放的三连 | 1000 |
| 眠三 | 一端被封的三连 | 100 |
| 活二 | 两端开放的二连 | 100 |
| 眠二 | 一端被封的二连 | 10 |
这个表不是绝对的,活三和冲四都给 1000 是因为冲四下一步就成五,活三下一步也能成活四,威胁等级接近。实际写的时候,对每个空位,先假设电脑下在这里,扫描四个方向统计形成的棋型,累加电脑得分;再假设玩家下在这里,同样统计,累加玩家得分。最终该位置的分数 = 电脑得分 × 1.0 + 玩家得分 × 0.8,系数 0.8 表示电脑稍微偏向进攻,但也会认真防守。
3.3 电脑落子的完整实现与性能注意
void CChessDlg::ComputerMove() { int bestScore = -1; int bestRow = -1, bestCol = -1; for (int r = 0; r < 15; r++) { for (int c = 0; c < 15; c++) { if (board[r][c] != 0) continue; // 只考虑已有棋子附近的空位,减少计算量 if (!HasNeighbor(r, c, 2)) continue; int scoreAI = EvaluatePosition(r, c, 2); int scorePlayer = EvaluatePosition(r, c, 1); int total = scoreAI + (int)(scorePlayer * 0.8); if (total > bestScore) { bestScore = total; bestRow = r; bestCol = c; } } } if (bestRow != -1) { board[bestRow][bestCol] = 2; if (CheckWin(bestRow, bestCol, 2)) { MessageBox(_T("电脑赢了!")); bGameOver = TRUE; } } bIsPlayerTurn = TRUE; }逻辑说明:双重循环遍历所有空位,HasNeighbor检查该位置周围 2 格内是否有棋子,没有就跳过,这是性能优化的关键——15×15 全盘评估大约 225 个位置,但实际有意义的只有已有棋子附近的几十个。EvaluatePosition返回在(r,c)落player棋子后形成的最高棋型分值。参数0.8是防守权重,调高会让电脑更保守,调低会更激进。注意bestScore初始化为 -1 而不是 0,因为空位评分可能为 0,如果初始化为 0 且所有位置评分都是 0,bestRow会保持 -1,电脑就不落子了。
3.4 评估函数的四个方向扫描与棋型匹配
EvaluatePosition是评分表方案里最需要仔细写的函数。对给定位置和玩家,向四个方向各取前后 4 格,拼成一个长度 9 的字符串,然后匹配棋型:
int CChessDlg::EvaluatePosition(int row, int col, int player) { int totalScore = 0; int dirs[4][2] = {{0,1}, {1,0}, {1,1}, {1,-1}}; for (int d = 0; d < 4; d++) { int count = 1; // 当前假设落子的位置 int block = 0; // 被封堵的端数 // 正方向 for (int step = 1; step <= 4; step++) { int r = row + dirs[d][0] * step; int c = col + dirs[d][1] * step; if (r < 0 || r >= 15 || c < 0 || c >= 15) { block++; break; } if (board[r][c] == player) count++; else if (board[r][c] == 0) break; else { block++; break; } } // 反方向 for (int step = 1; step <= 4; step++) { int r = row - dirs[d][0] * step; int c = col - dirs[d][1] * step; if (r < 0 || r >= 15 || c < 0 || c >= 15) { block++; break; } if (board[r][c] == player) count++; else if (board[r][c] == 0) break; else { block++; break; } } // 根据连子数和封堵数给分 if (count >= 5) totalScore += 100000; else if (count == 4) { if (block == 0) totalScore += 10000; else if (block == 1) totalScore += 1000; } else if (count == 3) { if (block == 0) totalScore += 1000; else if (block == 1) totalScore += 100; } else if (count == 2) { if (block == 0) totalScore += 100; else if (block == 1) totalScore += 10; } } return totalScore; }参数说明:count统计包含假设落子在内的连续同色棋子数,block统计两端被对方棋子或边界封堵的次数。step <= 4是因为五子棋最多需要看前后各 4 格就能判断是否成五。注意block在遇到空位时break但不增加,因为空位意味着这一端还有发展空间。这个评估函数对每个方向独立打分再累加,四个方向的总分就是该位置的威胁值。
4. 避坑与排查:MFC 五子棋最容易翻车的五个地方
4.1 棋盘闪烁严重,快速点击时棋子重影
现象:鼠标连续点击落子,棋盘出现明显闪烁,偶尔看到上一帧的棋子残影。原因:OnPaint里直接使用CPaintDC绘制,每次Invalidate都先擦背景再画棋子,擦和画之间有时间差。解决:按 2.2 节的双缓冲方案,所有绘制在内存 DC 完成,最后BitBlt一次性输出。另外Invalidate传FALSE可以避免擦背景,进一步减少闪烁。
4.2 点击位置和落子位置偏移半格
现象:明明点在交叉点上,棋子却画在格子中间。原因:坐标反算时没有做四舍五入,直接整除导致截断误差。解决:在(point.x - margin) / cellSize的基础上加cellSize / 2再整除,相当于四舍五入到最近的交叉点。同时确保OnPaint和OnLButtonDown里的margin和cellSize计算方式完全一致,最好抽成一个独立函数GetCellSize()和GetMargin()。
4.3 电脑落子后偶尔不响应或落子位置越界
现象:电脑有时候不落子,或者落在一个明显错误的位置。原因:bestScore初始化为 0,而所有空位评分可能都是 0,导致bestRow保持 -1;或者HasNeighbor的范围设得太小,棋盘边缘的空位被跳过。解决:bestScore初始化为 -1,确保至少有一个位置被选中;HasNeighbor的范围设为 2 格,覆盖所有有意义的空位;在ComputerMove最后加一个兜底:如果bestRow == -1,随机找一个空位落子。
4.4 胜负判定在边界位置误判
现象:棋子在棋盘边缘时,明明没有五连却提示胜利。原因:CheckWin里边界检查写成了r < 0 || r > 15,应该是r >= 15,> 15会访问board[15][c]越界。解决:统一用r < 0 || r >= 15 || c < 0 || c >= 15做边界判断,并且在访问board之前检查。另外dirs数组的副对角线方向{1,-1}在反方向延伸时变成{-1,1},注意行列增量的符号。
4.5 窗口拉伸后棋盘变形或鼠标点击错位
现象:最大化窗口后棋盘被拉长,点击位置和棋子位置对不上。原因:cellSize用rect.Width()和rect.Height()分别计算,但棋盘是正方形,应该取两者最小值。解决:int cellSize = (min(rect.Width(), rect.Height()) - 2 * margin) / 14;这样无论窗口怎么拉伸,棋盘始终保持正方形,点击反算也用同一个cellSize。如果希望棋盘居中,还需要计算偏移量offsetX = (rect.Width() - 14 * cellSize) / 2,绘制和点击都加上这个偏移。
5. 让电脑更难缠:评分权重调优与开局库的轻量做法
评分表方案的效果很大程度上取决于权重系数和棋型分值的搭配。默认的scoreAI + scorePlayer * 0.8在大多数情况下够用,但如果你想让电脑更难缠,可以按阶段调整:开局前 4 手,把防守权重降到 0.5,让电脑积极占中心;中盘阶段把防守权重提到 1.2,优先堵玩家的活三和冲四;残局如果电脑已经有活四,进攻权重直接拉满,不再防守。这个策略用一个简单的if (moveCount < 4)分支就能实现,代码改动不超过十行。
另一个轻量优化是加一个极简开局库。五子棋先手优势明显,如果玩家先手,电脑第一步最好下在天元附近。常见做法是:如果棋盘上只有一个玩家棋子,电脑直接下在玩家棋子的相邻位置,优先选正下方或正右方。如果玩家第一手下在天元,电脑就下在(7,8)或(8,7)。这个规则不需要搜索,几行代码就能让电脑开局不落下风。
验证电脑强度的方法很简单:自己跟自己下十把,记录电脑赢了几把。如果电脑胜率低于 30%,说明防守权重太低或者棋型分值不合理;如果电脑胜率高于 70%,说明玩家体验太差,适当降低进攻权重。我一般会把EvaluatePosition里的活三和冲四分值调成一样,因为冲四下一步就成五,活三下一步成活四,威胁等级确实接近,调成一样后电脑的防守更均衡。
最后一个习惯:每次改完评分参数,不要只测一局就下结论。五子棋有随机性,至少跑二十局取平均。我自己的血泪教训是,有一次把活三分值从 1000 调到 2000,测试第一局电脑赢了,以为调对了,结果后面十局电脑因为过度进攻被玩家偷了六把。参数调优没有后悔药,只能靠多测。希望帮到你。
本文还有配套的精品资源,点击获取