1. 项目概述为什么今天还要聊VC6.0和五子棋如果你是一位有一定C基础但总感觉停留在语法层面对如何把代码变成一个看得见、摸得着的“程序”感到迷茫的开发者那么这个项目就是为你量身定做的。五子棋规则简单逻辑清晰是绝佳的练手项目。而VC6.0这个诞生于上世纪90年代末的“古董”开发环境在今天看来用它做项目更像是一场“考古式”的编程实践。你可能会问都202X年了为什么还要用VC6.0Visual Studio 2022不香吗Qt、wxWidgets这些现代框架不好用吗这正是这个项目的核心价值所在。首先VC6.0及其附带的MFCMicrosoft Foundation Classes框架是理解Windows桌面程序开发“底层逻辑”的绝佳入口。很多现代框架的封装其思想源头都能在MFC中找到影子。通过这个相对原始的工具你能更清晰地看到一个Windows消息是如何从操作系统传递到你的窗口你的鼠标点击事件是如何被捕获并处理的。其次许多高校的课程设计、一些遗留系统的维护依然绕不开这个环境。掌握它等于掌握了一把打开特定领域大门的钥匙。这个项目将带你从零开始在VC6.0环境下用MFC开发一个具备完整人机对战功能的五子棋游戏。我们不仅会实现棋盘绘制、落子逻辑、胜负判定更会深入一个简单的AI算法如极大极小值搜索或启发式评估让你理解“电脑是如何思考下一步棋”的。整个过程就像在修复一台老式收音机每一步都需要你亲手焊接但当你听到声音响起时那种对原理的通透理解是使用现成“智能音箱”无法比拟的。2. 核心需求与整体设计思路2.1 功能需求拆解一个完整的五子棋游戏远不止“黑子白子轮流下”那么简单。我们需要将其拆解为几个核心模块图形用户界面GUI模块这是用户看到的一切。需要一个棋盘窗口、棋子绘制、当前玩家/回合提示、胜负提示框、以及“重新开始”、“悔棋”等控制按钮。游戏逻辑核心模块这是游戏的大脑。负责维护一个虚拟的棋盘状态通常是一个15x15的二维数组处理玩家的落子请求判断落子位置是否合法并在每次落子后检查是否形成五连珠横、竖、斜以判定胜负。人机对战AI模块这是项目的难点和亮点。电脑需要根据当前棋盘状态计算出一个“最优”或“较优”的落子位置。我们将实现一个基础的算法让电脑具备初级棋力。数据持久化模块可选但建议用于保存游戏记录、棋谱或者实现简单的“复盘”功能。这能加深你对文件I/O和数据结构设计的理解。2.2 技术选型与MFC框架浅析在VC6.0的世界里MFC是我们的不二之选。MFC是一个用C类对Windows API进行封装的应用程序框架。你可以把它想象成一个乐高积木套装它提供了“窗口”、“按钮”、“对话框”这些基础积木块类我们通过继承和组合这些类来搭建我们的应用程序。为什么用MFC而不是纯Win32 API纯Win32 API开发就像用汇编语言写程序控制力极强但极其繁琐。你需要自己处理窗口注册、消息循环、绘制细节等大量样板代码。MFC帮我们封装了这些让我们可以更专注于业务逻辑。例如处理一个鼠标左键点击消息在MFC中你只需要在对应的视图类里重写OnLButtonDown函数即可。我们的项目将采用经典的“文档/视图Document/View”架构。这是MFC中用于分离数据管理和数据显示的经典模式。文档类CMyFiveChessDoc对应我们的“游戏逻辑核心模块”。它负责保存和管理棋盘数据那个二维数组、当前玩家状态、游戏记录等。它是数据的中心。视图类CMyFiveChessView对应我们的“图形用户界面模块”。它负责从文档类获取数据并将其绘制到屏幕上画棋盘、画棋子同时接收用户的鼠标、键盘输入并转发给文档类进行处理。主框架窗口类CMainFrame和应用程序类CMyFiveChessApp负责程序的整体框架和生命周期管理。这种分离的好处是清晰的修改棋盘绘制风格视图不会影响游戏规则文档未来如果想增加一个网络对战功能只需要修改或扩展文档类的数据交互部分视图部分可以基本不变。注意VC6.0对C标准的支持停留在很旧的阶段比如不支持STL中的很多容器模板功能也有限。因此我们在数据结构选择上会偏向于使用原生数组、CArray、CList等MFC自带的集合类这本身也是一种对历史环境的适应和学习。3. 核心细节解析与实操要点3.1 棋盘与棋子数据结构的定义这是所有逻辑的基石。在文档类CMyFiveChessDoc的头文件中我们需要定义核心数据结构。// 在 MyFiveChessDoc.h 中 class CMyFiveChessDoc : public CDocument { // ... 其他代码 public: // 定义棋盘大小通常为15x15 static const int BOARD_SIZE 15; // 定义棋子状态0-空1-黑子2-白子 static const int EMPTY 0; static const int BLACK 1; static const int WHITE 2; // 核心数据成员一个二维数组表示棋盘状态 int m_board[BOARD_SIZE][BOARD_SIZE]; // 当前轮到谁下棋 int m_currentPlayer; // 游戏是否结束 BOOL m_gameOver; // 获胜方 int m_winner; // 记录每一步的棋步用于悔棋等功能 CArrayCPoint, CPoint m_moveHistory; // ... 成员函数 };为什么用二维数组而不是vectorvectorint在VC6.0的环境下标准模板库STL的支持和稳定性并不理想。而原生的二维数组访问速度最快概念最清晰与后续的AI算法中的棋盘评估函数结合也最直接。CArray是MFC提供的动态数组模板类我们用它来记录历史棋步方便实现悔棋。3.2 视图绘制如何画出一个标准的棋盘和棋子视图类CMyFiveChessView的重绘函数OnDraw是整个UI表现的核心。这里涉及到Windows图形设备接口GDI的基本操作。void CMyFiveChessView::OnDraw(CDC* pDC) { CMyFiveChessDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); // 1. 绘制棋盘网格 pDC-SelectStockObject(BLACK_PEN); // 选择黑色画笔 int cellWidth 30; // 每个格子的像素宽度 int startX 50, startY 50; // 棋盘起始坐标 // 画横线 for (int i 0; i pDoc-BOARD_SIZE; i) { pDC-MoveTo(startX, startY i * cellWidth); pDC-LineTo(startX pDoc-BOARD_SIZE * cellWidth, startY i * cellWidth); } // 画竖线 for (int i 0; i pDoc-BOARD_SIZE; i) { pDC-MoveTo(startX i * cellWidth, startY); pDC-LineTo(startX i * cellWidth, startY pDoc-BOARD_SIZE * cellWidth); } // 2. 绘制所有棋子 for (int i 0; i pDoc-BOARD_SIZE; i) { for (int j 0; j pDoc-BOARD_SIZE; j) { if (pDoc-m_board[i][j] ! pDoc-EMPTY) { CBrush brush; // 根据棋子状态选择画刷颜色 if (pDoc-m_board[i][j] pDoc-BLACK) { brush.CreateSolidBrush(RGB(0, 0, 0)); // 黑色画刷 } else { brush.CreateSolidBrush(RGB(255, 255, 255)); // 白色画刷 // 白棋需要画一个黑边以示区分 pDC-SelectStockObject(BLACK_PEN); } CBrush* pOldBrush pDC-SelectObject(brush); // 计算棋子在屏幕上的中心坐标 int centerX startX j * cellWidth; int centerY startY i * cellWidth; int radius cellWidth / 2 - 2; // 棋子半径略小于格子一半 // 用画圆函数绘制棋子 pDC-Ellipse(centerX - radius, centerY - radius, centerX radius, centerY radius); pDC-SelectObject(pOldBrush); // 恢复旧的画刷 brush.DeleteObject(); // 删除创建的画刷对象防止资源泄漏 } } } // 3. 绘制游戏状态信息如当前玩家、胜负提示 CString status; if (pDoc-m_gameOver) { status.Format(_T(游戏结束%s获胜), pDoc-m_winner pDoc-BLACK ? _T(黑方) : _T(白方)); } else { status.Format(_T(当前轮到%s), pDoc-m_currentPlayer pDoc-BLACK ? _T(黑方) : _T(白方)); } pDC-TextOut(10, 10, status); }实操心得GDI资源管理GDI编程的一个关键点是资源管理。像CBrush、CPen这类GDI对象使用后必须及时删除DeleteObject否则会造成GDI资源泄漏长时间运行可能导致程序或系统图形性能下降。上面代码中我们在绘制每个棋子后立即删除画刷是一种好习惯。更高效的做法是在视图类初始化时创建好黑、白两个画刷对象作为成员变量在OnDraw中直接选用避免在重绘循环中频繁创建和销毁对象。3.3 鼠标交互与落子逻辑用户通过鼠标点击来落子。我们需要在视图类中处理WM_LBUTTONDOWN消息。void CMyFiveChessView::OnLButtonDown(UINT nFlags, CPoint point) { CMyFiveChessDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); // 如果游戏已结束不处理点击 if (pDoc-m_gameOver) { return; } // 1. 将屏幕像素坐标转换为棋盘数组坐标 int cellWidth 30; int startX 50, startY 50; // 计算最近的交叉点 int col (point.x - startX cellWidth / 2) / cellWidth; int row (point.y - startY cellWidth / 2) / cellWidth; // 2. 检查坐标是否在棋盘有效范围内 if (row 0 row pDoc-BOARD_SIZE col 0 col pDoc-BOARD_SIZE) { // 3. 检查该位置是否为空 if (pDoc-m_board[row][col] pDoc-EMPTY) { // 4. 调用文档类的落子函数 pDoc-PlaceStone(row, col, pDoc-m_currentPlayer); // 5. 视图更新 Invalidate(); // 标记整个窗口区域需要重绘触发OnDraw } else { // 可以播放一个提示音或显示提示信息此处已有棋子 MessageBeep(MB_ICONEXCLAMATION); } } CView::OnLButtonDown(nFlags, point); }落子的核心逻辑在文档类的PlaceStone函数中BOOL CMyFiveChessDoc::PlaceStone(int row, int col, int player) { // 1. 安全检查 if (m_gameOver || m_board[row][col] ! EMPTY) { return FALSE; } // 2. 更新棋盘状态 m_board[row][col] player; // 记录这一步 m_moveHistory.Add(CPoint(col, row)); // 注意CPoint是(x,y)对应(col,row) // 3. 检查是否获胜 if (CheckWin(row, col, player)) { m_gameOver TRUE; m_winner player; UpdateAllViews(NULL); // 通知所有视图更新 return TRUE; } // 4. 切换玩家 m_currentPlayer (player BLACK) ? WHITE : BLACK; // 5. 如果是人机对战模式且轮到电脑假设电脑是白方 if (m_isAIMode m_currentPlayer WHITE !m_gameOver) { // 触发AI计算并落子 PostMessage(AfxGetMainWnd()-m_hWnd, WM_AI_MOVE, 0, 0); // 使用PostMessage异步触发避免阻塞UI线程 } UpdateAllViews(NULL); // 通知所有视图更新 return TRUE; }重要提示UpdateAllViews(NULL)是文档/视图架构的关键。它通知所有与此文档关联的视图数据已经发生变化视图应该调用自己的OnDraw函数来更新显示。这是一种观察者模式的实现保证了数据和显示的同步。4. 人机对战AI算法的实现初级这是项目的灵魂。我们实现一个基于“启发式评估”的AI。它的思想是不对所有可能走法进行深度搜索那是极大极小算法而是为当前棋盘上每一个空位计算一个“分数”分数越高代表在这个位置落子“越好”然后选择分数最高的位置落子。4.1 评估函数的设计评估函数是AI的“价值观”。我们为棋形定义分数。例如连五直接获胜分数极高例如100000分。活四两头无阻挡的四连子下一步就能成五分数很高例如10000分。冲四一头被堵的四连子下一步也能成五但需要两步分数次之例如5000分。活三、死四、活二等依次递减。我们为每个空位分别计算如果在此处下黑棋和白棋能形成什么棋形然后根据棋形累加分数。最后AI白方的决策逻辑是选择一个位置使得“我白在此处的得分”最高同时“对手黑在此处的得分”最低即最大化我的优势最小化对手的威胁。// 在文档类或一个专门的AI类中 int CMyFiveChessDoc::EvaluatePosition(int row, int col, int player) { int score 0; // 模拟在此位置落子 m_board[row][col] player; // 检查四个方向水平、垂直、主对角线、副对角线 // 这里以水平方向为例其他方向逻辑类似 score EvaluateDirection(row, col, 0, 1, player); // 向右 score EvaluateDirection(row, col, 0, -1, player); // 向左 // ... 垂直和对角线方向 // 恢复棋盘 m_board[row][col] EMPTY; return score; } int CMyFiveChessDoc::EvaluateDirection(int row, int col, int dRow, int dCol, int player) { int count 1; // 当前位置已经有一颗子 int block 0; // 两端被阻挡的情况0表示两端空1表示一端堵2表示两端堵 // 向正方向扫描 for (int i 1; i 4; i) { int newRow row dRow * i; int newCol col dCol * i; if (newRow 0 || newRow BOARD_SIZE || newCol 0 || newCol BOARD_SIZE) { block; // 遇到边界相当于被阻挡 break; } int stone m_board[newRow][newCol]; if (stone player) { count; } else if (stone EMPTY) { break; // 遇到空位停止扫描这个方向 } else { block; // 遇到对手棋子被阻挡 break; } } // 向反方向扫描逻辑同上 for (int i 1; i 4; i) { int newRow row - dRow * i; int newCol col - dCol * i; // ... 类似判断 } // 根据连子数(count)和阻挡情况(block)返回分数 return GetScoreByShape(count, block); } int CMyFiveChessDoc::GetScoreByShape(int count, int block) { if (count 5) return 100000; // 成五 if (count 4) { if (block 0) return 10000; // 活四 if (block 1) return 5000; // 冲四 } if (count 3) { if (block 0) return 2000; // 活三 if (block 1) return 500; // 死三眠三 } if (count 2) { if (block 0) return 200; // 活二 if (block 1) return 50; // 死二 } // ... 其他情况 return 0; }4.2 AI决策与落子有了评估函数AI的决策就简单了遍历棋盘所有空位计算该位置对AI白方的得分选择得分最高的位置落子。void CMyFiveChessDoc::AIMakeMove() { if (m_gameOver || m_currentPlayer ! WHITE) return; int bestRow -1, bestCol -1; int bestScore -1; // 遍历所有空位 for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { if (m_board[i][j] EMPTY) { // 计算在此下白棋的得分进攻 int scoreWhite EvaluatePosition(i, j, WHITE); // 计算在此下黑棋的得分防守即对手的威胁 int scoreBlack EvaluatePosition(i, j, BLACK); // 综合得分倾向于选择自己优势大且能压制对手的位置 int totalScore scoreWhite * 2 scoreBlack; // 进攻权重稍高 if (totalScore bestScore) { bestScore totalScore; bestRow i; bestCol j; } } } } if (bestRow ! -1 bestCol ! -1) { // 调用落子函数 PlaceStone(bestRow, bestCol, WHITE); } }踩坑记录AI的“思考”时间上面的遍历评估算法在15x15的棋盘上每次决策需要计算225个位置每个位置计算4个方向复杂度尚可。但如果评估函数更复杂或者你想实现更深度的搜索如极大极小算法计算量会指数级增长导致UI“卡死”。切记不能在UI线程中进行耗时计算上面代码中我们通过PostMessage异步触发AI计算。更正确的做法是将AIMakeMove()放在一个单独的“工作线程”中执行计算完成后再向主线程发送消息更新UI。在VC6.0中可以使用AfxBeginThread创建工作者线程。这是从初级向中级进阶的关键一步。5. 胜负判定算法的优化实现胜负判定是游戏逻辑的核心必须高效准确。最直观的方法是每次落子后从落子点向四个方向辐射检查。BOOL CMyFiveChessDoc::CheckWin(int row, int col, int player) { // 四个方向向量(行增量 列增量) int directions[4][2] { {1, 0}, {0, 1}, {1, 1}, {1, -1} }; // 垂直水平主对角副对角 for (int d 0; d 4; d) { int count 1; // 当前位置已经有一颗子 int dRow directions[d][0]; int dCol directions[d][1]; // 向正方向检查 for (int i 1; i 5; i) { int newRow row dRow * i; int newCol col dCol * i; if (!IsInBoard(newRow, newCol) || m_board[newRow][newCol] ! player) { break; } count; if (count 5) return TRUE; // 找到五连立即返回 } // 向反方向检查 for (int i 1; i 5; i) { int newRow row - dRow * i; int newCol col - dCol * i; if (!IsInBoard(newRow, newCol) || m_board[newRow][newCol] ! player) { break; } count; if (count 5) return TRUE; } // 注意这里count在正反方向检查后可能大于5但5即判胜 } return FALSE; } BOOL CMyFiveChessDoc::IsInBoard(int row, int col) { return (row 0 row BOARD_SIZE col 0 col BOARD_SIZE); }这个算法的时间复杂度是O(1)只检查落子点周围最多8个方向上的共9个位置因为最多连5子效率非常高。这是游戏逻辑中值得称道的优化点。6. 功能扩展与工程化思考一个基本的五子棋程序已经完成。但要让其更完整、更像一个“产品”我们还需要考虑更多。6.1 悔棋功能的实现悔棋需要记录每一步棋。我们在文档类中已经使用了CArray m_moveHistory。实现悔棋时只需从历史记录中弹出最后一步或两步如果是人机对战需要一次悔掉人和AI的两步并将棋盘上对应的位置重置为空。BOOL CMyFiveChessDoc::Undo(int steps) { if (m_moveHistory.GetSize() steps) return FALSE; for (int i 0; i steps; i) { CPoint lastMove m_moveHistory[m_moveHistory.GetSize() - 1]; m_moveHistory.RemoveAt(m_moveHistory.GetSize() - 1); // 将棋盘上该位置清空 m_board[lastMove.y][lastMove.x] EMPTY; // 注意CPoint的x,y与数组row,col的对应关系 } // 更新当前玩家悔几步就切换几次玩家 for (int i 0; i steps; i) { m_currentPlayer (m_currentPlayer BLACK) ? WHITE : BLACK; } m_gameOver FALSE; // 悔棋后游戏应恢复进行状态 m_winner EMPTY; UpdateAllViews(NULL); return TRUE; }在视图或对话框菜单中调用pDoc-Undo(1)或pDoc-Undo(2)即可。6.2 游戏设置的保存与加载序列化MFC的文档/视图架构天然支持序列化Serialization。我们可以方便地将游戏状态棋盘、当前玩家等保存到文件或从文件加载。在文档类中重写Serialize函数void CMyFiveChessDoc::Serialize(CArchive ar) { if (ar.IsStoring()) { // 保存 ar m_currentPlayer m_gameOver m_winner; for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { ar m_board[i][j]; } } m_moveHistory.Serialize(ar); // CArray 也支持序列化 } else { // 加载 ar m_currentPlayer m_gameOver m_winner; for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { ar m_board[i][j]; } } m_moveHistory.Serialize(ar); } }这样通过MFC框架的“文件”-“保存”/“打开”菜单就能自动调用此功能。6.3 界面美化与交互优化双缓冲绘图在复杂的绘图操作中直接绘制到屏幕DC上可能会引起闪烁。双缓冲技术是先在内存中创建一个位图并绘制完成再一次性贴到屏幕上能有效消除闪烁。在OnDraw中可以使用CMemDC等技巧实现。音效使用PlaySoundAPI函数在落子、获胜等时刻播放简单的WAV音效能极大提升游戏体验。个性化设置通过对话框CDialog让用户选择棋盘颜色、棋子样式、AI难度通过调整评估函数的权重或搜索深度实现等。7. 常见问题与调试技巧实录在VC6.0环境下开发你会遇到一些特有的“坑”。问题1编译时提示“无法找到程序入口”或链接错误。这通常是因为项目设置不对。VC6.0创建项目时要选择“MFC AppWizard (exe)”并在第二步选择“单文档”或“多文档”确保勾选了“文档/视图架构支持”。如果是从空项目开始添加MFC需要手动配置很多库和预编译头对新手不友好。问题2程序运行时点击鼠标没反应。检查视图类中是否添加了ON_WM_LBUTTONDOWN消息映射消息处理函数OnLButtonDown的声明和实现是否正确调试在OnLButtonDown函数开始处设置断点看点击时是否进入。如果没有检查消息映射宏BEGIN_MESSAGE_MAP和END_MESSAGE_MAP是否包围了你的消息处理函数声明。问题3棋盘或棋子绘制不出来或者位置不对。检查OnDraw函数中的绘图坐标计算。确保将棋盘数组下标(row, col)正确转换为屏幕坐标(x, y)。记住屏幕坐标系是x向右y向下。调试在OnDraw中在绘制网格和棋子的代码前后加上pDC-TextOut输出调试信息看看函数是否被调用关键变量的值是否正确。问题4AI下棋速度慢导致界面卡顿。如前所述这是计算耗时操作阻塞了UI线程。解决方案优化评估函数避免不必要的重复计算使用查表法预存常见棋形的分数。使用线程将AIMakeMove放在工作线程中。// 在视图或文档中 void CMyFiveChessView::OnAIMove() { // 禁用界面操作显示“思考中”... AfxBeginThread(AIThreadFunc, (LPVOID)GetDocument()); } UINT AIThreadFunc(LPVOID pParam) { CMyFiveChessDoc* pDoc (CMyFiveChessDoc*)pParam; pDoc-AIMakeMove(); // 这个函数内部不能再直接调用UpdateAllViews // 计算完成后发送自定义消息给主窗口通知其更新视图 ::PostMessage(AfxGetMainWnd()-m_hWnd, WM_AI_MOVE_FINISHED, 0, 0); return 0; }在主框架窗口处理WM_AI_MOVE_FINISHED消息调用UpdateAllViews。问题5在Debug模式下运行正常Release模式下崩溃。这是典型的“发布版问题”。可能原因未初始化的变量确保所有成员变量在构造函数中初始化。Release版优化更激进未初始化变量的值是不确定的。数组越界严格检查所有数组访问的边界BOARD_SIZE是15下标范围就是0到14。使用ASSERT宏ASSERT只在Debug版生效Release版会被移除。不要将关键逻辑写在ASSERT中。用ASSERT_VALID检查指针后仍需做空指针判断。个人调试习惯我习惯在项目早期就打开“编译警告等级”到最高Project Settings - C/C - Warning Level 到 Level 4并把所有警告当作错误来处理。这能帮助你在编码阶段就发现很多潜在问题比如类型转换丢失精度、函数未返回值等。VC6.0的调试器虽然古老但“监视窗口”Watch、”调用堆栈“Call Stack和“内存查看”Memory功能依然强大是解决复杂指针和内存问题的利器。通过这个从零到一的VC6.0五子棋项目你收获的不仅仅是一个游戏程序。你深入理解了Windows消息机制、MFC文档视图架构、GDI绘图、基础AI算法以及面对一个完整项目时的模块化设计和调试思维。这些知识是穿越到任何一个GUI桌面开发领域都不会过时的硬通货。当你用这个“老古董”工具完成了项目再回头去看那些现代化的集成开发环境和框架你会更加理解它们背后的设计哲学与便利所在。