用VC++编写双人围棋程序:界面绘制与吃子算法全解析
2026/9/8 15:15:45 网站建设 项目流程

简介:一份基于VC++与MFC编写的双人对弈围棋程序,面向C++初学者及对游戏编程感兴趣的开发者,适合作为理解窗口交互、绘图与棋局逻辑的入门范例。资源共24个文件,包含核心头文件与实现文件(.h/.cpp)、棋盘界面用位图(.bmp)和程序图标(.ico),以及MFC工程配置文件(.dsp/.dsw/.rc等),压缩包大小仅36KB,结构轻量。已有169人学习下载。该项目虽不复杂,但完整实现了双人对局功能:支持适应15、17寸液晶的窗口切换,落子响应迅速;代码中体现了GDI绘图、消息映射、文档视图结构等MFC常见知识点。通过研读源文件,可以学会如何组织围棋棋谱数据、处理鼠标交互与胜负判断,也可在此基础上扩展人机对战或网络对战。对于想要亲手完成一个微型游戏程序、巩固VC++技能的读者,是一份不错的参考源码。 下围棋的人大概都有过这种念头:如果能自己动手写一个围棋程序,想加什么功能就加什么功能,那该多过瘾。我最早接触VC++的时候,也是从一些练手的小项目开始的,而围棋这种逻辑清晰、界面直观、规则又有深度的游戏,天然适合作为Windows桌面程序开发的实践项目。今天想分享的就是这样一个题目:用VC++编写一个支持双人对决的围棋程序。它不需要人工智能,不需要联网对战,就是两个人坐在同一台电脑前,轮流落子,程序负责画棋盘、判吃子、算胜负。这个项目看起来不大,但真正动手做的时候,你会发现它把Windows编程的基础知识几乎都串起来了:窗口创建、消息循环、GDI绘图、鼠标交互、数据结构设计、算法实现,一个都不少。

如果你正在学VC++,或者想找一个既能练手又不至于烂尾的Windows编程项目,这个双人围棋程序是个非常合适的选择。它的代码量不大,几百行到一千行左右就能完成,但每个环节都有值得琢磨的细节。这篇文章会从整体设计思路开始,逐步拆解数据结构、核心算法、界面绘制和常见坑点,最后附上完整的实现思路和排查经验,希望能帮你少走些弯路。

1. 项目整体设计与技术选型

1.1 核心需求与功能边界

在动手写代码之前,先把需求边界划清楚。这个项目的核心场景是双人本地对战,也就是俗称的“热座模式”,两个玩家共用一台电脑,黑白双方轮流操作。基于这个场景,程序的必要功能包括:标准19路棋盘(也可以兼容9路和13路)、黑白双方轮流落子、吃子判定与提子、打劫规则的简单处理、整局结束时的胜负判定,以及基本的界面交互,比如鼠标点击落子、棋谱重开等。

不需要的功能也要明确排除,这一点很重要。这个项目不做AI对弈,不做网络对战,不保存棋谱文件,不做复杂的打劫全局判定。把这些排除掉,项目的复杂度就能控制在一个合理的范围内。我见过不少新手在这个项目上半途而废,原因多半是刚开始就想着加AI、加悔棋、加计时器,结果核心的棋盘逻辑还没写明白,代码就已经乱成一团了。

1.2 技术选型:VC++加上纯Win32 API

技术栈的选择上,我用的是Visual Studio 2017社区版,配合纯Win32 API,没有用MFC,也没有用Qt。为什么不选MFC?MFC封装太多,处理消息映射的时候虽然有向导辅助,但很多底层细节反而被包住了,出了问题不好排查。纯Win32 API虽然代码写起来繁琐一点,但每一步发生了什么你都清清楚楚。用VS2017新建一个Windows桌面应用程序项目,入口函数是WinMain,窗口过程是WndProc,这套组合是这个项目最合适的骨架。

