十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

C++ MFC五子棋人机对战:从课程设计到可运行桌面程序

C++ MFC五子棋人机对战:从课程设计到可运行桌面程序 简介这是一份面向高校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里创建内存 DCvoid 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 且所有位置评分都是 0bestRow会保持 -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测试第一局电脑赢了以为调对了结果后面十局电脑因为过度进攻被玩家偷了六把。参数调优没有后悔药只能靠多测。希望帮到你。本文还有配套的精品资源点击获取
返回列表