基于MFC的人机对战五子棋:从工程搭建到AI估值与Alpha-Beta剪枝
2026/9/23 15:32:20 网站建设 项目流程

简介:这是一份面向高校C++课程学习者与Windows桌面开发入门者的期末大作业参考方案,围绕MFC框架实现人机对战五子棋,帮助读者理解面向对象设计、界面开发与博弈算法的结合方式。压缩包共36个文件,约160KB,以cpp与h源码为主体,配合vcproj、sln、vcxproj等工程配置,以及rc资源脚本、doc说明文档和ReadMe.txt,覆盖从界面到算法的完整工程结构。已有103人学习下载。项目将游戏逻辑、用户界面与电脑棋手拆分为独立类,核心涉及极小化极大搜索与启发式评估函数,并包含悔棋、计时、难度选择等细节功能,还附有评分与算法改进相关文档,便于读者对照源码理解估值函数设计、胜负判断与调试思路,适合作为课程设计模板或MFC与AI算法综合练习的起点。

1. 从 MFC 对话框到人机对战:一份能跑起来的五子棋大作业长什么样

期末周临近,C++ 大作业选题里「基于 MFC 的人机对战五子棋」几乎是每年都会被翻出来的经典款。它看起来简单——棋盘、落子、判胜,但真正动手才会发现,MFC 的对话框工程、GDI 绘图、消息映射、AI 估值函数这几块拼在一起,坑比想象中多。很多人卡在第一步:新建完 MFC 对话框工程,面对一堆自动生成的代码不知道从哪下手;也有人棋盘画出来了,鼠标点击却对不上格子;更常见的是 AI 只会堵眼前一步,被玩家一个双三直接带走。

这篇笔记面向两类人:一是第一次接触 MFC、需要交一份能演示能讲清原理的大作业的同学;二是写过 C++ 但没碰过 Windows 桌面绘图、想借五子棋把消息机制和 GDI 摸一遍的开发者。我会按「工程骨架 → 棋盘绘制 → 鼠标交互 → 胜负判定 → 人机 AI → 调试避坑」的顺序,把每一步的可复现代码和参数讲清楚。整套方案用纯 MFC + 标准 C++,不依赖第三方库,Visual Studio 2019/2022 社区版直接能编译,适合作为课程设计或自学练手项目。

2. 工程骨架与棋盘数据结构:先把地基打对

2.1 为什么选对话框工程而不是单文档

MFC 提供三种典型工程:单文档(SDI)、多文档(MDI)、基于对话框(Dialog-based)。五子棋这种「一个固定窗口 + 一块绘图区 + 几个按钮」的形态,对话框工程最省事。单文档会带一整套文档/视图/框架结构,对一个大作业来说属于过度设计,反而增加讲解负担。

新建工程时选「MFC 应用」,应用程序类型选「基于对话框」,项目名建议用英文如GobangMFC,避免中文路径导致资源编译器偶发报错。生成后你会看到GobangMFCDlg.cpp和对应的.h,主对话框资源在GobangMFC.rc里,ID 默认是IDD_GOBANGMFC_DIALOG

我一般会把棋盘逻辑和界面逻辑分开:新建一个GobangLogic.h/.cpp放纯算法,对话框类只负责绘图和消息转发。这样 AI 部分可以单独写测试,不用每次启动界面。

2.2 棋盘用一维数组还是二维数组

棋盘是 15×15 的标准五子棋规格。存储上二维数组int board[15][15]最直观,但一维数组int board[225]在遍历和估值时缓存更友好,索引换算idx = y * 15 + x。对大作业来说两者都行,我倾向一维,因为后面 AI 打分要频繁扫描整盘,一维写起来更紧凑。

