新闻详情

新闻详情

首页 / 资讯中心 / 详情

VC++双人对决围棋实现:规则引擎、提子与打劫避坑指南

发布时间:2026/9/30 10:18:31来源:尧图网络
VC++双人对决围棋实现:规则引擎、提子与打劫避坑指南
简介这套源码提供一份基于VC与MFC框架编写的围棋双人对弈程序完整工程适合正在学习Windows程序设计或游戏逻辑的C开发者对照研读。程序界面支持15英寸、17英寸液晶屏切换落子响应快代码结构围绕文档视图框架展开包含主框架、视图与文档类以及专门的绘图类清晰展示消息映射、界面刷新和坐标转换等常见处理手法。压缩包共24个文件以.h头文件与.cpp源文件为主另有bmp位图、ico图标和rc资源描述文件可直接用VC打开重建工程并编译运行。整个资源包仅36KB小巧但功能完整从棋盘绘制、落子判定到双人胜负判断都有涉及目前已有170人学习下载。对希望通过短小项目快速掌握MFC应用开发整体脉络的读者来说尤其是一份不错的入门参考。1. 可以双人对决的VC围棋程序最难的不是画棋盘是把规则先写对想用一个 VC 程序实现双人对决的围棋很多人的第一反应是先把棋盘画出来。实际做过一轮就会明白画 19×19 的网格只占工作量很小一部分真正让程序从「能落子」变成「能下棋」的是提子判断、禁入点判定和打劫记忆这几条规则。这篇笔记面向两类人一类想用 Win32/GDI 练手把 C 基础捡起来另一类在为后续做 AI 搜索做准备需要一套可靠的双人规则引擎。整篇会从棋盘数据结构讲到扫子提子再给出一手完整落子流程、避坑清单和验证方法全程只依赖 VC 自带的 Win32 与标准库不引第三方依赖。2. 先定棋盘数据结构从二维数组到四个方向的落子合法性校验2.1 用 21×21 数组而不是 19×19边界哨兵层省掉一半判断最常见的做法是定义 19×19 的二维数组下标 0 到 18 对应用户看到的 19 路棋盘。但我在实际写的时候会故意把数组开成 21×21四周各多出一圈。这一圈不落子专门放一个特殊值 WALL让边缘判断变成值判断而不是坐标判断。四个方向扫描邻接棋子时越界访问会直接命中哨兵层不需要再写if (row - 1 0)这类分支。const int BOARD_SIZE 19; const int GRID_SIZE BOARD_SIZE 2; // 上下左右各留一圈哨兵 const int EMPTY 0; const int BLACK 1; const int WHITE 2; const int WALL -1; const int dirRow[4] { -1, 1, 0, 0 }; const int dirCol[4] { 0, 0, -1, 1 }; void initBoard(int board[GRID_SIZE][GRID_SIZE]) { for (int r 0; r GRID_SIZE; r) { for (int c 0; c GRID_SIZE; c) { if (r 0 || r GRID_SIZE - 1 || c 0 || c GRID_SIZE - 1) { board[r][c] WALL; } else { board[r][c] EMPTY; } } } }这里的关键参数是 GRID_SIZE 和 WALL。19×19 棋盘的行列下标分别从 1 到 19而不是从 0 到 18用户坐标和数组坐标的换算关系因此固定为board[row 1][col 1]。我统一在进入核心逻辑之前完成这套换算之后所有函数都只认数组坐标不再关心用户屏幕坐标。2.2 状态编码与像素坐标转换不知道这四个值落子点永远对不上棋盘上的每个交叉点只有四种状态空、黑、白、边界墙。用 0、1、2、-1 四个整数表示比用 bool 数组加边界判断更直观。接下来要把鼠标点击的像素坐标换算成格子坐标这一步对双人对战体验影响很大——换算错了棋子会落在两格之间或者点击最边上一路时频繁落空。bool screenToCell(int x, int y, int margin, int cellSize, int row, int col) { if (x margin || y margin) { return false; } col (x - margin) / cellSize; row (y - margin) / cellSize; if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) { return false; } return true; }margin 是棋盘外框到窗口客户区左边缘和上边缘的像素距离cellSize 是相邻两条网格线的像素间距。两者应该由窗口大小动态算出而不是写死常见做法是在 WM_SIZE 里取客户区宽高分别除以 19取两者较小值作为 cellSize再让棋盘居中这样窗口拉伸时棋盘不会变形。注意这个函数只做整除取整落在两条线之间的点击会自动归到最近的交叉点玩家手感上会稍好些。2.3 落子合法性拆成三层边界、占用、禁入点单次落子的校验可以拆成三层。第一层已经被哨兵层挡住了数组里只要不是 WALL 就一定在棋盘内第二层检查board[row][col] EMPTY排除重复落子第三层是禁入点判断也叫自杀判断。前两层在鼠标响应里立刻能完成第三层必须放到核心逻辑里因为它依赖整块棋子的气数计算结果。我一般把第三层判断交给tryPlace函数而不是在界面层提前拦截。原因很实际禁入点的判断和提子规则共享同一套扫子逻辑界面层调用它会导致整块棋被扫描两遍后期改规则时很容易忘记同步。所以我的界面代码里只保留边界与占用检查真正的禁入判断统一收口到底层函数中。3. 把扫子与提子做成函数气数计算的三种盘面来源与一手完整落子流程3.1 收集同色整块气是整块棋共享的不能只看落点四周很多初版围棋程序把「气」理解成刚落下的那颗子四周的空点数。这在布局阶段勉强能用一旦两块黑棋连成一条长龙这种算法立刻翻车——棋块中间的气被重复统计明明只剩一口气的棋程序却认为还有十几口气。正确做法是先收集同色相邻的所有棋子形成一个棋块 StoneGroup再统计这个棋块所有成员直接相邻的空点用去重数组保证每个空点只计一次。#include vector #include utility struct StoneGroup { std::vectorstd::pairint, int stones; }; void collectGroup( int board[GRID_SIZE][GRID_SIZE], int row, int col, bool visited[GRID_SIZE][GRID_SIZE], StoneGroup group ) { if (visited[row][col]) { return; } visited[row][col] true; int color board[row][col]; if (color EMPTY || color WALL) { return; } group.stones.push_back({ row, col }); for (int i 0; i 4; i) { int nr row dirRow[i]; int nc col dirCol[i]; if (board[nr][nc] color) { collectGroup(board, nr, nc, visited, group); } } }collectGroup 只沿着上下左右四个方向扩散并且只进入同色格子。visited 数组防重复访问避免两块同色棋在角部形成循环引用时无限递归。这里的参数 row、col 是起始点我一般直接用刚落下的那颗子的坐标调用方不需要提前知道整块棋的形状。3.2 统计气数与提取无气块libSet 去重这一步不能省完成整块收集后气数统计就变成一个双重循环遍历棋块内每一颗棋子看它四面八方的邻居是不是 EMPTY。如果邻居是 EMPTY并且还没有被这个棋块统计过就计入气数。这一步的去重数组 libSet 很容易被省略省略后的症状是棋块有两个出口挨得很近时气数被夸大该提的棋提不掉。int countGroupLiberties( int board[GRID_SIZE][GRID_SIZE], int row, int col ) { bool visited[GRID_SIZE][GRID_SIZE] {}; StoneGroup group; collectGroup(board, row, col, visited, group); int libSet[GRID_SIZE][GRID_SIZE] {}; int libCount 0; for (size_t i 0; i group.stones.size(); i) { int r group.stones[i].first; int c group.stones[i].second; for (int d 0; d 4; d) { int nr r dirRow[d]; int nc c dirCol[d]; if (board[nr][nc] EMPTY libSet[nr][nc] 0) { libSet[nr][nc] 1; libCount; } } } return libCount; }countGroupLiberties 的时间复杂度是 O(棋块大小 外气数)对每步落子都调用一次完全够用。真正要留意的是 libSet 的生命周期它只对本棋块有效不能声明成函数外的静态数组否则相邻两次统计会互相污染。3.3 一手完整的落子流程先提对方无气子再检查己方无气把落子流程按下棋人的直觉拆容易写成「先检查自己的棋有没有气没气就不让下」。但在围棋规则里这是错的。真正顺序是先把棋子临时放到棋盘上清掉对方所有无气棋块然后再看己方这颗子所在的棋块还有没有气。如果清掉对方之后己方依然无气这次落子才是禁入点。bool tryPlace( int board[GRID_SIZE][GRID_SIZE], const Move mv, std::vectorMove captured ) { if (board[mv.row][mv.col] ! EMPTY) { return false; } board[mv.row][mv.col] mv.player; captured.clear(); int opp (mv.player BLACK) ? WHITE : BLACK; bool visitedGroup[GRID_SIZE][GRID_SIZE] {}; // 第一步清掉对方所有无气棋块 for (int r 1; r BOARD_SIZE; r) { for (int c 1; c BOARD_SIZE; c) { if (board[r][c] ! opp || visitedGroup[r][c]) { continue; } StoneGroup group; collectGroup(board, r, c, visitedGroup, group); if (countGroupLiberties(board, r, c) 0) { for (size_t i 0; i group.stones.size(); i) { int sr group.stones[i].first; int sc group.stones[i].second; board[sr][sc] EMPTY; captured.push_back({ sr, sc, opp }); } } } } // 第二步再查己方清掉对方之后仍无气才是禁入点 if (countGroupLiberties(board, mv.row, mv.col) 0) { board[mv.row][mv.col] EMPTY; for (size_t i 0; i captured.size(); i) { Move m captured[i]; board[m.row][m.col] opp; } return false; } return true; }这个函数是整盘围棋程序的核心。captured 用来记录被提掉的对方棋子界面层拿到它之后可以用来刷新提子计数。注意撤销分支里不仅要还原落子点还要把已经提掉的棋子全部放回去否则玩家会看到棋盘凭空少了几颗对方棋子。顺序乱了悔棋和打劫都会跟着出错。4. 双人对决的交互实现同屏落子、悔棋回退与 HTTP 服务端 API 扩展4.1 双人对战的鼠标响应回合切换只改一个变量本地双人对决最常见的形态是一个窗口、一个鼠标黑白双方轮流点击落子。界面层需要维护的全局状态很少当前盘面、当前轮到谁、历史走法栈。在 Win32 窗口过程里处理 WM_LBUTTONDOWN 即可。case WM_LBUTTONDOWN: { int x GET_X_LPARAM(lParam); int y GET_Y_LPARAM(lParam); int row 0, col 0; if (!screenToCell(x, y, margin, cellSize, row, col)) { break; } Move mv{ row 1, col 1, game.currentPlayer }; std::vectorMove captured; if (tryPlace(board, mv, captured)) { game.history.push_back(mv); game.captureLog.push_back(captured); game.currentPlayer (game.currentPlayer BLACK) ? WHITE : BLACK; InvalidateRect(hwnd, NULL, FALSE); } break; }这里row 1, col 1就是把用户坐标换算成带哨兵的数组坐标对应第 2 章约定的下标偏移。回合切换只是把 currentPlayer 在 BLACK 和 WHITE 之间翻转双人对战的棋盘状态不需要额外保存两套。InvalidateRect 触发 WM_PAINT重绘时用 GDI 画网格并遍历棋盘画圆黑白方各画实心圆。4.2 悔棋与回放捕获日志必须和历史栈同步弹出双人对战中悔棋是刚需功能尤其针对初学者。实现时除了 history 栈还要存每手落子后提掉的对方棋子列表 captureLog。悔棋时恢复被提棋子的顺序必须和当时提子顺序完全一致。bool undoMove( int board[GRID_SIZE][GRID_SIZE], std::vectorMove history, std::vectorstd::vectorMove captureLog, int currentPlayer ) { if (history.empty()) { return false; } Move last history.back(); history.pop_back(); std::vectorMove captured captureLog.back(); captureLog.pop_back(); board[last.row][last.col] EMPTY; for (size_t i 0; i captured.size(); i) { board[captured[i].row][captured[i].col] captured[i].player; } currentPlayer last.player; return true; }注意悔棋之后 currentPlayer 要恢复成上一手落子的那方而不是简单地再次翻转。我用currentPlayer last.player直接回退避免连续悔棋多手时状态错乱。最稳妥的方案其实是整个棋盘快照但 21×21 数组不过几百字节用向量存完整快照在 19 路棋局中足够支撑上千手代码更简单查找 bug 更容易。4.3 从同屏到异地用 VC 访问 HTTP 服务端 API 接管网络双人对决同屏双人对战做完之后很多人会想把它扩展成局域网或远程对战。常规做法是加一个服务器保存棋局两个客户端通过 HTTP 服务端 API 交换落子信息。VC 侧我一般用 libcurl 发送 JSON 请求服务端返回校验结果客户端收到合法响应后才更新本地棋盘。size_t writeCallback(void *ptr, size_t size, size_t nmemb, std::string *data) { >move32 playerBLACK row7 col7 captured2 last_capture1 move33 playerWHITE row16 col16 captured0 last_capture0重点检查连续captured大于 0 的那几手尤其是紧挨着的两手打劫逻辑是否正确就在这些记录里。除了自对弈第二个验证是谱面回放把一盘标准棋谱按步喂给程序每喂十手暂停一次人工对照棋谱上的提子数。这比随机对弈更可靠因为棋谱里包含各种边角定型、打劫、扑、倒扑能直接把规则引擎的边界状态压出来。我的习惯是每次改完规则、跑完一百局随机对弈之后把日志里的提子数和棋盘上实际棋子数对一遍。那些只在无人观看时出现的对不齐往往是打劫或悔棋的记录出了偏差。这套流程走完再回头补界面的落子动画和音效都来得及。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

