简介一份可以直接编译运行的VC双人围棋对弈程序源码基于MFC设计面向刚开始学习VC或对游戏编程感兴趣的开发者。程序支持双人在同一台电脑上轮流落子自动完成棋盘绘制与胜负判断并针对15/17寸液晶屏幕提供了窗口尺寸选择运行反应迅速。资源共24个文件以.h头文件与.cpp实现文件为核心辅以位图、图标等界面资源还包含标准MFC工程配置文件压缩包仅36KB结构典型完整。已有171人学习浏览。借助该源码读者可深入理解MFC文档/视图架构、消息映射机制、GDI绘图操作以及围棋棋盘状态管理、鼠标坐标换算、落子规则判定等实用算法同时掌握双人轮流操作的状态切换、边界条件处理等细节。适合作为继入门之后的第一个完整小项目进行研读与二次开发亦可直接编译后用于朋友间的娱乐对战。1. 双人对决围棋程序为什么这个题目到现在仍值得用 VC 做两个人共用一台电脑轮流点击鼠标在 19 路棋盘上完成一局围棋。所谓“可以双人对决的 VC 围棋程序”就是不依赖服务器、不匹配在线对手、全部规则逻辑在本机完成的围棋对弈工具。它既是很多初学者练手 MFC/GDI 的经典项目也是验证提子、禁着点、劫这些围棋规则算法的捷径。选择 VC 而不是 Web 技术是因为 GDI 能直接控制每个像素消息循环天然适合鼠标事件规则代码也能和界面代码分层得特别清楚。这篇文章就从坐标映射一路讲到劫的判断给出可复现的代码骨架和几个实战里容易翻车的点。2. 棋盘与棋子的绘制从 19 路网格到双人落子交互2.1 先定坐标体系屏幕像素和棋盘交叉点之间隔着一个舍入围棋棋盘是 19 条横线和 19 条竖线交叉出的 361 个落子点不是 19 乘 19 个格子。绘制时最容易犯的错是把格子中心当成交叉点。设棋盘边距margin 30交叉点间距cell 24第 x 列交叉点的屏幕 X 坐标就是margin x * cell。鼠标点击后换算成棋盘坐标关键是舍入int ScreenToBoardX(int screenX) { int x (int)floor((screenX - margin) / cell 0.5); if (x 0 || x 18) return -1; // 棋盘外返回无效 return x; }这里的0.5再floor就是四舍五入点击偏差在半格以内都能落到正确交叉点。返回 -1 而不是夹紧到边界是为了防止玩家在棋盘外误点导致落子。另一个容易忽略的点是屏幕 Y 坐标增长方向向下ScreenToBoardY只需要同样公式不要像数学坐标系那样翻转。如果你偷懒不写无效判断就会出现“点棋盘外面也能下棋”的体验双人对决时两个人会不断为这种误触吵架。设计窗口大小时客户区宽高可以直接按公式算margin * 2 cell * 18。若还要显示状态栏高度再额外加 40 像素左右。很多人先把窗口拉大再居中棋盘结果窗口拉伸时棋盘偏到一侧WM_SIZE 里也没有做适配。我一般把视图的最小尺寸固定下来并在 OnSize 里重新计算margin让棋盘始终保持在客户区中间。对于 19 路棋盘cell取 22 到 30 之间都合适过小人眼难分辨过大棋子显得空洞。2.2 用 GDI 绘制棋盘最小可跑通的 OnDraw 代码MFC 的视图类在 WM_PAINT 时触发 OnDraw。我把棋盘状态放在一个 19x19 的二维数组里绘制函数只根据数组把画面画出来不在绘制时改状态这样重绘不会丢数据。void CGoView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(rc); pDC-FillSolidRect(rc, RGB(230, 180, 120)); // 木色底 CPen pen(PS_SOLID, 1, RGB(10, 10, 10)); pDC-SelectObject(pen); for (int i 0; i 19; i) { int x margin i * cell; int y margin i * cell; pDC-MoveTo(margin, y); pDC-LineTo(margin 18 * cell, y); pDC-MoveTo(x, margin); pDC-LineTo(x, margin 18 * cell); } // 星位19 路的八个星位坐标固定 int star[][2] { {3,3},{3,9},{3,15}, {9,3}, {9,15}, {15,3},{15,9},{15,15} }; for (int i 0; i 8; i) { CPoint c(margin star[i][1] * cell, margin star[i][0] * cell); pDC-Ellipse(c.x - 2, c.y - 2, c.x 2, c.y 2); } for (int row 0; row 19; row) { for (int col 0; col 19; col) { if (g_board[row][col] 0) continue; CBrush brush(g_board[row][col] 1 ? RGB(10, 10, 10) : RGB(245, 245, 245)); CPoint c(margin col * cell, margin row * cell); pDC-SelectObject(brush); pDC-Ellipse(c.x - 11, c.y - 11, c.x 11, c.y 11); } } }cell为 24棋子直径 22比间隔小一点视觉上不会粘连。白棋用RGB(245,245,245)而不是纯白在浅色木纹底上更容易分辨边界。先画网格再画棋子能避免棋子压过网格线后留下断线。星位坐标我用了star[i][0]表示行、star[i][1]表示列避免和屏幕坐标混在一起。这段代码没有恢复旧画刷实际项目里最好在函数末尾把pen.OldObj和brush.OldObj还原否则 GDI 对象会越攒越多这在 5.4 里会专门说。2.3 鼠标消息与双人轮转把点击变成一次有效落子双人对决的交互核心是左键点击后判断落点是否有效有效则写入数组然后换对方走。用 ClassWizard 给视图类添加 WM_LBUTTONDOWN 处理void CGoView::OnLButtonDown(UINT nFlags, CPoint point) { int col ScreenToBoardX(point.x); int row ScreenToBoardY(point.y); if (col 0 || row 0) return; // 点在棋盘外 if (g_board[row][col] ! 0) return; // 已有棋子的交叉点 if (!IsLegalMove(row, col, g_turn)) return; // 规则校验 g_board[row][col] g_turn; g_turn (g_turn 1) ? 2 : 1; // 黑 1 白 2 交替 Invalidate(); // 请求重绘 }把鼠标处理和规则校验分开后后面加禁着点、劫的时候都不需要再动这个函数。Invalidate是异步重绘比RedrawWindow更平滑不至于在鼠标消息里直接绘制。nFlags未使用会出 C4100 警告如果团队规范严格可以加UNREFERENCED_PARAMETER(nFlags)。这里还要注意双人对决不需要保存“当前是黑方还是白方”之外的任何状态所以g_turn就是整个轮转逻辑的全部。2.4 状态区与提子计数让双方都知道现在该谁走双人对决里至少要明确提示“黑棋落子”还是“白棋落子”否则两个人会为了谁走错争半天。我在视图底部画一块状态区同时显示双方吃子数void DrawStatus(CDC* pDC, CRect rc) { CString s; s.Format(_T(当前%s 黑吃%d 白吃%d), g_turn 1 ? _T(黑棋) : _T(白棋), g_blackCaptures, g_whiteCaptures); pDC-TextOut(rc.left 10, rc.bottom - 30, s); }用_T包裹字符串是为了兼容 VC 6.0 和 Visual Studio 2008 之后默认的 Unicode 工程。CString::Format里的中文字符串如果不加_T在 Unicode 编译下会报类型不匹配。状态区也可以在 MainFrame 的 CStatusBar 里做但画在视图底部更直观而且双缓冲绘制时能和棋盘一起刷新不用额外处理消息。3. 围棋规则落地提子、禁着点与劫双人对决最容易翻车的地方3.1 用深度优先找“气”规则实现的地基围棋里一颗棋子的生死看“气”也就是相邻的空交叉点。一个连通块只要还有一口气这块棋就活着没气就该被提掉。实现时要先写一个相邻点生成函数然后 DFS 标记整个连通块并统计气。注意气不能重复计数。const int dx[] {-1, 1, 0, 0}; const int dy[] {0, 0, -1, 1}; void CollectBreath(int board[19][19], int row, int col, int color, bool visited[19][19], bool breath[19][19]) { visited[row][col] true; for (int k 0; k 4; k) { int nr row dx[k], nc col dy[k]; if (nr 0 || nr 19 || nc 0 || nc 19) continue; if (board[nr][nc] 0) { breath[nr][nc] true; // 空点就是气标记去重 } else if (board[nr][nc] color !visited[nr][nc]) { CollectBreath(board, nr, nc, color, visited, breath); } } }相邻点用 dx/dy 数组表达避免写四次 if。breath是标记数组不是集合遇到同一个空点只写一次自然去重。递归最多穿透整块棋19 路棋盘上连通块最多 361 个点不会爆栈。如果你把代码改成 25 路棋盘建议换成显式栈的 DFS否则 Windows 线程栈默认 1MB 也可能扛不住极端棋形。这里还有一个细节breath必须每块棋单独清零我见过有人把它放在函数外累计导致上一块的气算到下一块头上吃子判断跟着错。这段代码里我特意加了board参数因为后面判断提子时要用临时盘面模拟不能直接操作g_board。很多初学者把CollectBreath写死成访问全局数组临时盘面一进来就全乱套。3.2 提子逻辑先模拟落子再清理无气方围棋行棋规则是落子后先看对方有没有被提再看自己有没有气。我习惯先复制一份棋盘做模拟这样判断禁着点、劫都容易回滚。bool IsLegalMove(int row, int col, int color) { int temp[19][19]; memcpy(temp, g_board, sizeof(g_board)); temp[row][col] color; // 第一步提掉所有无气的对方棋子 for (int k 0; k 4; k) { int nr row dx[k], nc col dy[k]; if (nr 0 || nr 19 || nc 0 || nc 19) continue; if (temp[nr][nc] ! 3 - color) continue; // 3-color 是对手颜色 bool visited[19][19] {false}; bool breath[19][19] {false}; CollectBreath(temp, nr, nc, temp[nr][nc], visited, breath); if (!HasAnyBreath(breath)) { RemoveGroup(nr, nc, temp[nr][nc], temp); // 从临时盘面提掉 } } // 第二步看自己刚落下的子是否有气 bool visited[19][19] {false}; bool breath[19][19] {false}; CollectBreath(temp, row, col, color, visited, breath); return HasAnyBreath(breath); }3 - color是个常用技巧黑 1、白 23 - 1 23 - 2 1。HasAnyBreath遍历breath数组只要有 true 就说明还有气。所有提子逻辑都放到模拟盘面上执行最后检查自己是否有气的顺序不能反先提对方再看自己这个顺序正好对应围棋规则。RemoveGroup同样用 DFS 把同色连通块清成空位同时统计提子数量存到g_blackCaptures或g_whiteCaptures里。这段代码有个容易被忽略的点在判断提对方时visited和breath数组应该对每个相邻的对方连通块重新清零。我上面是在循环内定义的数组天然每轮重置。如果你把数组定义在循环外就必须在每次CollectBreath前 memset否则上一块棋的气会被下一块棋看到导致吃子判断时有时无。3.3 禁着点与劫不处理就等着双人对决翻车禁着点是落子后自己没气、也提不了对方棋子的点。上面的IsLegalMove已经处理了这个情况模拟落子、先提对方、再看自己自己无气就返回 false。绝大多数新手写的围棋程序都栽在这一步因为他们只检查“这个点是不是空的”。劫是更隐蔽的问题。经典劫形黑方提白一子白方如果立刻在原位置提回黑一子盘面会回到上一步规则禁止这种全局同形再现。简化实现可以只处理单劫struct LastMoveInfo { int row, col; // 上一步落子位置 int takenRow, takenCol; // 上一步被提单子的位置 int capturedCount; // 上一步提子数 }; bool IsKo(int row, int col, int color) { if (lastMove.capturedCount ! 1) return false; if (row ! lastMove.takenRow || col ! lastMove.takenCol) return false; // 当前落子还要正好提掉对方一颗才会形成循环 return currentCapturedCount 1; }这里currentCapturedCount需要在执行提子逻辑时统计。简化劫判断对日常双人对决够用但覆盖不了双重劫、盘角曲四这类循环劫。完整解法是对整个局面做 Zobrist 哈希每次落子后把新局面的哈希值和历史记录比较重复就禁止。Zobrist 哈希给每个交叉点黑/白各分配一个 64 位随机数落子时异或进去提子时异或出去计算成本极低。给自己的程序先实现单劫玩到“提回被禁止”的反馈后再升级到 Zobrist会顺很多。漏掉劫判断的程序不会崩溃但会让双人对决变成“两个人不断提同一颗子”的无限循环这是最典型的翻车现场。所以哪怕先只做一步劫也比完全不做好。3.4 吃子统计与死子标记让提子看得见双人对决需要给玩家即时反馈提掉对方几颗子、当前双方被吃多少。实现时在RemoveGroup里计数存到g_blackCaptures和g_whiteCaptures。如果不在界面显示吃子数双人会很快忘记这一局谁占了上风。可以把两个数字画到棋盘右侧或状态栏随重绘更新。还有一个细节提子后g_dead标记终局时用的死子要同步清掉否则会出现棋盘上已经没有子但计数仍认为它存在的怪事。吃子统计会影响后面的胜负判定所以数据结构上我建议把g_board、g_blackCaptures、g_whiteCaptures、lastMove全部放在一个GameState结构体里悔棋和序列化都方便。如果你只用一个裸全局数组后面写 SGF 导出和回放时会发现到处都要传参代码很快臭。4. 数子与胜负判定双人对决结束怎么算出谁赢4.1 终局与死子别让玩家手动清子双人对决程序要提供一个“结束”按钮进入死子确认。最简单可靠的方案是让玩家点击对方死子程序把标记的死子拿掉后按当前盘面数空白交叉点。死子判断比想象中麻烦因为有些棋形有气但被围住算死还是算活需要棋理判断程序很难自动。所以我不建议做全自动判定常见做法是让玩家在终局后依次点击对方死子程序把它们当作已提掉处理。int blackScore 0, whiteScore 0; // 被标记为死子的子先移除 for (int r 0; r 19; r) { for (int c 0; c 19; c) { if (g_dead[r][c]) g_board[r][c] 0; } } // 对每个空点做区域归属 for (int r 0; r 19; r) { for (int c 0; c 19; c) { if (g_board[r][c] ! 0) continue; int owner FindAreaOwner(r, c); // 1黑 2白 0公共 if (owner 1) blackScore; else if (owner 2) whiteScore; } }FindAreaOwner用 DFS 扩展同一块空区域同时记录区域边界上出现了哪几种棋子。边界既有黑又有白时这块空域算公共点不归属任何一方。一个简化实现是用栈做区域生长int FindAreaOwner(int row, int col) { std::vectorCPoint stack; stack.push_back({row, col}); g_visitedArea[row][col] true; int areaType 0; // 位0黑位1白 while (!stack.empty()) { CPoint p stack.back(); stack.pop_back(); for (int k 0; k 4; k) { int nr p.x dx[k], nc p.y dy[k]; if (nr 0 || nr 19 || nc 0 || nc 19) continue; if (g_board[nr][nc] 1) areaType | 1; else if (g_board[nr][nc] 2) areaType | 2; else if (!g_visitedArea[nr][nc]) { g_visitedArea[nr][nc] true; stack.push_back({nr, nc}); } } } if (areaType 1) return 1; if (areaType 2) return 2; return 0; // 同时接触黑白公共空点 }这里g_visitedArea需要在数子前整体清零。areaType用位标记如果有黑有白就是公共点。这个实现没有处理禁入点但终局空域里不会有禁入点可以接受。死子确认阶段最怕误点击。我的做法是先进入“标记模式”玩家每点一个棋子那个子就高亮比如画红圈双方都认可后点“确认提走”程序才真正清空并进入数子。这样比只点一次更公平也避免手滑点错导致整盘重算。4.2 终局流程的状态机别让玩家在胜负界面里迷路死子确认和数子不是一次性操作通常要走几步点击“结束”进入死子标记标记完成后点“确认”然后显示胜负。如果在第一步就数子玩家误点一个棋子导致整个局面不对。我建议用一个小的状态枚举enum EndState { kPlaying, kMarkDead, kConfirmed };kMarkDead时鼠标点击改死子标记而不是落子kConfirmed后禁止再改棋盘。每个状态入口都有对应的提示文字比如标记死子时标题栏显示“点击对方死子右键取消标记”确认后显示“黑 x 子 vs 白 y 子”。这种状态机代码不多但能避免很多终局误操作。还有一点死子标记不能标记活棋。程序可以做个简单检查当玩家点击一个仍有气的连通块时弹提示“该连通块还有气是否强制标记”这样做虽然不能解决所有棋理争议但至少不会因为手滑把整块活棋当死子。4.3 中国规则和日韩规则的差别参数要可调双人对决程序如果只算“围到的空点”日韩规则和中国规则在大多数场景结果一致但在有吃子的局面会不同。日韩规则要数双方提掉的死子、俘虏填到对方地盘里再数中国规则数活子加空点。实际程序需要把贴目/贴子做成可调参数不能写死一个值。下表是常用的配置规则计算方式贴目/贴子典型胜负线中国规则数活子 空点黑贴 3.75 子7.5 目黑须 185 子日韩规则数空点 提子数黑贴 6.5 目黑 184 胜185 胜多自定义空点 固定贴目可设置 0 到 7.5可设置这里的“目”和“子”换算关系是 1 子等于 2 目。黑贴 3.75 子是 7.5 目对应黑收完单官盘面领先 7 目半时数子才能赢半子。双人对决常见场景是朋友之间随便下不贴目也可以所以终局时显示“黑 x 子 vs 白 y 子未贴目”比强行出一个胜负更友好。我实现时把这些参数放在一个结构体里开局对话框能改终局结算时再读取不要让数子函数自己猜。4.4 让子设置与贴目换算朋友之间下让子棋很常见双人对决程序也应该支持。在新建对局对话框里设置让子数0、2、3、4、5、6、7、8、9程序按让子规则预先在星位放子让 2 子放边上两个星位让 3 子加左下星位让 4 子加右下星位让 5 子及以上再加边星。这个并非绝对标准但符合现实让子棋习惯。让子后黑棋仍然先走终局时黑方的贴目补偿要相应调整。最简单的方式是让子数每多 1黑方贴目减少 1 目最终保留半目或整数目由玩家选。写一个CalculateWinner()函数返回枚举内部读取规则参数别在 OnLButtonDown 里做胜负计算。5. VC 版本与界面实现的常见坑从 VC 6.0 到 Visual Studio 2008 再到 2015-20225.1 现象高 DPI 下棋盘模糊、点击坐标漂移系统缩放设为 150%棋盘看起来比实际小鼠标点击位置和落子位置错半个格。原因MFC 默认没有感知 DPI系统对窗口进行位图拉伸缩放坐标映射失真。解决在 Visual Studio 2008 或 vc 2015-2022 工程里给项目加 manifest 声明 dpiAware或者在 InitInstance 里调用SetProcessDPIAware()。常见做法是项目属性里勾选“Per Monitor DPI Aware”。VC 6.0 老工程没有这个选项可以把画布固定按 96 DPI 设计不接受缩放界面小但位置准。这里要提醒的是只设 DPI 感知还不够棋盘绘制的cell最好根据GetDpiForWindow动态调整否则字体和棋子大小在 150% 缩放下仍然偏小。但从双人对决角度位置准确优先级最高大小其次。5.2 现象OnDraw 里每次重绘都闪成流眼泪双人对决时落一子闪一下甚至拖动窗口像下雪。原因默认 WM_ERASEBKGND 先擦背景再画擦和画之间暴露底色。解决重写 OnEraseBkgnd 直接返回 TRUE不擦背景再加双缓冲。在 OnDraw 里创建内存 DC先画到内存位图再一次 BitBlt 到屏幕。BOOL CGoView::OnEraseBkgnd(CDC*) { return TRUE; // 不要擦背景交给 OnDraw 整体覆盖 } void CGoView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(rc); if (rc.IsRectEmpty()) return; CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOld memDC.SelectObject(bmp); DrawBoard(memDC); // 所有绘制都画到内存 DC pDC-BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }这里注意一定要在 OnEraseBkgnd 返回真否则系统在 OnDraw 之前会发 WM_ERASEBKGND 把客户区填成白色双缓冲也救不回来。还有一个隐蔽坑窗口最小化时 GetClientRect 返回 0CreateCompatibleBitmap 会失败所以先判断rc.IsRectEmpty()。如果你画出的是内存 DC 但忘了用 BitBlt 贴回屏幕白忙一场。5.3 现象VC 6.0 的 CString 到 Visual Studio 2008 后代码编译不过原因VC 6.0 默认使用 ANSI 字符集CString 是CStringAVS 2008 或 vc 2015-2022 的 MFC 默认是 UnicodeCString 解析为CStringW。如果字符串字面量没加_T()或 L就会出现“无法从 char* 转换为 CString”或“LPCTSTR 与 char* 类型冲突”。解决全工程字符串用_T()、TCHAR、_tprintf不要写裸 char。老代码迁移过来后第一件事就是改字符串这个跑不掉。可参考老一代 MFC 程序里常见的写法AfxMessageBox(_T(当前轮到黑棋));这个坑在双人对决程序里特别明显因为要显示“黑棋落子”“白棋落子”“黑方胜”“白方胜”这类中文几乎每个界面字符串都要处理。我见过不少从网上下载的老工程在 VS2015 里打开后报几十个错误几乎全和字符串编码有关规则算法本身反而没问题。5.4 现象Release 版没问题Debug 版一落子就断言失败常见于 VC 6.0 SP6 或 VS2008 的老工程。表现是断言指向CreateSolidBrush或SelectObject失败。原因在 OnDraw 里创建了画刷但忘记删除对象或让 CBrush 对象生命周期异常。解决把画刷作为栈对象在局部作用域内使用或使用CBrush brush(...)后记得pDC-SelectObject(pOld)还原。在循环里反复创建画刷而不释放会耗尽 GDI 对象越下到后面越容易触发。从 VC 6.0 到 vc 2015-2022这个问题一直在Debug 版还会额外计数句柄所以表现更明显。写一个绘制函数时尽量把所有 GDI 对象限制在函数内部创建和释放不要让它们活到下一帧。5.5 现象vc 2015-2022 编译老代码时报一堆 C4996 警告原因VS2008 后的 CRT 默认把fopen、strcpy等标记为 deprecated要求用_s版本。双人对决程序里写棋谱文件时会用到fprintf这时候最容易刷屏。解决项目属性里预处理器加_CRT_SECURE_NO_WARNINGS或者逐个改成安全版本。我一般在 stdafx.h 里加一行#define _CRT_SECURE_NO_WARNINGS老工程能少几百条警告。如果以后还想维护建议还是改成fopen_s毕竟警告也是一种噪音。这个坑不影响运行但会让新手误以为自己代码写错浪费很多排查时间。6. 让双人对决更顺手悔棋与对局记录6.1 悔棋用状态栈实现“后悔药”双人对决两个人都会有看漏的时候悔棋是刚需。最直接的做法是把每次落子后的整个棋盘压栈。struct MoveState { int board[19][19]; int turn; int lastRow, lastCol; }; std::vectorMoveState g_history; void Undo() { if (g_history.empty()) return; MoveState st g_history.back(); memcpy(g_board, st.board, sizeof(g_board)); g_turn st.turn; g_history.pop_back(); Invalidate(); }这个方案内存占用很小361 字节一步一盘棋 300 手也才 100 KB比记录增量修改更省心。悔棋后要重置提子统计和劫状态否则会出现上一步劫位置残留影响后面落子的情况。具体做法是在 MoveState 里也保存g_blackCaptures、g_whiteCaptures和lastMove悔棋时一并还原。6.2 对局记录导出 SGF 文件可以复盘SGFSmart Game Format是围棋棋谱通用格式。双人对决程序导出 SGF 后可以在任何围棋软件里复盘。基本格式是(;GM[1]FF[4]SZ[19]PB[Black]PW[White]PL[B];B[pd];W[dd];B[pq])。每个落子用坐标编码用字母 a 到 s 表示 0 到 18。程序里维护一个std::vectorstd::string每步合法落子追加;B[pd]或;W[dd]终局后拼接写文件。注意 SGF 坐标是先列后行且从 a 开始不是从零开始新手很容易把pd写成dp。让子棋还要写AB[dd][dq][pd][pq];PL[W]之类的起始标记否则复盘软件不认。我一般会在导出时顺手写上DT日期和EV对局名方便归档。做完了这些你会发现双人对决围棋程序的难点早已不是“双人”而是“围棋”。我自己第一次写完时遇到的最荒诞的 bug 是劫判断里忘了看提子数量导致黑提白后白不能立刻提回但可以提同一连通块的另一颗子这在规则上其实是允许的只是玩家会以为是 bug。后来我养成一个习惯每改完一个规则函数就用“黑先、白再、黑白重复走到某个局部”的固定棋谱做一遍回归确认提子数、禁着点、劫在这三步里都和真棋理一致。这个习惯帮我节省了不知道多少血泪时间也希望帮到你。本文还有配套的精品资源点击获取