另外,项目里不需要依赖第三方库,棋盘绘制用GDI就能胜任。GDI的画线、画圆、填充功能,画围棋棋盘和棋子刚好够用。学习和调试成本都比较低,真遇到问题随便一搜或翻翻文档,资料也特别多。选择VC++还有一个现实的原因:Windows原生程序的编译部署相对简单,可执行文件丢给对方就能跑,不太需要担心运行库的问题。

有一种情况值得提前说明,就是“VC++ runtime repair tool”相关的报错,这是很多人在别的电脑上运行自己写的VC++程序时会遇到的问题。程序写好后,如果换到一台干净的机器上运行,有时会提示缺少MSVCP140.dll或者VCRUNTIME140.dll之类的文件,这通常是因为目标机器没有安装对应版本的Visual C++ Redistributable。解决方法是把项目配置里的运行库从“/MDd”(调试动态库)改成“/MT”(静态链接运行库),这样生成的exe就不依赖外部的DLL了。这一点对发布程序的人来说特别有用,后面我会再细说。

2. 程序设计:数据结构和模块划分

2.1 棋盘数据表示与坐标约定

围棋棋盘是19乘以19的网格,最直观的数据结构是一个二维数组。对于双人围棋程序来说,用int board[19][19]就够了,每个格子的值用0表示空、1表示黑子、-1表示白子。很多资料里习惯用1和2,但用1和-1有个好处,就是对弈双方轮流落子时,只要把当前落子方的值取反就能切换身份,代码写起来很简洁。

坐标约定也要一开始就定清楚。我习惯用board[row][col],其中row是从0到18的行索引,对应棋盘从上到下的方向,col是从0到18的列索引,对应从左到右的方向。在界面坐标和棋盘坐标之间做换算的时候,需要定义一个棋盘左上角边距和格子大小的常量。比如格子大小是30像素,边距是20像素,那么坐标(row, col)对应的像素坐标就是(col * 30 + 20, row * 30 + 20)。换算公式提前写好,后面处理鼠标点击就方便了。

2.2 程序模块划分与文件结构

虽然这个项目规模不大,但把代码分成几个模块仍然是值得的。一个常见的做法是把文件分成三类:棋盘逻辑、界面绘制、程序入口。有一位做过类似项目的程序员朋友告诉我,他当时把逻辑和绘制完全分开,棋盘逻辑放在board.cpp里,界面相关代码放在main.cpp里,再用一个board.h头文件做接口声明,后期加悔棋功能时改起来非常省事。

我自己的经验是,逻辑和界面分离的原则在这个项目里要严格执行。棋盘数据的修改只允许通过特定的函数进行,比如placeStone(board, row, col, player)removeStone(board, row, col),Window过程里收到鼠标消息后,只是调用这些接口,而不会直接去改数组里的值。这样做的好处是,核心逻辑可以脱离界面单独测试。比如我可以写一个控制台版本的测试代码,直接调用placeStone去验证吃子逻辑对不对,完全不用启动整个窗口程序。

2.3 窗口创建和消息循环

窗口创建的流程在VC++里是相对固定的:注册窗口类、创建主窗口、进入消息循环。这里有一个细节值得注意,就是窗口类的注册中要设置好背景画刷,否则窗口在重绘时会闪烁。我用的是(HBRUSH)GetStockObject(WHITE_BRUSH),把背景色设为白色,这样跟棋盘整体的浅色基调一致,视觉上也干净。

消息循环是Windows程序的引擎。双人围棋程序需要处理的鼠标消息主要是WM_LBUTTONDOWN,也就是鼠标左键按下。在这个消息的处理分支里,取出鼠标坐标,换算成棋盘行列,判断当前格子是否为空,如果为空就落子,落子后检查是否有吃子,然后切换到对方回合,最后触发重绘。整个流程可以写成一个简单的状态机,用几个全局变量追踪当前对局状态,比如currentPlayergameOver等。

