简介:这是一份基于VC++/MFC编写的双人对决围棋程序源码,面向具备一定C++基础、希望借小型游戏项目深入理解MFC框架与Windows图形编程的开发者。程序支持双人轮流落子,并提供适配15、17寸液晶显示器的窗口选项,落子响应迅速,适合作为课程设计、毕业设计参考或MFC入门练手项目。压缩包共24个文件,约36KB,包含7个h头文件与6个cpp源文件,构成视图、文档、主框架等核心类;另有3个bmp位图与2个ico图标用于棋盘、棋子及界面资源,以及dsp、dsw工程文件、rc资源脚本和clw类向导文件,工程结构完整,可直接用Visual Studio打开编译。目前已有171人学习下载。通过阅读源码,读者可掌握MFC文档视图架构、鼠标消息响应、棋盘绘制与落子逻辑等关键实现,并借鉴作者在界面适配与性能优化上的处理思路,是理解VC++游戏编程的实用范例。
1. 双人对决的 VC++ 围棋程序:一份能直接跑起来的 MFC 源码包
前阵子有个做桌面开发的朋友找我,说想找一个能直接编译运行的 VC++ 围棋程序,要求很简单:双人对战、有棋盘绘制、能落子提子、判断胜负。他在网上翻了一圈,要么是残缺的代码片段,要么是控制台版本,要么干脆是论文配图没有源码。后来我帮他拆了一份基于 VC/MFC 的围棋程序源码包,实测在 Visual Studio 环境下能编译、能跑、能两个人轮流下。这份资源的核心价值在于:它把围棋的规则逻辑和 MFC 的绘图、消息响应完整地串了起来,不是那种只画个棋盘就结束的 Demo。适合两类人:一是正在学 VC/MFC 游戏编程、想找一个有完整交互逻辑的练手项目;二是需要快速搭一个棋类对战框架、再往上加 AI 或网络模块的开发者。下面我按实际拆解的顺序,把这份源码的结构、编译方法、核心算法和踩过的坑讲清楚。
2. 拆开源码包:MFC 文档视图架构与围棋数据模型
2.1 为什么这类程序偏爱文档视图架构
拿到源码包后,第一件事是看工程结构。这份 VC++ 围棋程序用的是典型的 MFC 单文档(SDI)架构,App 类、MainFrame、View 类、Doc 类一应俱全。很多人觉得文档视图是老古董,但在棋类程序里它有个实际好处:Doc 类天然适合存放棋盘状态和落子历史,View 类只负责绘制和鼠标响应,逻辑和界面分离得比较干净。你后面想加悔棋、复盘、保存棋谱,直接往 Doc 里塞数据就行,不用动绘图代码。
具体到这份源码,Doc 类里维护了一个二维数组表示棋盘,常见做法是int m_board[19][19],0 表示空点,1 表示黑子,2 表示白子。另外还有几个关键成员:当前执子方、提子数、最后一手位置。View 类里重写了OnDraw做棋盘和棋子的绘制,重写了OnLButtonDown处理鼠标落子。MainFrame 里挂了菜单和工具栏,用来触发新局、悔棋、认输这些操作。
如果你之前只写过对话框程序,这里要转个弯:文档视图的消息流向是 View 先拿到鼠标事件,View 再调用 Doc 的方法去改数据,改完调用UpdateAllViews触发重绘。不要反过来在 Doc 里直接操作 View 的绘图,那样耦合会越来越重。
2.2 棋盘坐标与屏幕坐标的映射
围棋程序最容易翻车的地方不是规则,是坐标换算。屏幕像素是离散的,棋盘交叉点是逻辑坐标,鼠标点下去的位置要吸附到最近的交叉点。这份源码里用了一个比较稳妥的做法:先定义棋盘边距和格子宽度,然后做一次四舍五入。
// 棋盘绘制参数(常见做法) const int MARGIN = 40; // 棋盘边缘留白 const int CELL_SIZE = 30; // 每个格子像素宽度 const int BOARD_SIZE = 19; // 19 路棋盘 // 屏幕坐标转棋盘索引 BOOL CGoView::ScreenToBoard(CPoint point, int& row, int& col) { // 先减去边距,再除以格子宽度,四舍五入到最近交叉点 int x = point.x - MARGIN; int y = point.y - MARGIN; col = (x + CELL_SIZE / 2) / CELL_SIZE; row = (y + CELL_SIZE / 2) / CELL_SIZE; // 边界检查,防止越界写入 if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return FALSE; return TRUE; }这段代码的逻辑说明:MARGIN是棋盘左上角第一个交叉点距离窗口左上角的像素距离,CELL_SIZE是相邻交叉点的间距。加CELL_SIZE / 2再整除,等价于四舍五入,这样鼠标点在两个交叉点中间时也能吸附到较近的那个。参数怎么改:如果你想让棋盘大一点,把CELL_SIZE调到 35 或 40,同时窗口初始尺寸也要跟着调,否则右边和下边会被裁掉。边界检查不能省,我见过有人漏了这一步,鼠标点到棋盘外面直接数组越界,程序当场崩掉。
2.3 落子、提子与气判断的数据流
围棋规则里最核心的是“气”的判断。这份源码把提子逻辑放在 Doc 类的一个TryPlaceStone方法里,流程大致是:先检查该位置是否为空、是否轮到该方;然后临时落子,检查落子后自己有没有气、对方有没有被提;如果自己没气且提不掉对方,就撤销这手棋。
// 简化的落子与提子流程(伪代码结构) BOOL CGoDoc::TryPlaceStone(int row, int col, int color) { if (m_board[row][col] != 0) return FALSE; // 已有子 if (color != m_currentColor) return FALSE; // 不是该方走 m_board[row][col] = color; // 检查四个方向的对方棋子是否无气 int captured = 0; for (int d = 0; d < 4; d++) { int nr = row + dr[d]; int nc = col + dc[d]; if (InBoard(nr, nc) && m_board[nr][nc] == 3 - color) { if (!HasLiberty(nr, nc)) { captured += RemoveGroup(nr, nc); } } } // 如果没提到子,且自己这块也没气,则非法,撤销 if (captured == 0 && !HasLiberty(row, col)) { m_board[row][col] = 0; return FALSE; } // 记录历史,切换执子方 m_history.Add(...); m_currentColor = 3 - color; return TRUE; }逻辑说明:dr和dc是方向数组,常见写法是{ -1, 1, 0, 0 }和{ 0, 0, -1, 1 }。HasLiberty用递归或栈做广度优先搜索,判断一块棋是否还有空邻点。RemoveGroup把无气的对方棋子清空并返回提子数。参数方面,m_currentColor用 1 和 2 表示黑白,3 - color就是切换对方。这里有个容易忽略的点:提子之后要重新检查自己这块棋的气,因为提掉对方可能给自己造出新的气。这份源码在提子后没有再查一遍自己,严格来说在极少数“打劫”变体里会有问题,但双人对战娱乐够用。你要做正式规则,得把这一步补上。
3. 编译与运行:从工程配置到第一手落子
3.1 工程配置与常见编译错误
这份源码包解压后一般是一个.sln加一个.vcxproj,或者老版本的.dsp。用 Visual Studio 打开后,先别急着按 F5。我一般会先做三件事:检查平台工具集、检查字符集、检查 MFC 是否安装。
平台工具集在项目属性 → 常规里看。如果源码是老版本 VC++ 6.0 或 VS2010 的,直接升级可能会报一堆error C2065未声明标识符。常见做法是右键项目 → 重定解决方案目标,选你当前装的工具集。字符集方面,MFC 项目分 Unicode 和多字节,这份源码如果用的是CString和_T()宏,一般两种都能编,但如果里面混了char*和LPCSTR,切到 Unicode 就会报类型不匹配。稳妥起见先看项目属性 → 高级 → 字符集,跟源码里的字符串写法保持一致。
MFC 组件不是默认安装的。如果你打开工程提示cannot open include file 'afxwin.h',说明没装 MFC。去 Visual Studio Installer 里勾选“使用 C++ 的桌面开发”下的 MFC 支持,装完重启 VS。
3.2 棋盘绘制:OnDraw 里的双缓冲
MFC 的OnDraw直接往屏幕 DC 上画,落子频繁时闪屏会很明显。这份源码里用了双缓冲,常见做法是在内存 DC 里画好整张棋盘,再一次性 BitBlt 到屏幕。
void CGoView::OnDraw(CDC* pDC) { CGoDoc* pDoc = GetDocument(); CRect rect; GetClientRect(&rect); // 内存 DC 与位图 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); memDC.SelectObject(&bmp); // 背景 memDC.FillSolidRect(&rect, RGB(220, 180, 120)); // 画网格线 for (int i = 0; i < BOARD_SIZE; i++) { memDC.MoveTo(MARGIN, MARGIN + i * CELL_SIZE); memDC.LineTo(MARGIN + (BOARD_SIZE - 1) * CELL_SIZE, MARGIN + i * CELL_SIZE); memDC.MoveTo(MARGIN + i * CELL_SIZE, MARGIN); memDC.LineTo(MARGIN + i * CELL_SIZE, MARGIN + (BOARD_SIZE - 1) * CELL_SIZE); } // 画棋子 for (int r = 0; r < BOARD_SIZE; r++) for (int c = 0; c < BOARD_SIZE; c++) if (pDoc->m_board[r][c] != 0) DrawStone(&memDC, r, c, pDoc->m_board[r][c]); // 一次性贴到屏幕 pDC->BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); }逻辑说明:CreateCompatibleDC创建的内存 DC 默认只有 1x1 像素,必须选入一张和客户区等大的位图才能画。FillSolidRect填木色背景,MoveTo/LineTo画 19 条横线和 19 条竖线。棋子用Ellipse画圆,黑子填黑、白子填白加黑边。参数方面,RGB(220, 180, 120)是棋盘木色,你可以按喜好调。CELL_SIZE和MARGIN必须和坐标换算里用的一致,否则棋子会画偏。双缓冲的代价是多占一张位图的内存,19 路棋盘窗口不大,可以忽略。
3.3 鼠标落子与消息响应
View 类里用类向导添加WM_LBUTTONDOWN消息处理,或者手动在消息映射里加ON_WM_LBUTTONDOWN()。处理函数里先调ScreenToBoard换算坐标,再调 Doc 的TryPlaceStone,成功就UpdateAllViews重绘。
void CGoView::OnLButtonDown(UINT nFlags, CPoint point) { int row, col; if (!ScreenToBoard(point, row, col)) return; CGoDoc* pDoc = GetDocument(); if (pDoc->TryPlaceStone(row, col, pDoc->m_currentColor)) { pDoc->UpdateAllViews(NULL); // 触发重绘 } CView::OnLButtonDown(nFlags, point); }逻辑说明:ScreenToBoard返回 FALSE 说明点到棋盘外,直接忽略。TryPlaceStone返回 TRUE 表示落子合法,这时才刷新视图。UpdateAllViews(NULL)会让所有关联视图重绘,单文档程序里就是当前视图。参数方面,nFlags里可以判断是否按了 Shift、Ctrl,这份源码没用,但你可以扩展成“按住 Ctrl 落子显示坐标”之类的功能。注意CView::OnLButtonDown要放在最后调用,否则默认处理可能会覆盖你的逻辑。
3.4 胜负判断与终局处理
双人对战的胜负判断比 AI 简单,常见做法是双方都点“停一手”后,程序数子或数目。这份源码里给了一个简化方案:菜单里点“结束对局”,弹对话框让用户输入双方目数,或者直接按提子数加棋盘上活棋估算。严格数目需要做死子判定和双活处理,代码量不小。如果你只是做娱乐对战,可以让双方自己数,程序只负责记录提子数。
// 简化的终局统计 void CGoDoc::EndGame() { int blackScore = m_capturedByBlack; // 黑提子数 int whiteScore = m_capturedByWhite; // 遍历棋盘,统计各方棋子数(简化处理,未算双活) for (int r = 0; r < BOARD_SIZE; r++) for (int c = 0; c < BOARD_SIZE; c++) { if (m_board[r][c] == 1) blackScore++; else if (m_board[r][c] == 2) whiteScore++; } CString msg; msg.Format(_T("黑方:%d,白方:%d"), blackScore, whiteScore); AfxMessageBox(msg); }逻辑说明:这里把提子数和盘面棋子数直接相加,没有做贴目和双活判定,属于娱乐级实现。参数方面,如果你要加贴目,在whiteScore上加一个常数即可。注意AfxMessageBox在 View 里也能调,但放在 Doc 里更符合数据归数据、界面归界面的原则。
4. 避坑排查:双人对战程序最容易翻车的五个地方
4.1 落子后棋子画偏或画到棋盘外
现象:鼠标点交叉点,棋子出现在旁边一格,或者点到边缘时棋子跑到窗口外面。原因:坐标换算里的MARGIN和CELL_SIZE跟绘图时用的不一致,或者边界检查写成了row <= BOARD_SIZE导致越界。解决:把绘图和换算的参数抽成同一个常量,放在头文件里共用;边界检查用row < 0 || row >= BOARD_SIZE,不要用<=。
4.2 提子后程序卡死或递归爆栈
现象:吃掉对方一大块棋时程序无响应,或者直接崩溃。原因:HasLiberty或RemoveGroup用了递归但没有标记已访问节点,遇到循环引用时无限递归。解决:搜索时用一个bool visited[19][19]数组标记,或者改用栈做迭代。这份源码里如果没带 visited,你自己补一个,几行代码的事。
4.3 编译报错“无法打开 afxwin.h”
现象:打开工程直接编译失败,提示找不到 MFC 头文件。原因:Visual Studio 安装时没勾选 MFC 组件。解决:打开 Visual Studio Installer,修改当前版本,在“单个组件”里搜 MFC,勾选对应版本安装。装完重启 VS,再重新生成。
4.4 落子闪烁严重
现象:每下一手整个棋盘闪一下,棋子多的时候更明显。原因:OnDraw里直接往屏幕 DC 画,没有双缓冲。解决:按 3.2 节的做法加内存 DC 和位图。如果加了还闪,检查是不是在OnLButtonDown里调了Invalidate而不是UpdateAllViews,前者会强制擦背景。
4.5 悔棋后棋盘状态错乱
现象:点悔棋,棋子消失了但执子方没换回来,或者提掉的子没恢复。原因:悔棋只撤销了棋盘数组,没恢复m_currentColor和提子记录。解决:在 Doc 里维护一个历史栈,每手棋记录落子位置、颜色、被提棋子列表。悔棋时反向操作:恢复被提棋子、清掉落子、切换执子方。这份源码如果历史记录不完整,你可以自己加一个CArray或std::vector来存。
5. 进阶改造:把双人对战扩展成带 AI 或网络对战的框架
这份源码跑通之后,很多人会想往上加东西。我一般建议先别急着写 AI,先把“棋局状态序列化”做掉。具体做法是给 Doc 类加两个方法:Serialize和Deserialize,把棋盘数组、当前执子方、提子数、历史记录写进一个二进制文件或文本文件。这样你后面做网络对战、复盘、AI 训练数据生成,都有统一的数据出口。
// 简化的序列化:保存棋局到文件 void CGoDoc::Serialize(CArchive& ar) { if (ar.IsStoring()) { ar << m_currentColor << m_capturedByBlack << m_capturedByWhite; for (int r = 0; r < BOARD_SIZE; r++) for (int c = 0; c < BOARD_SIZE; c++) ar << m_board[r][c]; } else { ar >> m_currentColor >> m_capturedByBlack >> m_capturedByWhite; for (int r = 0; r < BOARD_SIZE; r++) for (int c = 0; c < BOARD_SIZE; c++) ar >> m_board[r][c]; } }逻辑说明:MFC 的CArchive支持基本类型和CString的流式操作,<<和>>会自动处理。参数方面,m_board是int数组,直接循环写入即可。注意读写顺序必须完全一致,否则反序列化会错位。做完这一步,你可以把保存的文件发给别人,对方用同样的程序打开就能复盘。
再往上走,加 AI 的常见做法是接一个外部引擎,通过标准输入输出通信,或者直接嵌一个简单的蒙特卡洛搜索。网络对战则是在 Doc 的落子方法里加一层网络消息发送,收到对方落子消息后调用同样的TryPlaceStone。这两条路都要求你先有干净的棋局状态管理,否则改到后面会发现自己被界面逻辑缠死。
我自己的习惯是:每次拿到一个能跑的棋类源码,先不急着加功能,而是把“新局、落子、提子、悔棋、保存、读取”这六个操作各跑十遍,确认状态一致。从那以后我每次改这类程序,都强制走一遍这个回归流程,省了很多后悔药。希望这份拆解能帮你把这份 VC++ 围棋程序真正用起来。
本文还有配套的精品资源,点击获取