// GobangLogic.h #pragma once const int BOARD_SIZE = 15; // 15x15 标准棋盘 const int EMPTY = 0; // 空位 const int PLAYER = 1; // 玩家棋子 const int AI = 2; // 电脑棋子 class GobangLogic { public: GobangLogic(); void reset(); // 清空棋盘 bool place(int x, int y, int who); // 落子,返回是否合法 int get(int x, int y) const; // 读取某点 bool isWin(int x, int y, int who) const; // 判断某点落子后是否连五 bool isFull() const; // 棋盘是否已满 private: int m_board[BOARD_SIZE * BOARD_SIZE]; int m_count; // 已落子数 };
// GobangLogic.cpp #include "pch.h" #include "GobangLogic.h" GobangLogic::GobangLogic() { reset(); } void GobangLogic::reset() { for (int i = 0; i < BOARD_SIZE * BOARD_SIZE; ++i) m_board[i] = EMPTY; m_count = 0; } bool GobangLogic::place(int x, int y, int who) { if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) return false; int idx = y * BOARD_SIZE + x; if (m_board[idx] != EMPTY) return false; // 已有子,拒绝 m_board[idx] = who; ++m_count; return true; } int GobangLogic::get(int x, int y) const { return m_board[y * BOARD_SIZE + x]; } bool GobangLogic::isFull() const { return m_count >= BOARD_SIZE * BOARD_SIZE; }

place里做了边界检查和占位检查,这是后面鼠标点击防重复落子的第一道闸。isWin单独实现,逻辑是:以刚落下的点为中心,沿横、竖、两条对角线四个方向各数连续同色棋子,任一方向达到 5 即胜。