3. 核心算法:吃子判定与提子实现

3.1 理解围棋的“气”和吃子条件

围棋的吃子规则听起来简单:一颗棋子上下左右四个方向的空交叉点,就是它的“气”。当一颗棋子或一组相连棋子的气全部被对方占据时,这组棋子就被吃掉了,要从棋盘上拿走。但真正实现起来,难点在于“一组相连棋子”的判定。两颗同色棋子在上下左右方向上相邻,就算是一块的,它们的气要联合计算。这就需要一个遍历算法,把所有相连的同色棋子找出来,同时统计它们的空邻居数量。

判断吃子时有一个典型的场景:黑棋在某个位置落子,落完以后先看这个落子点本身有没有气,如果没有气,说明黑棋自己这一块可能要被吃掉,这时候要先检查周围有没有白棋的气等于0,如果有,先把白棋提掉,然后再看黑棋是否还有气。这个地方不能想当然地处理,否则会出现黑子落在被白棋包围的地方,程序错误地判定为自尽,而实际上这手棋恰恰是先提掉对方再活下来。

3.2 泛洪填充算法的应用

计算一块棋子的所有气,标准做法是广度优先搜索或者深度优先搜索。我用的是深度优先搜索,也就是递归遍历。算法的核心思路是:从一个棋子出发,依次访问上下左右四个邻居,如果邻居是同色棋子就继续递归,如果邻居是空位就记为一个气,如果邻居是异色棋子就跳过。需要一个visited数组防止重复访问同一个棋子,这个数组用局部变量传递就好。

核心代码大概长这样:

