☰
Visual Studio 五子棋人机对战:MFC 界面与 AI 搜索实战
2026/10/8 23:54:01 网站建设 项目流程

简介:这份资源是面向编程初学者与课程设计学习者的五子棋游戏完整工程,基于 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 剪枝

搜索框架是经典的 negamax:AI 层取最大值,玩家层取最小值,用 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::vector<int> 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::vector<int> genMoves(int role) { std::vector<std::pair<int,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::vector<int> 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::vector<Move> 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_map<uint64_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 能打,一个周末足够,但想调到「有点意思」,得反复磨评估函数。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询