简介:一份基于MFC框架的五子棋人机对战项目,面向正在学习Windows桌面应用开发以及游戏人工智能入门的中高级C++程序员,着力演示如何利用微软基础类库构建带图形界面的交互式对战程序。压缩包内共收录37个文件,既包含7个头文件与6个C++源文件,也附带可直接运行的执行程序、界面图标、棋盘位图以及工程配置文件,全部资源约3.55MB。目前已有195人浏览学习。通过阅读源码可以梳理棋盘状态管理、落子合法性判断、横竖斜五子连珠检测等核心逻辑;人机对战部分采用极小极大搜索配合Alpha-Beta剪枝算法,在效率与棋力之间取得平衡,同时人人对战模式与胜负数据记录功能为界面交互和简单持久化存储提供了完整示范。项目结构清晰、注释分明,适合对照实践,用以巩固MFC消息映射、文档视图架构和基础游戏AI设计。
1. MFC五子棋人机:从压缩包到能跑通的对战程序,关键在这四处
如果你拿到一个名为 mfc.rar 的压缩包,里面大概率就是一份 MFC 五子棋人机对战的课程设计源码。它要解决的事情并没有想象中复杂:把 15×15 的棋盘画得干净不闪烁、把鼠标点击换算成精确的交叉点、让电脑会堵会攻。真正让新手反复翻车的,反而不是 AI 算法,而是窗口重绘、坐标换算和消息阻塞这些看着不起眼的基础功。这篇文章按我自己的落地顺序拆开讲——从对话框基类选择到双缓冲绘制,从胜负判定到棋型评分,最后补上悔棋和序列化回放的坑,每步都给出能直接抄的代码和参数。
2. 搭一个不闪烁的 MFC 五子棋棋盘:消息映射、双缓冲和坐标换算
2.1 选择对话框还是单文档:MFC 画棋盘的基类取舍
MFC 里能承载棋盘交互的窗口类无非几类:CDialog、CFormView、CView以及自绘CWnd。做五子棋人机,我一般直接用CDialogEx生成的对话框程序。原因很直接:课程设计最常用它,资源编辑器里放按钮、放状态栏提示都方便;而CView/CFrameWnd那套 Document/View 框架对这个规模的项目属于杀鸡用牛刀,还会牵扯到文档序列化和视窗刷新的额外规则。
在对话框的头文件里,棋盘数据和关键消息会这样声明:
// MyGobangDlg.h #pragma once #include "afxwin.h" #include "afxcmn.h" enum ChessColor { EMPTY = 0, BLACK = 1, WHITE = 2 }; class CMyGobangDlg : public CDialogEx { public: CMyGobangDlg(CWnd* pParent = nullptr); enum { IDD = IDD_MYGOBANG_DIALOG }; protected: virtual BOOL OnInitDialog(); afx_msg void OnPaint(); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); DECLARE_MESSAGE_MAP() private: int m_board[15][15]; // 0空、1黑、2白 bool m_bPlayerIsBlack; // 玩家是否执黑 CPoint m_lastMove; // 最后一手坐标 };这里必须解释几个设计点。m_board用二维数组存储棋盘状态,15 路棋盘的索引 0~14,正好对应交叉点,不用动态分配,查表和调试都直观。EMPTY/BLACK/WHITE用枚举类型而不是裸int,是为了在轮到判断时避免“1 和 2 谁是谁”的混淆。消息映射在MyGobangDlg.cpp里通过ON_WM_PAINT()和ON_WM_LBUTTONDOWN()注册,MFC 才会在窗口重绘和鼠标按下时回调对应函数。
有一点容易被忽略:对话框的OnPaint在窗口创建初期就会被调用,而此时棋盘数组还没初始化。所以OnInitDialog里要先把m_board全部清零,否则第一次绘制时会读取到未初始化的栈内存,轻则画出色块,重则直接崩溃。这也是很多人拿到模板工程最先踩的雷。
2.2 像素坐标到交叉点:加半个格子比不加直观得多
鼠标在客户区点下的point是以像素为单位的。棋盘从一个边距MARGIN开始画,每个格子宽度CELL_SIZE,那么交叉点的行列可以这样算:
const int BOARD_SIZE = 15; const int CELL_SIZE = 40; // 每格 40 像素 const int MARGIN = 30; // 棋盘外侧留白 // 鼠标事件里的 point 已经是客户区坐标 int col = (point.x - MARGIN + CELL_SIZE / 2) / CELL_SIZE; int row = (point.y - MARGIN + CELL_SIZE / 2) / CELL_SIZE; if (col < 0 || col >= BOARD_SIZE || row < 0 || row >= BOARD_SIZE) return; // 点在棋盘外,忽略 if (m_board[row][col] != EMPTY) return; // 已有棋子,忽略关键在+ CELL_SIZE / 2:如果直接(point.x - MARGIN) / CELL_SIZE,那么点击格子左边缘会落到上一列,右边缘落到下一列,肉眼看到的就是棋子偏移半个格。加CELL_SIZE / 2本质是四舍五入到最近的交叉点,这是五子棋落子的标准做法。
还有一个坑在“客户区坐标”这四个字上。OnLButtonDown消息里带的CPoint已经由 MFC 从屏幕坐标转换成了对话框客户区坐标,可以直接用。但如果你的代码用GetCursorPos获取坐标,拿到的是屏幕坐标,必须调用ScreenToClient(&pt)转换后再进入上述公式,否则在带标题栏的对话框上会出现固定偏移,棋子全部往下错一截。
2.3 双缓冲绘制:OnPaint 里 20 行解决 90% 的闪烁
MFC 自带的OnPaint默认会用CPaintDC dc(this)直接往窗口设备上下文画。每画一条网格线、每画一个棋子就刷一次屏幕,在窗口被拖动、频繁Invalidate时会产生肉眼可见的闪烁。双缓冲的做法是先在一张内存位图上画完整个棋盘,再一次性BitBlt到窗口。核心代码:
void CMyGobangDlg::OnPaint() { if (IsIconic()) { CDialogEx::OnPaint(); return; } CPaintDC dc(this); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, BOARD_PIXEL + 2 * MARGIN, BOARD_PIXEL + 2 * MARGIN); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 背景色 memDC.FillSolidRect(0, 0, BOARD_PIXEL + 2 * MARGIN, BOARD_PIXEL + 2 * MARGIN, RGB(220, 180, 120)); // 网格线 CPen pen(PS_SOLID, 1, RGB(0, 0, 0)); CPen* pOldPen = memDC.SelectObject(&pen); for (int i = 0; i < BOARD_SIZE; i++) { memDC.MoveTo(MARGIN, MARGIN + i * CELL_SIZE); memDC.LineTo(MARGIN + BOARD_PIXEL, MARGIN + i * CELL_SIZE); memDC.MoveTo(MARGIN + i * CELL_SIZE, MARGIN); memDC.LineTo(MARGIN + i * CELL_SIZE, MARGIN + BOARD_PIXEL); } // 先画棋盘上所有棋子 for (int r = 0; r < BOARD_SIZE; r++) for (int c = 0; c < BOARD_SIZE; c++) if (m_board[r][c] != EMPTY) DrawStone(&memDC, r, c, m_board[r][c]); // 一次性贴到窗口 dc.BitBlt(0, 0, BOARD_PIXEL + 2 * MARGIN, BOARD_PIXEL + 2 * MARGIN, &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); memDC.SelectObject(pOldPen); }其中BOARD_PIXEL = CELL_SIZE * (BOARD_SIZE - 1),也就是 560 像素。这里有一个细节:CreateCompatibleBitmap创建的内存位图默认内容随机,如果不先FillSolidRect,棋盘外区域会出现花屏。另外SelectObject返回的旧对象必须保存并在退出前选回,否则CBitmap析构时可能操作错误的 GDI 对象,在调试版里弹断言。
DrawStone是独立的绘制函数,这样OnPaint只负责遍历棋盘并调用它,逻辑清晰,也方便以后加预览棋子:
void CMyGobangDlg::DrawStone(CDC* pDC, int row, int col, int color) { CPoint center(MARGIN + col * CELL_SIZE, MARGIN + row * CELL_SIZE); int radius = CELL_SIZE / 2 - 2; CBrush brush(color == BLACK ? RGB(0, 0, 0) : RGB(255, 255, 255)); CBrush* pOldBrush = pDC->SelectObject(&brush); pDC->SetBkMode(TRANSPARENT); // 防止文字/填充背景覆盖网格 pDC->Ellipse(center.x - radius, center.y - radius, center.x + radius, center.y + radius); pDC->SelectObject(pOldBrush); }默认的Ellipse会用背景色填充内部空隙,如果BK_MODE没有设成透明,棋子边缘会泛白,影响观感。radius = CELL_SIZE / 2 - 2让棋子直径略小于格子间距,这样棋子之间不会粘连,视觉上也更接近真实棋盘。
3. 人机对战的规则引擎:五连判断、落子合法性和轮转控制
3.1 胜负判定:一个函数横扫横竖两条斜线
五子棋判赢只需要检查最新落子的交叉点,以它为中心向四个方向(横、竖、主对角线、副对角线)数连续同色棋子数是否达到 5。代码可以写得很紧凑:
bool CMyGobangDlg::CheckWin(int row, int col, int color) { int dirs[4][2] = { {1, 0}, // 竖直方向 {0, 1}, // 水平方向 {1, 1}, // 右下/左上斜线 {1, -1} // 右上/左下斜线 }; for (int d = 0; d < 4; d++) { int count = 1; // 当前落子本身 // 向正方向延伸 int r = row + dirs[d][0]; int c = col + dirs[d][1]; while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && m_board[r][c] == color) { count++; r += dirs[d][0]; c += dirs[d][1]; } // 向反方向延伸 r = row - dirs[d][0]; c = col - dirs[d][1]; while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && m_board[r][c] == color) { count++; r -= dirs[d][0]; c -= dirs[d][1]; } if (count >= 5) return true; } return false; }这里的边界检查是必不可少的。board[r][c]本身不检查越界更快的写法是r < 0时直接 break,但判断条件里先做边界比较能避免数组访问越界。另一个容易被忽略的点:判断胜负时传入的color必须与棋盘上放置的棋子颜色一致,建议在落子之后立刻调用CheckWin(row, col, m_playerColor),而不要自己去猜当前轮是谁,避免状态错乱。
还要注意“长连”问题。标准五子棋中五连即胜,但有些规则要求“六连及以上”不算五连。上面代码用count >= 5,如果项目要求严格五连,改成count == 5并且额外检查两端是否有异色或边界。不过课程设计通常不区分,用>= 5是行业普遍做法。
3.2 轮转控制:玩家落子,电脑回应,别让界面卡成白板
整个对局就是一个状态机:玩家可落子 → 玩家落子 → 判断胜负 → AI 落子 → 判断胜负 → 回到玩家。最容易出问题的环节在 AI 落子时阻塞了主线程。下面是一段稳妥的OnLButtonDown流程:
void CMyGobangDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (m_bGameOver || !m_bPlayerTurn) return; // 不在玩家回合,忽略 int col = (point.x - MARGIN + CELL_SIZE / 2) / CELL_SIZE; int row = (point.y - MARGIN + CELL_SIZE / 2) / CELL_SIZE; if (col < 0 || col >= BOARD_SIZE || row < 0 || row >= BOARD_SIZE) return; if (m_board[row][col] != EMPTY) return; m_board[row][col] = m_playerColor; // 玩家落子 m_lastMove = CPoint(row, col); Invalidate(FALSE); // 请求重绘,不擦背景 if (CheckWin(row, col, m_playerColor)) { m_bGameOver = true; MessageBox(_T("玩家获胜")); return; } m_bPlayerTurn = false; // 切换回合 AIPlay(); // AI 走棋 Invalidate(FALSE); UpdateWindow(); // 强制立即重绘 }注意UpdateWindow()和Invalidate(FALSE)的区别:Invalidate只是告诉系统客户区无效,WM_PAINT 会在消息队列空闲时触发;UpdateWindow则立即发送 WM_PAINT 并等待重绘完成。在 AI 计算耗时较长时,先Invalidate(FALSE)让玩家刚下的棋子显示出来,再调用UpdateWindow刷掉待处理区域,可以避免画面停留在上一手。
AI 内部的落子也必须走同样的合法性检查。如果 AI 随机挑选空点,却忘记判断周围八格是否有棋子,就会出现电脑下在离战场很远的孤岛位置,看起来像 bug。更合理的做法是 AI 只评估距离已有棋子 2 格以内的空点,这部分在下一章展开。
3.3 悔棋恢复:一次悔棋要回退两步,否则 AI 会秒下
课程设计必有一项“悔棋”功能。最直接的实现是在vector<CPoint>里保存每回合双方落子的坐标序列。玩家点击“悔棋”按钮时不能只弹出最后一颗玩家棋子,否则棋盘上还留着 AI 上一手的子,轮到玩家又得立刻重新面对这个局面。
void CMyGobangDlg::UndoLastTurn() { if (m_moveHistory.size() < 2) { MessageBox(_T("没有可悔的棋")); return; } // 回退玩家这一步 CPoint pp = m_moveHistory.back(); m_board[pp.x][pp.y] = EMPTY; m_moveHistory.pop_back(); // 再回退 AI 这一步 CPoint ap = m_moveHistory.back(); m_board[ap.x][ap.y] = EMPTY; m_moveHistory.pop_back(); m_bPlayerTurn = true; m_bGameOver = false; Invalidate(FALSE); }用vector<CPoint>时要注意保存的坐标顺序。如果每回合先压玩家后压 AI,那么back()是 AI 的最后一步,pop_back()两次就完整退回玩家落子前的状态。这个逻辑在玩家执黑和执白时都成立,不需要区分当前是什么颜色。还有一个细节:悔棋后如果对局已经结束,必须把m_bGameOver置回false,否则窗口虽然显示棋盘,但点击任何地方都不会响应。
4. 人机 AI 引擎:基于棋型评分的攻防决策与三个必调参数
4.1 棋型分值表:活三、冲四到底值几分
五子棋 AI 最简单又最容易出效果的做法不是深度搜索,而是对每一个空点打一个“威胁分”。这个分由该点向四个方向形成的棋型决定。先定义一套通用的棋型分值,我常用的是:
| 棋型 | 分值 | 出现条件 |
|---|---|---|
| 连五 | 1000000 | 5 颗连续同色 |
| 活四 | 100000 | 四颗连续,两端均空 |
| 冲四 | 10000 | 四颗连续,一端有堵 |
| 活三 | 5000 | 三颗连续,两端均空 |
| 眠三 | 1000 | 三颗连续,一端有堵 |
| 活二 | 200 | 两颗连续,两端均空 |
| 眠二 | 50 | 两颗连续,一端有堵 |
这个分表不必追求绝对精确,只要保证数量级差距就能让 AI 优先堵活三、冲四,而不是傻傻地连自己那个眠二。关键在计算时怎么数连续棋子和开放端。下面是一个单方向上的统计实现:
int CMyGobangDlg::CountDirection(int row, int col, int color, int dIdx) { static int dirs[4][2] = {{1,0}, {0,1}, {1,1}, {1,-1}}; int count = 1; int openEnds = 0; // 正方向延伸 int i = 1; while (true) { int r = row + i * dirs[dIdx][0]; int c = col + i * dirs[dIdx][1]; if (r < 0 || r >= 15 || c < 0 || c >= 15) break; // 越界视为堵 if (m_board[r][c] == color) count++; else if (m_board[r][c] == EMPTY) { openEnds++; break; } else break; i++; } // 负方向延伸 i = -1; while (true) { int r = row + i * dirs[dIdx][0]; int c = col + i * dirs[dIdx][1]; if (r < 0 || r >= 15 || c < 0 || c >= 15) break; if (m_board[r][c] == color) count++; else if (m_board[r][c] == EMPTY) { openEnds++; break; } else break; i--; } // 把 count 和 openEnds 换算成分数 if (count >= 5) return 1000000; if (count == 4 && openEnds == 2) return 100000; if (count == 4 && openEnds == 1) return 10000; if (count == 3 && openEnds == 2) return 5000; if (count == 3 && openEnds == 1) return 1000; if (count == 2 && openEnds == 2) return 200; if (count == 2 && openEnds == 1) return 50; return 0; }这个函数有几个值得注意的边界条件。越界被当作堵,因为棋盘边缘确实不能形成开放端;遇到对方棋子立即停止并且不增加开放端。举个例子,某一方向上连续 4 颗白子但一端是边界另一端是空,那么openEnds = 1,查表得到 10000 的冲四——AI 看到冲四必须堵,否则对方下一步连五。如果count == 4 && openEnds == 0,它其实是一个死四,不会立即形成五连,分值会落到 0,这个细节让 AI 不会被假的威胁带偏。
4.2 两步预判:先防对方的杀招,再找自己的最佳点
有了单点单方向的评分,一个完整落子点的得分就是对四个方向求和。但只评估己方的威胁还不够,好的 AI 必须同时评估“如果对手下在这里有多危险”。我常用的GetBestMove逻辑分三层:
CPoint CMyGobangDlg::GetBestMove() { int bestScore = -1; CPoint bestMove(-1, -1); for (int r = 0; r < 15; r++) { for (int c = 0; c < 15; c++) { if (m_board[r][c] != EMPTY) continue; if (!IsNearby(r, c)) // 只评估附近有棋子的点 continue; // 第一优先级:我方下一步能赢就走那里 if (WouldWin(r, c, m_aiColor)) return CPoint(r, c); // 第二优先级:对方下一步能赢必须堵 if (WouldWin(r, c, m_playerColor)) return CPoint(r, c); // 第三优先级:威胁分 = 己方进攻 + 对方威胁 * 权重 int score = EvaluatePoint(r, c, m_aiColor) + (int)(EvaluatePoint(r, c, m_playerColor) * 1.2); if (score > bestScore) { bestScore = score; bestMove = CPoint(r, c); } } } return bestMove; }WouldWin的实现很直接:临时把棋子放到该点,调用CheckWin,然后再恢复成空。这就是为什么说“后悔药”很重要——AI 试探杀招时不得不修改棋盘状态,但用完一定得恢复,否则棋盘上会多出一堆幽灵棋子。IsNearby的常见做法:检查以该点为中心的 5×5 邻域内是否有任意非空棋子,如果没有就跳过。这个剪枝能砍掉大部分远离战场的位置,让 15×15 的棋盘实际只评估 30~50 个候选点,计算压力很小。
防守权重 1.2 是第一个必调参数。它表达的是“同样的棋型,对方形成的威胁比自己形成的进攻更值得重视”。五子棋先手优势明显,如果这个权重太低,AI 会只顾着做自己的活三,被玩家抢先连五;权重太高,AI 又会过度防守,永远赢不了。我建议从 1.0 到 1.5 之间逐档调,先手玩家时设 1.2,AI 自己先手时设 1.0,效果比较平衡。
4.3 三个必调参数:候选范围、防守权重和随机扰动
除了防守权重,另外两个参数直接决定“AI 强不强”以及“AI 是不是每次都一样”。候选范围指IsNearby的半径,常见值是 1 到 3 格。半径越大,AI 看得越远,但计算量按平方上升;15 路棋盘我一般用 2 格半径,既能抓住对方隔着两个空位的冲四,又不至于每次循环都扫全盘。
第三个参数是随机扰动。当多个空点的总分相同时,AI 永远选第一个,会出现玩家固定走某条线就必赢的套路。解决办法是给最终得分加上一个占最终分数 0~3% 的随机偏移:
score += (rand() % 100) * 0.01; // 0~1 分的轻微扰动这样同分决胜点时电脑不会每次走同一个位置,棋局变化更多。扰动比例不能超过 5%,否则会覆盖真实差距,让 AI 忽略真正的杀棋。这三个参数就是调难度的核心:把候选范围调大、防守权重调到 1.3、扰动压到 1%,AI 会显得稳健又多变,玩家胜率能明显下降。
5. MFC 五子棋人机踩坑实录:重绘闪烁、坐标漂移和 AI 假死这样解
5.1 窗口拖动后棋盘出现黑色残影
现象:窗口缩小、拖动或切换后,棋盘区域出现一团团黑色块,甚至有些棋子像“拖了影子”。
原因:这是典型的 WM_PAINT 处理错误。很多人直接在OnPaint里用CClientDC代替CPaintDC,导致两个设备上下文同时操作窗口,BeginPaint/EndPaint不匹配,系统无法正确裁剪无效区域;另一种原因是Invalidate(TRUE)擦除背景,而背景刷子不是自己设置的,产生黑底。
解决:OnPaint里始终用CPaintDC dc(this),不要手动创建第二个上下文;调用重绘时用Invalidate(FALSE)并配合双缓冲。检查你的OnPaint是不是在CPaintDC之外又创建了CClientDC,如果有,删掉后者。
5.2 点击落子位置和鼠标点偏了半个格子
现象:鼠标点在两个交叉点中间,棋子却落在其中一个上,视觉上总是歪一格。
原因:坐标换算是直接整数除法,没有做四舍五入。比如point.x - MARGIN等于 19,19 / 40 = 0,而 19 离 20 更近,本应归到第 1 列;另外如果通过GetCursorPos拿的是屏幕坐标,没有ScreenToClient,也会造成整体偏移。
解决:统一用(point.x - MARGIN + CELL_SIZE / 2) / CELL_SIZE后判断边界,并且确认OnLButtonDown里传入的point没有被手动转换过。若用了GetCursorPos,请先ScreenToClient。
5.3 AI 下棋时窗口卡死,标题栏显示“未响应”
现象:玩家落子后整个窗口无法拖动,等好几秒才恢复;CPU 占用高,任务管理器里能看到主线程繁忙。
原因:AI 计算(尤其是不慎改成递归搜索后)在主线程内长循环,没有让消息泵运行。Windows 的消息机制里,窗口在同一个线程内如果持续执行耗时逻辑,WM_PAINT、WM_MOVE 等消息都会被阻塞,系统就判定窗口“未响应”。
解决:先保证 AI 算法本身轻量,只评估附近的空点;计算前后用PeekMessage处理必要的绘制消息。最常见做法是在AIPlay()开头先UpdateWindow()强制刷新玩家这手棋,计算结束后再Invalidate(FALSE) + UpdateWindow()。如果以后要加深搜索,应把 AI 放到工作线程,用 PostMessage 通知主线程重绘。
5.4 悔棋后电脑立刻又走一步
现象:玩家点击悔棋,棋盘上自己上一步消失了,但紧接着电脑马上又下一步,根本没有重新思考的时间。
原因:悔棋逻辑只回退了玩家的落子,没有回退 AI 的上一步;或者回退了棋子但没有把m_bPlayerTurn置为true,消息循环检测到不是玩家回合,AI 又被触发。
解决:确保一次悔棋弹出历史栈中两个坐标(玩家和 AI 各一步),并把m_bGameOver置为false、m_bPlayerTurn置为true。如果使用vector<CPoint>,注意先查size()是否大于等于 2,避免访问空栈。
5.5 Debug 模式下ASSERT弹窗,指向dumpcont.cpp
现象:程序运行时突然弹出断言错误,文件指向dumpcont.cpp类似位置的ATLTRACE相关代码。
原因:这是 MFC 的调试机制在检测 GDI 对象泄漏或设备上下文不平衡。常见于OnPaint里创建了CClientDC、CPen、CBrush但没有恢复和释放,或者SelectObject后没把旧对象选回。
解决:养成所有 GDI 对象成对恢复的习惯:pOldPen = pDC->SelectObject(newPen),用完pDC->SelectObject(pOldPen);局部变量CBrush、CPen在析构时会自己删除,但必须保证脱离SelectObject状态。双缓冲代码里尤其注意CBitmap和旧位图的恢复顺序。
6. 把整局存成文件:MFC 五子棋的序列化回放与复盘小工具
做到这步,你的 MFC 五子棋人机已经可以完整对局了。最后一件事往往被忽略:把对局过程保存下来,错了能复盘,赢了能分析 AI 是哪一手走偏的。用 MFC 的CArchive序列化落子序列,是成本最低、最稳的方案。先在类里加一个记录坐标的容器:
std::vector<CPoint> m_moveHistory; // 按顺序保存 (row, col)保存时注意区分坐标顺序,我每回合并不会混着存,而是约定偶数下标注玩家、奇数下标 AI。写入文件:
void CMyGobangDlg::SaveGame(LPCTSTR filename) { CFile file(filename, CFile::modeCreate | CFile::modeWrite); CArchive ar(&file, CArchive::store); ar << m_playerColor << m_aiColor; ar << (int)m_moveHistory.size(); for (size_t i = 0; i < m_moveHistory.size(); i++) { ar << m_moveHistory[i].x << m_moveHistory[i].y; } ar.Close(); file.Close(); }回放则是反向操作,读取文件后再用一个定时器逐手显示。核心逻辑很简单:
void CMyGobangDlg::StartReplay(LPCTSTR filename) { CFile file(filename, CFile::modeRead); CArchive ar(&file, CArchive::load); int n; ar >> m_playerColor >> m_aiColor; ar >> n; m_replayMoves.resize(n); for (int i = 0; i < n; i++) ar >> m_replayMoves[i].x >> m_replayMoves[i].y; ar.Close(); file.Close(); memset(m_board, 0, sizeof(m_board)); m_playIndex = 0; SetTimer(REPLAY_TIMER, 500, nullptr); // 每 500ms 走一手 }在OnTimer里按顺序把下一颗棋子填到棋盘,重绘。这个回放小工具有一个额外价值:你可以把 AI 的每一步和它的估值分数一起打到日志文件里,然后回放时观察——哪一步 AI 选了低分点,说明你的棋型分值表可能在那个场景下失真。这种用真实对局反向校准参数的方法,往往比反复调静态权重高效得多。
我自己的习惯是给项目留一个快捷键:空格键存档,F8 回放。整个序列化代码不过四五十行,却让一个“能下棋”的程序变成“能复盘、能调参、能演示”的完整作品。如果你要交课程设计,这也是一眼能看出的加分项。希望这些从绘制到 AI 的落地细节能帮到你,少踩几个我当年踩过的坑。
本文还有配套的精品资源,点击获取