Visual Studio 五子棋人机对战:MFC 界面与 AI 搜索实战

发布时间:2026/10/8 23:54:15
Visual Studio 五子棋人机对战:MFC 界面与 AI 搜索实战
简介这份资源是面向编程初学者与课程设计学习者的五子棋游戏完整工程基于 Microsoft Visual Studio 开发同时支持人机对战与人人对战两种模式适合用来练习图形界面设计、事件驱动编程与基础 AI 算法。压缩包共 49 个文件约 107.07MB包含 5 个 cpp 源文件与 1 个头文件承载棋盘管理、落子判断、胜负检测及 AI 逻辑另有 vcxproj、sln 等工程配置以及 jpg、png 棋盘与棋子素材、wav 音效和 txt 存档文件可直接编译运行。资源已有 2655 人学习下载热度较高。通过它可掌握二维数组维护棋盘状态、Click 事件监听落子、遍历行列与对角线判断五子连珠并了解穷举、Minimax 与 Alpha-Beta 剪枝等 AI 思路以及悔棋、重开、存档读档等扩展功能的实现方式对提升 C 与 Windows 桌面游戏开发能力很有帮助。1. 用 Visual Studio 从零搭一个能对战的五子棋为什么值得动手很多人第一次在 Visual Studio 里写五子棋都是被课程设计或者面试题逼的。但真动手之后会发现这个题目远比想象中耐嚼棋盘绘制、落子判定、胜负检测、悔棋、人机对战每一块都能单独拆出来讲。尤其是人机对战这一块它逼着你去理解搜索、评估函数和剪枝而不是停留在「画个界面点点鼠标」的层面。Visual Studio 在这里的价值不是「能编译 C」而是它把调试器、性能分析、断点条件、内存查看这些工具一次性给全了你调 AI 搜索深度的时候能直接看到每一步的耗时和节点数这是很多轻量编辑器给不了的。这篇文章面向两类人一类是刚学完 C 或 C# 基础、想找一个完整项目练手的同学另一类是已经能写界面、但没认真做过博弈 AI 的开发者。我会用 Win32 / MFC 这条最经典的路线来讲因为热搜里「五子棋 MFC 代码」的检索量一直很高说明大量人卡在「界面怎么和逻辑解耦」这一步。整篇按「先能跑 → 再能玩 → 再能赢」推进代码可以直接抄参数我会标清楚为什么这么设。2. 选型与棋盘数据结构别一上来就画界面2.1 为什么用 MFC 而不是控制台或 Qt控制台五子棋能验证逻辑但你没法练到消息循环、双缓冲绘图、鼠标坐标映射这些真正在项目里天天用的东西。Qt 当然更现代可它对新手的环境配置门槛偏高热搜里「Visual Studio 2022 配置 OpenCV」这类问题本质上都是第三方库链接的坑五子棋没必要再引入一个框架。MFC 是 Visual Studio 自带组件装好「使用 C 的桌面开发」工作负载就能直接建项目零额外依赖这对「先跑起来」这个目标最友好。提示安装 Visual Studio 时勾选「使用 C 的桌面开发」其中就包含 MFC。如果你装的是 Build Tools 版本需要单独勾选 MFC 组件否则新建项目时找不到 MFC 应用模板。2.2 棋盘用一维数组还是二维数组我一般用一维int board[225]索引idx y * 15 x。原因有三个第一AI 搜索时经常要把整个棋盘做拷贝和哈希一维数组的memcpy和遍历更快第二做 Zobrist 哈希或者置换表的时候一维索引算起来干净第三缓存局部性更好评估函数会频繁扫描整盘二维数组的指针跳转在深层搜索里会被放大。// 棋盘常量定义 const int BOARD_SIZE 15; // 15x15 标准棋盘 const int EMPTY 0; // 空位 const int PLAYER 1; // 人类玩家 const int AI 2; // 电脑 const int WIN_SCORE 10000000; // 胜负分值远大于任何评估分 int board[BOARD_SIZE * BOARD_SIZE]; // 一维棋盘idx y * 15 x // 落子与撤销AI 搜索时成对调用 inline void place(int idx, int role) { board[idx] role; } inline void undo(int idx) { board[idx] EMPTY; } inline bool inBoard(int x, int y) { return x 0 x BOARD_SIZE y 0 y BOARD_SIZE; } inline int toIndex(int x, int y) { return y * BOARD_SIZE x; }这段代码里WIN_SCORE的取值有讲究。它必须大于评估函数可能出现的最大分值否则 AI 会把「赢棋」和「局面好」混为一谈。评估函数里单条活四我给到几十万分所以胜负分定在千万级留足余量。place和undo写成内联函数是因为在 alpha-beta 搜索里它们会被调用几十万次函数调用开销不能忽略。2.3 胜负判定只查落子点周围新手最容易犯的错是每落一子就全盘扫描。正确做法是只检查刚落下的那颗子沿四个方向各数连续同色棋子。横、竖、左斜、右斜每个方向往两边延伸累计到 5 就赢。// 只检查刚落子的位置四个方向 bool checkWin(int idx, int role) { int x idx % BOARD_SIZE; int y idx / BOARD_SIZE; // 四个方向横、竖、主对角、副对角 const int dx[4] {1, 0, 1, 1}; const int dy[4] {0, 1, 1, -1}; for (int d 0; d 4; d) { int count 1; // 正方向延伸 for (int step 1; step 5; step) { int nx x dx[d] * step, ny y dy[d] * step; if (!inBoard(nx, ny) || board[toIndex(nx, ny)] ! role) break; count; } // 反方向延伸 for (int step 1; step 5; step) { int nx x - dx[d] * step, ny y - dy[d] * step; if (!inBoard(nx, ny) || board[toIndex(nx, ny)] ! role) break; count; } if (count 5) return true; } return false; }dx和dy数组把四个方向编码成循环避免写四段几乎一样的代码。注意副对角方向dy取-1因为屏幕坐标系 y 轴向下副对角是右上到左下。这个函数在 AI 搜索里也会被调用所以不要在里面做任何动态内存分配。3. 人机对战的核心评估函数与极小化极大搜索3.1 评估函数怎么给棋型打分AI 的强弱八成取决于评估函数。五子棋的棋型就那么几种成五、活四、冲四、活三、眠三、活二、眠二。我的做法是扫描每条线横 15 条、竖 15 条、两个对角各 29 条把连续同色棋子和两端封堵情况提取出来匹配棋型给分。棋型说明建议分值成五五子连珠10000000活四两端开放的四连500000冲四一端被封的四连50000活三两端开放的三连30000眠三一端被封的三连3000活二两端开放的二连2000眠二一端被封的二连200分值不是随便定的核心原则是活四必须远大于冲四因为活四下一手必成五冲四还能被堵。活三和眠三的差距同理。如果你把冲四给太高AI 会沉迷于制造冲四而忽略真正的杀招这是最常见的翻车点。// 简化版棋型评分扫描一条线上的连续段 int scoreLine(const int* line, int len, int role) { int score 0; int i 0; while (i len) { if (line[i] ! role) { i; continue; } int j i; while (j len line[j] role) j; int cnt j - i; // 连续同色数量 bool leftOpen (i 0 line[i-1] EMPTY); bool rightOpen (j len line[j] EMPTY); if (cnt 5) score WIN_SCORE; else if (cnt 4) score (leftOpen rightOpen) ? 500000 : 50000; else if (cnt 3) score (leftOpen rightOpen) ? 30000 : 3000; else if (cnt 2) score (leftOpen rightOpen) ? 2000 : 200; i j; } return score; }这个简化版只统计连续段没有处理「跳三」这种中间隔一个空位的棋型。实战中跳三威胁很大进阶做法是把空位也纳入模式匹配用字符串或者位棋盘来识别。但对入门来说连续段版本已经能让 AI 有模有样先跑通再优化。3.2 极小化极大搜索加 alpha-beta 剪枝搜索框架是经典的 negamaxAI 层取最大值玩家层取最小值用 alpha-beta 剪枝砍掉不可能的分支。深度我一般设 4 到 6 层再深就要上置换表和迭代加深了。// negamax alpha-beta 剪枝 int negamax(int depth, int alpha, int beta, int role) { // 到达叶子节点返回当前角色视角的评估分 if (depth 0) return evaluate(role); int best -WIN_SCORE * 2; // 只搜索已有棋子周围两格内的空位减少分支 std::vectorint moves genMoves(role); for (int idx : moves) { place(idx, role); if (checkWin(idx, role)) { undo(idx); return WIN_SCORE - (MAX_DEPTH - depth); // 越早赢分越高 } int val -negamax(depth - 1, -beta, -alpha, 3 - role); undo(idx); if (val best) best val; if (best alpha) alpha best; if (alpha beta) break; // 剪枝 } return best; }几个关键点。第一genMoves必须做候选点裁剪只考虑已有棋子邻域内的空位否则 225 个点全展开深度 4 就是 225 的四次方根本跑不完。第二WIN_SCORE - (MAX_DEPTH - depth)这个写法让 AI 优先选择步数更少的胜利避免「能赢却绕路」。第三alpha-beta 的剪枝条件alpha beta不能写反写反了剪枝失效但结果还对你会以为代码没问题只是慢这是很隐蔽的坑。3.3 候选点排序让剪枝真正生效alpha-beta 的效率极度依赖走法顺序。如果先搜好棋剪枝能砍掉大半分支如果先搜废棋剪枝形同虚设。我的做法是先用评估函数给每个候选点打个分按分数从高到低排序再进搜索。// 候选点生成并按启发式分数排序 std::vectorint genMoves(int role) { std::vectorstd::pairint,int scored; for (int idx 0; idx BOARD_SIZE * BOARD_SIZE; idx) { if (board[idx] ! EMPTY) continue; if (!hasNeighbor(idx, 2)) continue; // 周围两格内有棋子 place(idx, role); int s evaluate(role); // 快速评估 undo(idx); scored.push_back({s, idx}); } std::sort(scored.begin(), scored.end(), [](auto a, auto b){ return a.first b.first; }); std::vectorint moves; for (auto p : scored) moves.push_back(p.second); return moves; }hasNeighbor的半径设 2 是经验值。设 1 会漏掉一些需要隔空做眼的点设 3 候选点太多拖慢排序。排序本身有开销但换来的剪枝收益远大于排序成本。如果你发现 AI 思考超过两秒先检查候选点数量通常问题出在邻域半径或者没有排序。4. 界面与逻辑解耦MFC 绘图和鼠标消息处理4.1 双缓冲绘图消除闪烁MFC 直接在OnPaint里画棋盘每落一子重绘会闪。标准解法是内存 DC 双缓冲先在内存位图上画完再一次性贴到屏幕。void CChessView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(rect); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); memDC.SelectObject(bmp); // 先填充背景 memDC.FillSolidRect(rect, RGB(240, 200, 120)); drawBoard(memDC); // 画网格和星位 drawPieces(memDC); // 画所有棋子 // 一次性贴到屏幕 pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); }CreateCompatibleBitmap必须用屏幕 DC 创建不能用内存 DC否则位图是单色的画出来全是黑白。这个坑我踩过排查了半天才发现是兼容位图的创建源搞错了。4.2 鼠标坐标到棋盘索引的映射鼠标点击的是像素坐标要转成棋盘格索引。关键是先算格子大小和边距再做整除。void CChessView::OnLButtonDown(UINT nFlags, CPoint point) { int margin 30; // 棋盘边距 int cell 35; // 格子像素大小 int x (point.x - margin cell / 2) / cell; int y (point.y - margin cell / 2) / cell; if (!inBoard(x, y)) return; int idx toIndex(x, y); if (board[idx] ! EMPTY) return; // 已有子 if (m_gameOver || m_turn ! PLAYER) return; place(idx, PLAYER); if (checkWin(idx, PLAYER)) { m_gameOver true; } else { m_turn AI; // 触发 AI 走子实际项目里建议放工作线程 int aiMove think(); place(aiMove, AI); if (checkWin(aiMove, AI)) m_gameOver true; m_turn PLAYER; } Invalidate(); // 触发重绘 }(point.x - margin cell / 2) / cell里的cell / 2是为了让点击落在格子中心附近也算命中提升手感。Invalidate会触发OnPaint配合双缓冲就不会闪。注意 AI 思考如果放在 UI 线程界面会卡住深度大的时候建议开个工作线程算完用PostMessage通知主线程落子。4.3 悔棋功能的状态保存悔棋要撤销两步玩家一步、AI 一步所以得记录落子历史。用一个栈存索引和角色悔棋时弹出并清空对应位置。struct Move { int idx; int role; }; std::vectorMove history; void undoMove() { if (history.size() 2) return; for (int i 0; i 2; i) { Move m history.back(); history.pop_back(); board[m.idx] EMPTY; } m_gameOver false; m_turn PLAYER; Invalidate(); }每次place之后要同步history.push_back否则悔棋和棋盘状态会不一致。这个不一致很隐蔽表现为悔棋后棋盘上少一颗子但 AI 还记得下一手就乱了。5. 避坑与排查那些让 AI 变傻的细节5.1 AI 第一步就下在天元旁边像随机走现象空盘或者只有一子时AI 落子毫无章法。原因候选点生成依赖hasNeighbor空盘时没有任何棋子候选点为空搜索直接返回一个默认值。解决在think()里加特判如果棋盘为空直接返回中心点toIndex(7, 7)如果只有一子返回其邻域内评分最高的点。5.2 AI 明明能赢却去堵对方现象AI 有四连可以成五却跑去防守玩家的三连。原因评估函数里防守分给得比进攻分高或者搜索深度不够没看到自己的成五。解决在negamax里先检查当前角色能否直接成五能就直接返回WIN_SCORE这个「立即胜着检测」要放在生成候选点之前。5.3 搜索深度加到 6 就卡死现象深度 4 秒出深度 6 要几十秒。原因候选点没排序或者邻域半径太大分支因子爆炸。解决先做候选点排序再把邻域半径从 2 降到 1 试试还可以加迭代加深先搜深度 2 拿到最佳走法再用它作为深度 4 的首选逐层加码。5.4 界面重绘闪烁棋子像在抖现象每次落子整个棋盘闪一下。原因没做双缓冲OnPaint里直接往屏幕 DC 画。解决按 4.1 的内存 DC 方案改注意兼容位图要用屏幕 DC 创建。另外OnEraseBkgnd要返回TRUE阻止系统擦背景否则还是会闪。5.5 中文路径下编译报错或运行乱码现象项目放在中文目录或者源码里有中文注释编译报编码错误。原因Visual Studio 默认源码字符集和系统代码页不一致。解决在项目属性里把「C/C → 命令行」加上/utf-8或者把源码另存为带 BOM 的 UTF-8。热搜里「Visual Studio 2026 中文输出为乱码」就是这类问题本质是编码没统一。6. 让 AI 更强置换表与迭代加深的落地技巧前面那套 negamax 深度 4 已经能打赢大部分休闲玩家但你想让它再上一个台阶就得解决重复搜索的问题。五子棋的搜索树里有大量通过不同走法顺序到达的相同局面置换表就是把这些局面的搜索结果缓存下来。实现上用一个哈希表键是棋盘的 Zobrist 哈希值存搜索深度、分值、标志位精确值 / 下界 / 上界。// Zobrist 哈希每个位置每个角色一个随机数 uint64_t zobrist[225][3]; uint64_t hashKey 0; void initZobrist() { std::mt19937_64 rng(20240501); for (int i 0; i 225; i) for (int r 0; r 3; r) zobrist[i][r] rng(); } inline void placeHash(int idx, int role) { hashKey ^ zobrist[idx][role]; // 异或进去 } inline void undoHash(int idx, int role) { hashKey ^ zobrist[idx][role]; // 异或出来可逆 }异或的妙处是可逆落子和撤销用同一个操作。置换表用unordered_mapuint64_t, Entry或者固定大小的数组都行固定数组更快但要做替换策略。查表时如果缓存的深度大于等于当前剩余深度且标志位是精确值就直接返回省掉整棵子树。迭代加深是另一个必加的技巧。不要一上来就搜深度 6而是从深度 2 开始逐层加每层用上一层的首选走法作为本层的第一个搜索分支。这样 alpha-beta 的剪枝效率极高而且你可以在任意时刻中断返回当前最优解天然支持「限时思考」。// 迭代加深主循环 int think(int timeLimitMs) { int bestMove -1; auto start clock(); for (int depth 2; depth MAX_DEPTH; depth 2) { int val negamax(depth, -WIN_SCORE*2, WIN_SCORE*2, AI); bestMove m_bestAtRoot; // negamax 里记录根节点最佳走法 double elapsed double(clock() - start) / CLOCKS_PER_SEC * 1000; if (elapsed timeLimitMs * 0.6) break; // 下一层可能超时 } return bestMove; }timeLimitMs * 0.6这个系数是留余量因为下一层耗时通常是当前层的三到五倍。如果你发现 AI 偶尔超时就把系数降到 0.4。置换表加迭代加深之后同样深度 6 的耗时能降到原来的三分之一左右这是实测能感觉到的提升。最后说个验证方法写一个自对弈脚本让 AI 分别执黑执白各跑一百局统计先手胜率和平均步数。如果先手胜率异常高或者平均步数很短说明评估函数有偏通常是某个棋型分值给错了。我调参的时候就靠这个脚本比人肉下棋快得多。这套东西从建项目到 AI 能打一个周末足够但想调到「有点意思」得反复磨评估函数。希望帮到你。本文还有配套的精品资源点击获取