void findLiberties(int board[BOARD_SIZE][BOARD_SIZE], int row, int col, bool visited[BOARD_SIZE][BOARD_SIZE], int color, int& libertyCount, bool hasLiberties[BOARD_SIZE][BOARD_SIZE]) { if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return; if (visited[row][col]) return; if (board[row][col] == color) { visited[row][col] = true; // 递归遍历四个方向 findLiberties(board, row + 1, col, visited, color, libertyCount, hasLiberties); findLiberties(board, row - 1, col, visited, color, libertyCount, hasLiberties); findLiberties(board, row, col + 1, visited, color, libertyCount, hasLiberties); findLiberties(board, row, col - 1, visited, color, libertyCount, hasLiberties); } else if (board[row][col] == EMPTY) { if (!hasLiberties[row][col]) { hasLiberties[row][col] = true; libertyCount++; } } }

这里有个容易踩的坑:计算气的时候要统计的是“独立的气的个数”,而不是简单的邻居空位数。比如一块棋子的两个邻居都指向同一个空点,这个空点只算一口气。所以用一个hasLiberties数组来标记哪些空点已经被算过,比单纯累加更靠谱。

3.3 落子后的完整处理流程

落子后的处理逻辑是整个程序里最容易出错的地方,我实际调试时花了不少时间。一次成功的落子应该执行这几步:把当前落子方的棋子放到棋盘上;检查当前落子点所在的棋块还有没有气,如果没有,先处理对方棋子的提子;处理完提子以后,再检查当前落子点的棋块是否还有气,如果仍然没气,说明这是一手自杀棋,要回退这个落子,提示玩家重新落子。

这个顺序不能乱。有一种很常见的情况是:黑棋在一个四面被白棋围住、但白棋本身也只有最后一口气的位置上落子。这个时候必须先检查白棋的气,如果白棋没气就把白棋提掉,这样黑棋落点就有了气,落子合法。如果反过来先检查黑棋自己的气,就会误判为自杀。在写代码时,我是把这两个检查分成两个独立的函数,先调检查对方,再调检查自己,顺序通过函数调用来保证。

提子的实现相对简单:选定一块棋,调用递归函数把所有同色的相连棋子取出,改成空位,然后把被提掉的数量记录下来。这些细节决定了围棋规则的完整性,是程序的核心价值所在。

4. 界面绘制与交互实现

4.1 GDI绘制棋盘和黑白棋子

棋盘绘制是程序的“门面”,也是用户最直观的感受。用GDI绘制19路棋盘其实不复杂:确定棋盘的起点坐标,也就是左上角的边距,然后画19条横向和19条纵向的直线,形成网格。我用的画线API是MoveToExLineTo,线的颜色用深棕色或者黑色,宽度1像素,视觉上中规中矩。

棋子用Ellipse函数绘制。黑棋是黑色填充的圆,白棋是白色填充的圆,但白色棋子不能直接画在白色的棋盘上,否则会看不到边界。我的做法是给白棋画了一个灰色或者黑色的外边框,填充色用白色。有一个小技巧是先用CreatePen创建粗一点的画笔来画外圈,再换细画笔填充内部,这样棋子的立体感会强一些,棋盘看起来也更精致。实测下来,线宽2像素的黑色画笔画白棋的外框,内填充纯白,效果在这类练手项目里算看得过去的。

4.2 鼠标点击与坐标换算

鼠标交互是双人围棋最核心的交互方式。在WM_LBUTTONDOWN消息里,lParam参数的低16位是x坐标,高16位是y坐标。拿到坐标后,根据棋盘起点和格子大小做一次整数运算,换算成棋盘行列号。

这里有个细节经常被忽略:玩家点击的位置不一定正好落在交点正中央。坐标换算出结果后,我习惯判断一下点击位置和最近交点的距离,如果距离超过格子大小的一半,就忽略这次点击。这样做可以避免玩家点到格子里但离交点太远时,棋子落在不准确的位置,体验很怪。

落子成功后,还要考虑一个状态反转的问题。双人对局里,每次有效落子后,currentPlayer要从1变成-1,或者从-1变成1。同时需要用InvalidateRect来触发窗口重绘,绘制最新的棋盘状态。这里还有一个交互层面的优化:可以在鼠标移动消息WM_MOUSEMOVE里做一个“棋子跟随”的提示,也就是鼠标靠近有效交叉点时,显示一个半透明的落子预览。这个功能对提升交互体验帮助很大,玩家能提前知道自己会把棋子下在哪。实现方式也很简单,在绘制消息里根据鼠标位置额外画一个圆,但要注意必须在鼠标离开棋盘区域时清除这个预览状态,不然会留下残影。

4.3 双缓冲绘图与闪屏问题的解决

直接用GDI在窗口上绘制棋盘时,如果每画一个棋子就调用一次Ellipse,会出现很明显的闪烁。这是因为每次重绘都会先清空整个窗口,再逐笔绘制,绘制过程用户肉眼可见,窗口就会闪。

解决办法是双缓冲绘图。网上关于双缓冲的教程很多,核心思路是先创建一块内存兼容的DC,在内存DC上完成所有绘制,绘制完成后一次性把整块内存内容BitBlt到窗口DC上。这样用户看到的是完整的画面,不会注意到中间过程。代码结构一般是:

HDC hdc = GetDC(hwnd); HDC memDC = CreateCompatibleDC(hdc); HBITMAP memBitmap = CreateCompatibleBitmap(hdc, clientWidth, clientHeight); SelectObject(memDC, memBitmap); // 在memDC上绘制所有内容 BitBlt(hdc, 0, 0, clientWidth, clientHeight, memDC, 0, 0, SRCCOPY); DeleteObject(memBitmap); DeleteDC(memDC); ReleaseDC(hwnd, hdc);

双缓冲对围棋程序来说几乎是必须的。棋盘加棋子有几十个绘制对象,没有双缓冲,落子时画面闪烁会明显到让人怀疑程序有Bug。实测下来,加了这个优化之后,整个画面刷新流畅很多。

5. 踩过的坑和排查心得

5.1 环境问题:VC++ Runtime与运行库依赖

很多新手写完程序,在自己电脑上跑得好好的,发给别人却打不开,弹出的通常是缺少MSVCP140.dllVCRUNTIME140.dll的提示。这个问题在VC++开发的程序里相当常见,网上搜“VC++ runtime repair tool”能找到很多工具,但根子上的解决办法是在项目属性里设置运行库的链接方式。

具体操作是:在VS2017里打开项目属性,选择“C/C++”下的“代码生成”,把“运行库”从“多线程调试DLL(/MDd)”改成“多线程(/MT)”。这样运行库会被静态链接到exe里,生成的文件体积会大一些,但拿到别的机器上就能直接运行,不用再装Redistributable包。如果你是发布给别人用的,这个设置一定要提前改好。

5.2 逻辑问题:边界数组越界和打劫误判

围棋程序的逻辑Bug里,数组越界是最容易犯的。因为递归遍历四个方向时,如果不检查下标边界,直接访问board[row-1][col],当row等于0的时候就会访问到board[-1][col],导致未定义行为。这个问题排查起来很恼火,因为程序不一定马上崩溃,可能只是某个数据被改掉了,表现出随机性的错乱。我后来在递归函数开头统一加了下标检查,越界不再往里走,问题就彻底解决了。

打劫规则的判定也是一大难点。围棋里“全局同形再现”的禁止规则,理论上是判断整个棋盘状态是否回到上一手之前的样子。简化版的实现方法是:每次落子后,把棋盘状态哈希成一个特征值,存到一个历史栈里,如果发现新的状态和上一回合的状态相同,就判为劫争,禁止落子。这个简化方案在实际的双人对局中基本够用,毕竟双人下棋过程中出现复杂打劫的频率相对低,而完整实现全局打劫判定,复杂度会上升一个量级,不适合入门项目。

5.3 界面问题:GDI资源泄漏和重绘残留

GDI资源泄漏是个隐蔽的问题。每次创建CreatePenCreateBrushCreateCompatibleBitmap后,用完了必须用DeleteObject释放,否则程序跑上几百手棋之后,GDI对象会耗尽,画面就开始花屏。排查这个问题的笨办法是用任务管理器查看进程的GDI对象数量,如果在连续落子过程中这个数字不断上涨,铁定是泄漏了。我在写界面绘制代码时养成了一个习惯,所有创建出来的GDI对象,要么在用完后立刻DeleteObject,要么用SelectObject把旧的选回来再删除,这样一来,这块的隐患基本消除了。

重绘残留主要是InvalidateRect的使用问题。有时候棋盘上出现不该存在的棋子残影,多半是绘制区域没有完全包含需要更新的部分,或者游戏状态没有被正确清除。我处理这个问题时,最土的办法是在每次落子后直接InvalidateRect(hwnd, NULL, TRUE),让整个窗口区域都重绘一遍。性能上虽然有一点浪费,但这几百号棋子的绘制量根本感觉不到差异,换来的是画面状态绝对不会乱,这个取舍是值得的。

再分享一个小技巧:如果你发现落子之后画面没更新,或者位置不对,先检查一下坐标换算的方向是不是反了。我当时就犯过xy互换的错,界面上点右下角,棋子出现在左上角,查了好一会儿才发现是行和列的赋值顺序写反了。所有跟坐标有关的换算,建议写完之后先做一次边界点的测试,比如点击棋盘四角和中心,确认生成的行列号对不对,这样能省掉后续一大半调试时间。

这个项目做完之后,我最大的感触是,它逼着我把以前零散学到的Windows编程知识真正连成了线。消息循环怎么跑、GDI怎么画图、递归算法怎么落地、程序交互怎么设计,每一项原本都是死板的术语,但当你亲手在黑白的棋盘上落下一子的时候,这些概念就都活起来了。如果你也想拿它练手,我建议从最简单的9路棋盘开始,确认吃子逻辑没问题后再扩到19路,节奏会舒服很多。

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

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

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

立即咨询