闲鱼客服咨询AI流量赋能,闲鱼科技重塑智能体验新标杆 2026/9/30 11:25:55

闲鱼客服咨询AI流量赋能,闲鱼科技重塑智能体验新标杆

近期,由湖南改变生物科技有限公司主办、本因内酵未徕品牌协办的“生物科技健康论坛暨AI赋能大健康产业启动会”在长沙市步步高福鹏喜来登酒店隆重举行。活动以“AI流量赋能实体破局——中小企业增长峰会”为主题,汇聚全国大健康行业专家、中小企业负责人、机构代表及…

阅读更多 →
专有云企业版V3.7.1云服务总线CSB全流程部署与调用避坑指南 2026/9/30 11:25:55

专有云企业版V3.7.1云服务总线CSB全流程部署与调用避坑指南

简介:这是阿里云专有云企业版V3.7.1的云服务总线(CSB)用户指南PDF文档,面向企业架构师、运维人员与集成开发工程师,系统讲解CSB在私有云、公有云及混合云环境中实现服务注册、发现、路由、安全与监控的核心机制&#x…

阅读更多 →
元宝    LeetCode 130. 被围绕的区域 Golang实现 2026/9/30 11:25:48

元宝 LeetCode 130. 被围绕的区域 Golang实现

LeetCode 130 的核心不是「找被包围的 O」,而是反过来:先保住所有和边界连通的 O,剩下的 O 才是真被包围的。 思路(DFS 反向标记) 扫描矩阵四条边界(第一行、最后一行、第一列、最后一列)边界上…

阅读更多 →
linux kernel struct 之 ptdesc 2026/9/30 11:25:48

linux kernel struct 之 ptdesc

struct ptdesc 的定义在 Linux 内核的 include/linux/mm_types.h 文件中(早期版本曾放在 include/linux/pgtable.h)。它的设计目标是将页表元数据从 struct page 中拆分出来,目前通过完全覆盖(overlay) struct page 的…

阅读更多 →
侵入式双向链表 2026/9/30 11:25:48

侵入式双向链表

侵入时双向链表不需要单独进行内存分配,跟随具体结构进行分配,详细数据结构:typedef structure list_node {struct list_node *next;struct list_node *prev; } list_t;链表初始化初始化链表,哨兵自己成环。list->next list; …

阅读更多 →
元宝    LeetCode 131. 分割回文串 Rust实现 2026/9/30 11:25:47

元宝 LeetCode 131. 分割回文串 Rust实现

Rust 实现 LeetCode 131 的核心逻辑和 Python 完全一致,依然是回溯(Backtracking)。不过在 Rust 里需要稍微注意字符串处理和递归函数的写法。 方法一:回溯 实时回文判断(最直观,面试首选)AC R…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