用VC++编写双人围棋程序:界面绘制与吃子算法全解析
简介一份基于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。为什么不选MFCMFC封装太多处理消息映射的时候虽然有向导辅助但很多底层细节反而被包住了出了问题不好排查。纯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也就是鼠标左键按下。在这个消息的处理分支里取出鼠标坐标换算成棋盘行列判断当前格子是否为空如果为空就落子落子后检查是否有吃子然后切换到对方回合最后触发重绘。整个流程可以写成一个简单的状态机用几个全局变量追踪当前对局状态比如currentPlayer、gameOver等。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是MoveToEx加LineTo线的颜色用深棕色或者黑色宽度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.dll或VCRUNTIME140.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资源泄漏是个隐蔽的问题。每次创建CreatePen、CreateBrush、CreateCompatibleBitmap后用完了必须用DeleteObject释放否则程序跑上几百手棋之后GDI对象会耗尽画面就开始花屏。排查这个问题的笨办法是用任务管理器查看进程的GDI对象数量如果在连续落子过程中这个数字不断上涨铁定是泄漏了。我在写界面绘制代码时养成了一个习惯所有创建出来的GDI对象要么在用完后立刻DeleteObject要么用SelectObject把旧的选回来再删除这样一来这块的隐患基本消除了。重绘残留主要是InvalidateRect的使用问题。有时候棋盘上出现不该存在的棋子残影多半是绘制区域没有完全包含需要更新的部分或者游戏状态没有被正确清除。我处理这个问题时最土的办法是在每次落子后直接InvalidateRect(hwnd, NULL, TRUE)让整个窗口区域都重绘一遍。性能上虽然有一点浪费但这几百号棋子的绘制量根本感觉不到差异换来的是画面状态绝对不会乱这个取舍是值得的。再分享一个小技巧如果你发现落子之后画面没更新或者位置不对先检查一下坐标换算的方向是不是反了。我当时就犯过xy互换的错界面上点右下角棋子出现在左上角查了好一会儿才发现是行和列的赋值顺序写反了。所有跟坐标有关的换算建议写完之后先做一次边界点的测试比如点击棋盘四角和中心确认生成的行列号对不对这样能省掉后续一大半调试时间。这个项目做完之后我最大的感触是它逼着我把以前零散学到的Windows编程知识真正连成了线。消息循环怎么跑、GDI怎么画图、递归算法怎么落地、程序交互怎么设计每一项原本都是死板的术语但当你亲手在黑白的棋盘上落下一子的时候这些概念就都活起来了。如果你也想拿它练手我建议从最简单的9路棋盘开始确认吃子逻辑没问题后再扩到19路节奏会舒服很多。本文还有配套的精品资源点击获取