bool GobangLogic::isWin(int x, int y, int who) const { // 四个方向:横、竖、主对角、副对角 const int dx[4] = {1, 0, 1, 1}; const int dy[4] = {0, 1, 1, -1}; for (int d = 0; d < 4; ++d) { int cnt = 1; // 正方向延伸 for (int s = 1; s < 5; ++s) { int nx = x + dx[d] * s, ny = y + dy[d] * s; if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) break; if (m_board[ny * BOARD_SIZE + nx] != who) break; ++cnt; } // 反方向延伸 for (int s = 1; s < 5; ++s) { int nx = x - dx[d] * s, ny = y - dy[d] * s; if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) break; if (m_board[ny * BOARD_SIZE + nx] != who) break; ++cnt; } if (cnt >= 5) return true; } return false; }

参数说明:dx/dy数组把四个方向编码成向量,避免写四段重复代码;cnt从 1 开始是因为当前点本身算一个。注意副对角方向dy取 -1,这是最容易写反的地方,写反了会导致斜向连五判不出来。

3. 用 GDI 把棋盘画到对话框上:坐标换算与双缓冲

3.1 绘图入口选 OnPaint 还是 OnEraseBkgnd

MFC 对话框默认会擦背景,如果只在OnPaint里画棋盘,窗口重绘时会闪。正确做法是响应WM_ERASEBKGND直接返回 TRUE 阻止擦除,然后在OnPaint里用双缓冲一次性贴图。双缓冲的意思是:先画到内存 DC,再BitBlt到屏幕 DC,这样不会看到逐笔绘制的过程。

先在对话框类里加成员:CDC m_memDC; CBitmap m_memBmp;以及棋盘几何参数。

// 在 GobangMFCDlg.h 的类声明里加 private: GobangLogic m_logic; int m_cellSize; // 每格像素 int m_margin; // 棋盘边距 int m_originX; // 棋盘左上角屏幕坐标 int m_originY; bool m_gameOver; void drawBoard(CDC* pDC); void boardToScreen(int bx, int by, int& sx, int& sy); bool screenToBoard(int sx, int sy, int& bx, int& by);

3.2 坐标换算:屏幕像素和棋盘格子的双向映射

棋盘 15 条线、14 个格子,落子点在交叉线上。设每格cellSize = 36像素,边距margin = 30,则棋盘宽高都是14 * 36 = 504像素,加上两侧边距总宽504 + 60 = 564。原点m_originX = marginm_originY = margin

void CGobangMFCDlg::boardToScreen(int bx, int by, int& sx, int& sy) { sx = m_originX + bx * m_cellSize; sy = m_originY + by * m_cellSize; } bool CGobangMFCDlg::screenToBoard(int sx, int sy, int& bx, int& by) { // 四舍五入到最近的交叉点 bx = (sx - m_originX + m_cellSize / 2) / m_cellSize; by = (sy - m_originY + m_cellSize / 2) / m_cellSize; if (bx < 0 || bx >= BOARD_SIZE || by < 0 || by >= BOARD_SIZE) return false; // 距离交叉点太远则视为无效点击 int cx, cy; boardToScreen(bx, by, cx, cy); if (abs(sx - cx) > m_cellSize / 2 || abs(sy - cy) > m_cellSize / 2) return false; return true; }

screenToBoard里的四舍五入是关键:鼠标点不可能精确落在交叉点上,用+ cellSize/2再整除,等价于就近取整。后面那个距离判断是防止玩家点到格子正中间时被误判到某个交叉点,提升手感。

3.3 双缓冲绘制棋盘和棋子

void CGobangMFCDlg::drawBoard(CDC* pDC) { CRect rc; GetClientRect(&rc); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOld = memDC.SelectObject(&bmp); // 背景 memDC.FillSolidRect(&rc, RGB(238, 203, 140)); // 画 15 条横线和竖线 CPen pen(PS_SOLID, 1, RGB(80, 40, 0)); CPen* pOldPen = memDC.SelectObject(&pen); for (int i = 0; i < BOARD_SIZE; ++i) { int sx, sy; boardToScreen(0, i, sx, sy); memDC.MoveTo(sx, sy); boardToScreen(BOARD_SIZE - 1, i, sx, sy); memDC.LineTo(sx, sy); boardToScreen(i, 0, sx, sy); memDC.MoveTo(sx, sy); boardToScreen(i, BOARD_SIZE - 1, sx, sy); memDC.LineTo(sx, sy); } memDC.SelectObject(pOldPen); // 画棋子 for (int y = 0; y < BOARD_SIZE; ++y) { for (int x = 0; x < BOARD_SIZE; ++x) { int v = m_logic.get(x, y); if (v == EMPTY) continue; int sx, sy; boardToScreen(x, y, sx, sy); int r = m_cellSize / 2 - 3; CBrush brush(v == PLAYER ? RGB(20, 20, 20) : RGB(240, 240, 240)); CBrush* pOldBrush = memDC.SelectObject(&brush); memDC.Ellipse(sx - r, sy - r, sx + r, sy + r); memDC.SelectObject(pOldBrush); } } // 一次性贴到屏幕 pDC->BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }

逻辑说明:CreateCompatibleDC创建内存设备上下文,CreateCompatibleBitmap创建与屏幕兼容的位图,所有绘制先在内存里完成,最后BitBlt一次拷贝。参数上,棋子半径取cellSize/2 - 3,留 3 像素间隙避免相邻棋子粘连。玩家黑子用RGB(20,20,20),AI 白子用RGB(240,240,240),白子要加一圈深色边框才看得清,可以在Ellipse前先画一个稍大的深色圆。

OnPaint里调用drawBoard,并加上OnEraseBkgnd返回 TRUE:

BOOL CGobangMFCDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; // 阻止默认擦背景,消除闪烁 }

提示:如果发现窗口最小化再恢复后棋盘消失,多半是没在OnPaint里重绘,或者内存 DC 没随窗口尺寸重建。把双缓冲对象做成局部变量、每次OnPaint重新创建,能规避大部分这类问题。

4. 鼠标落子与胜负判定:消息映射怎么接

4.1 用类向导绑定 WM_LBUTTONDOWN

在对话框资源上右键「添加事件处理程序」,消息类型选WM_LBUTTONDOWN,类列表选对话框类,函数名默认OnLButtonDown。生成后在里面写落子逻辑。

void CGobangMFCDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (m_gameOver) { CDialogEx::OnLButtonDown(nFlags, point); return; } int bx, by; if (!screenToBoard(point.x, point.y, bx, by)) { CDialogEx::OnLButtonDown(nFlags, point); return; } if (!m_logic.place(bx, by, PLAYER)) { // 该点已有子 CDialogEx::OnLButtonDown(nFlags, point); return; } Invalidate(); // 触发重绘 if (m_logic.isWin(bx, by, PLAYER)) { m_gameOver = true; MessageBox(_T("你赢了!"), _T("结果"), MB_OK); return; } if (m_logic.isFull()) { m_gameOver = true; MessageBox(_T("平局"), _T("结果"), MB_OK); return; } // 轮到 AI aiMove(); Invalidate(); CDialogEx::OnLButtonDown(nFlags, point); }

逻辑说明:先判m_gameOver防止结束后继续落子;screenToBoard把像素坐标转成棋盘坐标,失败直接返回;place返回 false 说明该点已有子,也返回。落子成功后Invalidate触发OnPaint重绘。玩家赢或平局要立刻置m_gameOver,否则 AI 还会继续走。

参数上,Invalidate()默认擦背景,配合前面的OnEraseBkgnd返回 TRUE 就不会闪。如果想让重绘更精确,可以传InvalidateRect只刷新棋盘区域,但大作业里全刷足够。

4.2 胜负判定的边界:连五还是长连

标准五子棋(无禁手)里,连成 5 个或以上都算赢。isWincnt >= 5就是这个规则。如果老师要求实现有禁手的专业规则,那要额外处理「三三禁手」「四四禁手」「长连禁手」,复杂度陡增,大作业一般不做。这里明确用无禁手规则,答辩时能说清就行。

一个容易忽略的点:判定必须针对「刚落下的那颗子」而不是全盘扫描。全盘扫描每次 O(225×4×5),虽然也不慢,但针对落子点判定是 O(4×5),更干净,也避免把之前已经连五但没判的情况重复触发。

4.3 重开一局和状态复位

加一个「重新开始」按钮,ID 设为IDC_BTN_RESTART,双击生成OnBnClickedBtnRestart

void CGobangMFCDlg::OnBnClickedBtnRestart() { m_logic.reset(); m_gameOver = false; Invalidate(); }

reset把棋盘清空、计数归零,m_gameOver复位,然后重绘。注意如果 AI 是异步线程跑的,这里要加锁或标志位,但本方案 AI 是同步调用,不存在竞态。

5. 人机对战 AI:估值函数与极小极大搜索怎么落地

5.1 先想清楚 AI 要什么水平

大作业的 AI 不需要打到职业段位,但也不能只会堵一步。合理的定位是:能识别活三、冲四、活四这些基本棋型,会做简单的攻防权衡,搜索深度 2 到 4 层。常见做法是「棋型打分 + 极小极大 + Alpha-Beta 剪枝」,这套组合在 15×15 上跑起来毫秒级,答辩时也讲得清楚。

如果只做一层贪心——对每个空位算一个分,选最高分落子——实现最简单,但会被「双三」这种需要两步才能形成的威胁骗过。加一层搜索(考虑对手回应)就能明显改善。

5.2 棋型打分表怎么定

打分的基本单位是「一条线上某个空位如果落子,能形成什么棋型」。常见棋型分值:

棋型说明建议分值
成五已有四子,落子即五连100000
活四两端开放的四连10000
冲四一端被封的四连1000
活三两端开放的三连1000
眠三一端被封的三连100
活二两端开放的二连100
眠二一端被封的二连10

这些分值不是绝对的,核心是让「成五 > 活四 > 冲四 ≈ 活三 > 眠三 ≈ 活二」。冲四和活三同分是有意的:冲四下一步能成五,活三下一步能成活四,威胁等级接近,AI 需要根据局面权衡。

// 评估在 (x,y) 落 who 子后,该点四个方向的棋型总分 int GobangLogic::evaluatePoint(int x, int y, int who) const { const int dx[4] = {1, 0, 1, 1}; const int dy[4] = {0, 1, 1, -1}; int total = 0; for (int d = 0; d < 4; ++d) { int count = 1; // 当前子 int block = 0; // 被封端数 int empty = 0; // 空位数(用于识别跳活三) // 正方向 for (int s = 1; s < 5; ++s) { int nx = x + dx[d] * s, ny = y + dy[d] * s; if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) { ++block; break; } int v = m_board[ny * BOARD_SIZE + nx]; if (v == who) ++count; else if (v == EMPTY) { ++empty; break; } else { ++block; break; } } // 反方向同理 for (int s = 1; s < 5; ++s) { int nx = x - dx[d] * s, ny = y - dy[d] * s; if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) { ++block; break; } int v = m_board[ny * BOARD_SIZE + nx]; if (v == who) ++count; else if (v == EMPTY) { ++empty; break; } else { ++block; break; } } total += shapeScore(count, block, empty); } return total; }

shapeScore根据count(连子数)、block(被封端数)、empty(是否跳空)返回对应分值。这里简化了跳活三的识别,用empty标记,实际写的时候可以按countblock组合查表。

5.3 极小极大 + Alpha-Beta 剪枝

搜索框架:AI 走一步,玩家走一步,交替到指定深度,叶子节点用全盘估值。Alpha-Beta 剪枝在搜索过程中维护alpha(当前最大下界)和beta(当前最小上界),当alpha >= beta时剪掉后续分支。

int CGobangMFCDlg::minimax(int depth, int alpha, int beta, bool isAI) { if (depth == 0) return evaluateBoard(); // 候选点:只考虑已有棋子周围 2 格内的空位,减少分支 std::vector<CPoint> moves = genMoves(); if (moves.empty()) return evaluateBoard(); if (isAI) { int best = INT_MIN; for (auto& p : moves) { m_logic.place(p.x, p.y, AI); int val = minimax(depth - 1, alpha, beta, false); m_logic.undo(p.x, p.y); // 撤销 best = max(best, val); alpha = max(alpha, best); if (alpha >= beta) break; // 剪枝 } return best; } else { int best = INT_MAX; for (auto& p : moves) { m_logic.place(p.x, p.y, PLAYER); int val = minimax(depth - 1, alpha, beta, true); m_logic.undo(p.x, p.y); best = min(best, val); beta = min(beta, best); if (alpha >= beta) break; } return best; } }

参数说明:depth是剩余搜索层数,大作业取 2 到 4 足够;alpha/beta初始传INT_MIN/INT_MAXisAI标记当前该谁走。genMoves只生成已有棋子周围 2 格内的空位,这是把分支从 225 降到几十的关键优化,否则深度 4 会卡顿。undo需要在GobangLogic里补一个撤销方法,把该点置回 EMPTY 并--m_count

aiMove就是遍历候选点,对每个点试落 AI 子,调用minimax取最高分对应的点:

void CGobangMFCDlg::aiMove() { std::vector<CPoint> moves = genMoves(); if (moves.empty()) return; int bestVal = INT_MIN; CPoint bestMove = moves[0]; for (auto& p : moves) { m_logic.place(p.x, p.y, AI); int val = minimax(3, INT_MIN, INT_MAX, false); m_logic.undo(p.x, p.y); if (val > bestVal) { bestVal = val; bestMove = p; } } m_logic.place(bestMove.x, bestMove.y, AI); if (m_logic.isWin(bestMove.x, bestMove.y, AI)) { m_gameOver = true; MessageBox(_T("电脑赢了"), _T("结果"), MB_OK); } }

注意:genMoves如果返回空(棋盘全空),AI 第一步要特殊处理,直接下天元(7,7)。否则空棋盘上没有任何「已有棋子周围」的点,AI 会无子可下。

6. 避坑与排查:那些让大作业翻车的细节

6.1 现象:编译报错「无法打开包括文件 pch.h」

原因:MFC 工程默认开启预编译头,新建的.cpp如果没在第一行#include "pch.h",或者工程属性里预编译头设置不一致,就会报这个。解决:所有新建的.cpp第一行都加#include "pch.h";如果某个文件不想用预编译头,在文件属性里把「预编译头」设为「不使用预编译头」。

6.2 现象:鼠标点击落子位置偏移一格

原因:screenToBoard里没做四舍五入,直接用整除,导致点击交叉点右下方时被算到下一个格子。解决:整除前加m_cellSize / 2,即(sx - m_originX + m_cellSize/2) / m_cellSize。另外确认m_originXm_cellSizeOnInitDialog里已经初始化,别用未初始化的值。

6.3 现象:AI 思考时界面卡死

原因:minimax在 UI 线程同步执行,深度太大或候选点太多时阻塞消息循环。解决:深度控制在 4 以内;genMoves限制候选点数量(比如按估值排序取前 12 个);如果非要更深,把 AI 放到工作线程,用PostMessage回传结果,但大作业不建议引入线程复杂度。

6.4 现象:斜向连五判不出来

原因:isWin里副对角方向的dy写成了1而不是-1,或者dx/dy数组顺序和循环里的方向对不上。解决:四个方向固定为(1,0)横、(0,1)竖、(1,1)主对角、(1,-1)副对角,写完后手动在纸上画一遍验证。这是血泪经验,斜向判定错一次能查半天。

6.5 现象:窗口拉伸后棋盘错位或棋子画到外面

原因:棋盘几何参数在OnInitDialog里按初始窗口尺寸算死了,窗口拉伸后没重算。解决:把m_cellSizem_originX/Y的计算放到OnSize里,根据当前客户区尺寸动态算,保证棋盘居中。或者干脆在对话框属性里把「边框」设为「对话框外框」并禁用最大化,从源头避免拉伸。

7. 让 AI 更聪明的两个进阶技巧:候选点排序与置换表

前面那套 AI 能应付大作业,但如果你想让它在答辩时多拿几分,有两个改动性价比很高。

第一个是候选点排序。genMoves现在返回的顺序是遍历顺序,Alpha-Beta 剪枝的效果高度依赖先搜到好棋。做法是:对每个候选点先算一个「快速估值」——分别算 AI 落这里的得分和玩家落这里的得分,取两者较大值作为排序键,降序排列。这样 AI 自己的好点和必须堵的玩家好点都排在前面,剪枝能砍掉更多分支。实测在深度 4 下,排序后搜索节点数能降一半以上。

std::vector<CPoint> CGobangMFCDlg::genMoves() { std::vector<std::pair<int, CPoint>> scored; for (int y = 0; y < BOARD_SIZE; ++y) { for (int x = 0; x < BOARD_SIZE; ++x) { if (m_logic.get(x, y) != EMPTY) continue; if (!hasNeighbor(x, y, 2)) continue; // 周围 2 格内有子才考虑 int sAI = m_logic.evaluatePoint(x, y, AI); int sPlayer = m_logic.evaluatePoint(x, y, PLAYER); scored.push_back({ max(sAI, sPlayer), CPoint(x, y) }); } } std::sort(scored.begin(), scored.end(), [](auto& a, auto& b) { return a.first > b.first; }); std::vector<CPoint> result; for (int i = 0; i < (int)scored.size() && i < 12; ++i) result.push_back(scored[i].second); // 只取前 12 个 return result; }

hasNeighbor判断某点周围radius格内是否有棋子,没有就跳过,这是把候选从 225 降到几十的第一层过滤。max(sAI, sPlayer)让攻防点都靠前。取前 12 个是分支上限,太大搜索慢,太小可能漏掉关键点,12 是个经验值。

第二个是置换表。同一局面可能通过不同落子顺序到达,用哈希表缓存「局面 → 估值」,下次遇到直接查表。局面哈希可以用 Zobrist 哈希:给每个「位置 + 颜色」组合预生成一个随机数,局面哈希就是所有已落子对应随机数的异或。落子时异或一次,撤销时再异或一次,天然可逆。

// Zobrist 表初始化(程序启动时执行一次) static UINT64 zobrist[BOARD_SIZE][BOARD_SIZE][3]; static UINT64 g_hash = 0; void initZobrist() { srand((unsigned)time(nullptr)); for (int y = 0; y < BOARD_SIZE; ++y) for (int x = 0; x < BOARD_SIZE; ++x) for (int c = 0; c < 3; ++c) zobrist[y][x][c] = ((UINT64)rand() << 32) | rand(); }

落子时g_hash ^= zobrist[y][x][who],撤销时再异或同一个值即可还原。置换表用std::unordered_map<UINT64, int>存估值,配合深度信息判断是否可用。大作业里如果嫌麻烦,只做候选点排序就能有明显提升,置换表属于锦上添花。

最后说个验证方法:写一个selfPlay函数让 AI 自己跟自己下,跑 100 局看有没有出现「明明能赢却去堵无关位置」的蠢棋。我一般会打印每步的估值和候选点排序,肉眼扫几局就能发现估值函数的漏洞。这套东西调通之后,你会发现 MFC 那层只是壳,真正有意思的是估值和搜索的权衡——这也是我做完这个作业后最深的体会:界面能抄,AI 的手感只能自己一局一局喂出来。希望帮到你。

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

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

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

立即咨询